Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows
Master removed the stdio demo (#702c8cc30) — accept the deletion; this branch's packChunks passthrough survives in acp-demo (auto-merged), and cli-demo/tui-demo arrived from master without one (the follow-up snapshot PR decides which demos expose the switch). Generated catalogs regenerated over merged sources; the hand-written session.md durability paragraph re-weaves this branch's lossless-encoding wording with master's invariant- companion sentence.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -21,17 +20,15 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader {
|
||||
return header
|
||||
}
|
||||
|
||||
async function expectParallelFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
|
||||
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
const [cause] = (error as AggregateError).errors as unknown[]
|
||||
expect(cause).toBeInstanceOf(Error)
|
||||
expect((cause as Error).message).toMatch(message)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toMatch(message)
|
||||
return
|
||||
}
|
||||
throw new Error('expected parallel flush to reject')
|
||||
throw new Error('expected flush to reject')
|
||||
}
|
||||
|
||||
async function freshRoot(): Promise<string> {
|
||||
@@ -49,21 +46,6 @@ afterEach(async () => {
|
||||
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function rejectDirectorySync(code: string): Promise<void> {
|
||||
const handle = await open(root, 'r')
|
||||
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
|
||||
await handle.close()
|
||||
const realSync = proto.sync
|
||||
vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
|
||||
if ((await this.stat()).isDirectory()) {
|
||||
const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
|
||||
error.code = code
|
||||
throw error
|
||||
}
|
||||
return realSync.call(this)
|
||||
})
|
||||
}
|
||||
|
||||
function appendClosedTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
@@ -257,7 +239,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
appendClosedTurn(source)
|
||||
|
||||
const child = ctx.sessions.fork(source, undefined, SessionId('persist-child'))
|
||||
await ctx.parallel('session/flush', child)
|
||||
await ctx.sessions.flush(child)
|
||||
const loaded = await ctx.sessionPersistence.load(child.id)
|
||||
|
||||
expect(loaded.events).toEqual(source.events)
|
||||
@@ -360,26 +342,43 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
|
||||
await rejectDirectorySync('EPERM')
|
||||
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
|
||||
backend.internals.platform = 'win32'
|
||||
const m = meta('windows-directory-sync')
|
||||
it('reports both the append failure and a failed rollback', async () => {
|
||||
const m = meta('rollback-failure')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
|
||||
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
|
||||
})
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
|
||||
it.each([
|
||||
['linux', 'EPERM'],
|
||||
['win32', 'EIO'],
|
||||
] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
|
||||
await rejectDirectorySync(code)
|
||||
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
|
||||
backend.internals.platform = platform
|
||||
const m = meta(`directory-sync-${platform}-${code}`)
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
|
||||
const path = rawLogPath(root, undefined, m.id)
|
||||
const handle = await (await import('node:fs/promises')).open(path, 'r')
|
||||
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
|
||||
await handle.close()
|
||||
const realSync = proto.sync
|
||||
let failed = false
|
||||
const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
|
||||
if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
|
||||
return realSync.call(this)
|
||||
})
|
||||
const backend = ctx.sessionPersistence as unknown as {
|
||||
rollbackAppend: (path: string, size: number) => Promise<void>
|
||||
}
|
||||
const realRollback = backend.rollbackAppend.bind(backend)
|
||||
backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
|
||||
|
||||
try {
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
] as SessionEvent[])
|
||||
throw new Error('expected append to reject')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
const aggregate = error as AggregateError
|
||||
expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
|
||||
expect(aggregate.errors).toHaveLength(2)
|
||||
expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
|
||||
expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
|
||||
} finally {
|
||||
backend.rollbackAppend = realRollback
|
||||
syncSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
|
||||
@@ -436,12 +435,14 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
|
||||
|
||||
const a = ctx.sessions.create(SessionId('sa'))
|
||||
const b = ctx.sessions.create(SessionId('sb'))
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', a)
|
||||
await ctx.parallel('session/flush', b)
|
||||
await ctx.sessions.flush(a)
|
||||
await ctx.sessions.flush(b)
|
||||
|
||||
const la = await ctx.sessionPersistence.load(SessionId('sa'))
|
||||
const lb = await ctx.sessionPersistence.load(SessionId('sb'))
|
||||
@@ -750,7 +751,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
}, { inject: ['sessions'] }))
|
||||
// Drain A, then dispose ITS fiber (the live session A is gone) while the
|
||||
// backend stays loaded.
|
||||
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
|
||||
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
|
||||
await sessFiberA.dispose()
|
||||
|
||||
// A new Session object reuses the id. Object-keyed initialization must run independently,
|
||||
@@ -818,7 +819,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
|
||||
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
|
||||
await firstFiber.dispose()
|
||||
|
||||
let second!: Session
|
||||
@@ -929,18 +930,19 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
const session = ctx2.sessions.create(SessionId('flush-fail'))
|
||||
// A full turn lands in the write-behind buffer.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Make the durable materialize fail on the next flush.
|
||||
const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
|
||||
const origMat = backend.materialize.bind(backend)
|
||||
backend.materialize = () => Promise.reject(new Error('disk full'))
|
||||
await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/)
|
||||
await expectFlushError(ctx2.sessions.flush(session), /disk full/)
|
||||
// The events are STILL buffered (not silently dropped): a retry persists them.
|
||||
backend.materialize = origMat
|
||||
await ctx2.parallel('session/flush', session)
|
||||
await ctx2.sessions.flush(session)
|
||||
const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2])
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user