From bf70da473b9ed19eed58fa25e54fb89ca893435e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:49:57 +0800 Subject: [PATCH] fix(session-persistence): async readRaw default and full branch coverage The inherited readRaw default now rejects like the async backend overrides instead of throwing synchronously, and its arms plus the JSONL override's retry loop, zero-frame, and corrupt-header branches get dedicated tests. --- .../tests/jsonl.spec.ts | 20 +++++++++++++++++++ .../tests/zstd.spec.ts | 12 +++++++++++ .../session/session-persistence/src/index.ts | 4 ++-- .../tests/persistence.spec.ts | 12 +++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 390d0e9048..7f760c7281 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -237,6 +237,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() }) + it('readRaw rejects a corrupt header line instead of exporting it', async () => { + const m = meta('raw-corrupt', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, '/work', m.id), 'not a header line\n{"type":"turn/start","seq":0}\n') + await expect(ctx.sessionPersistence.readRaw(m.id)).rejects.toThrow(/corrupt session log/) + }) + + it('readRaw retries when the file revision changes during the read', async () => { + const m = meta('raw-revision-race', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + statRace.path = rawLogPath(root, '/work', m.id) + + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + // Two stat calls per iteration; the mocked revision change forces a retry. + expect(statRace.reads).toBe(4) + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 37d3e6c13b..c54dca4c08 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -377,6 +377,18 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) }) + it('readRaw is undefined for a zstd artifact that carries no frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + // Overwrite the physical artifact with a short buffer: frame scanning + // answers zero frames before any magic check, so readRaw reports no artifact. + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined() + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 0889a60edf..aedfd8df5a 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -106,9 +106,9 @@ export abstract class SessionPersistence extends Service { * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ - readRaw(_id: SessionId, signal?: AbortSignal): Promise { + async readRaw(_id: SessionId, signal?: AbortSignal): Promise { signal?.throwIfAborted() - return Promise.resolve(undefined) + return undefined } /** diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index d3e715b085..99f6acc756 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -246,6 +246,18 @@ runPersistenceContract('memory', async () => { } }) +describe('the inherited readRaw default', () => { + it('answers undefined and honors an aborted signal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined() + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), + ).rejects.toThrow() + }) +}) + // Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are // atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => {