From bb5e9ea1c7c7a4014e888fe20d322ebcff26ad06 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 6 Aug 2026 04:09:16 +0800 Subject: [PATCH] test(session): cover persistence revision races --- .../tests/jsonl.spec.ts | 29 ++++++++++++++++ .../tests/sqlite.spec.ts | 15 +++++++++ .../tests/persistence.spec.ts | 33 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 3cc11da3b3..8d0e920b8c 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -266,6 +266,35 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() }) + it('handles revision-stat races and errors after log discovery', async () => { + const m = meta('stored-revision-race') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SessionPersistenceJsonl + const internals = persistence as unknown as { + findLog(id: SessionId, signal?: AbortSignal): Promise + } + const path = rawLogPath(root, m.cwd, m.id) + const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(path) + + await rm(path) + expect(await persistence.readStoredRevision(m.id)).toBeUndefined() + + const invalidPath = `${path}\0` + findLog.mockResolvedValue(invalidPath) + await expect(persistence.readStoredRevision(m.id)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG_VALUE', + }) + + const reason = new Error('revision read cancelled after discovery') + const controller = new AbortController() + findLog.mockImplementation(async () => { + controller.abort(reason) + return invalidPath + }) + await expect(persistence.readStoredRevision(m.id, controller.signal)).rejects.toBe(reason) + }) + it('omits a snapshot artifact removed after discovery', async () => { const m = meta('vanishing-snapshot') await ctx.sessionPersistence.create(m) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 50e3a593e0..d215696748 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -654,6 +654,21 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('resolves the preparation-cache default without schema normalization', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let persistence!: SessionPersistenceSqlite + await ctx.plugin(Object.assign((inner: Context) => { + persistence = new SessionPersistenceSqlite(inner, { + path: ':memory:', + journalMode: 'wal', + }) + }, { inject: ['sessions'] })) + + expect(await persistence.list()).toEqual([]) + await ctx.fiber.dispose() + }) + it('uses the configured preparation cache through the public service', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index d8bff0bcc4..16cadd8eeb 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -772,6 +772,39 @@ describe('PersistenceCoordinator session preparations', () => { } }) + it('rejects preparation when storage disappears after repair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('repair-disappeared') + backend.store.set(id, { + meta: meta(id), + events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await coordinator.inspect(id) + const readStoredRevision = backend.readStoredRevision.bind(backend) + let revisionReads = 0 + vi.spyOn(backend, 'readStoredRevision').mockImplementation((sessionId, signal) => { + revisionReads += 1 + if (revisionReads === 2) return Promise.resolve(undefined) + return readStoredRevision(sessionId, signal) + }) + + await expect(coordinator.prepare(id)).rejects.toThrow(/disappeared after persistence repair/) + expect(backend.repairAttempts).toBe(1) + expect(revisionReads).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('waits for an existing reservation and reuses it after release', async () => { const ctx = new Context() await ctx.plugin(SessionStore)