Просмотр исходного кода

test(session): cover persistence revision races

imccyu 1 месяц назад
Родитель
Сommit
bb5e9ea1c7

+ 29 - 0
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<string | undefined>
+    }
+    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)

+ 15 - 0
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)

+ 33 - 0
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<never>
+    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)