fix(session-persistence-jsonl): surface non-ENOENT storage errors; harden sidecar; broaden contract (review #33)

A durable persistence backend must not treat a storage fault as absence.
listCwdDirs() and exists() swallowed EVERY error and reported "no
sessions" / "not found", so EACCES/ENOTDIR/transient I/O could make
list() return nothing, load() report not-found, and collision checks
proceed under a false absence assumption.

- Add an isENOENT() helper; listCwdDirs() and exists() now return the
  empty/absent result ONLY for ENOENT and rethrow every other error.
  Regression tests drive ENOTDIR through both paths.

TODO-level hardening also addressed:
- writeSidecar() now uses an exclusive owner-only temp open ('wx', 0o600)
  like the log-materialization path, instead of a truncating writeFile —
  the sidecar can carry user data (title/firstPrompt), so a predictable/
  pre-existing temp path must never be silently followed.
- The shared runPersistenceContract serializability case now exercises
  EVERY value isJsonValue rejects (BigInt, undefined, Infinity, function,
  symbol, Map, circular), not just BigInt, so a backend cannot pass the
  contract while accepting values that corrupt the round-trip. The mock
  MemoryPersistence now validates via the canonical isJsonValue.
This commit is contained in:
Tianyi Cui
2026-06-15 23:53:03 +08:00
parent b4785e598b
commit 01621d38b6
4 changed files with 95 additions and 24 deletions

View File

@@ -1014,6 +1014,36 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx2.fiber.dispose()
})
it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => {
// A durable backend must NOT collapse a storage fault to "no sessions". Point
// the root at a regular FILE: readdir then fails with ENOTDIR, which must
// propagate rather than be swallowed as an empty listing.
const filePath = join(root, 'not-a-dir')
await writeFile(filePath, 'x')
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root: filePath })
await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
it('exists() surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
// Same contract on the existence path: a non-ENOENT error from the per-id
// open() must surface, not be collapsed to "not found" (which would let a
// collision check proceed under a false absence assumption). A LAZY session
// (created, never appended) keeps its cwd in state, so has() reaches
// findLog(id, cwd) → exists(logPath). Make that cwd's bucket DIRECTORY a
// regular file: open()ing `bucket/<id>.jsonl` under it then fails ENOTDIR.
const cwd = '/x'
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.sessionPersistence.create(meta('exists-fault', cwd)) // lazy: no bucket yet
await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
await expect(ctx2.sessionPersistence.has(SessionId('exists-fault'))).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
it('append() to a disk-only session adopts it and repairs a crash tail', async () => {
// Persist a session, then corrupt its tail, all through ONE backend.
const m = meta('disk-append', '/d')