fix(jsonl): reject unusable roots at plugin load

A configured root that already exists as a file or unreadable directory cannot host cwd buckets, but the backend previously mounted and deferred that deterministic configuration error until a later list or write.

Probe the resolved root while the plugin loads, surface every error except ENOENT, and keep an absent root valid for lazy first materialization. Document the timing contract, regenerate the config catalog, and pin the non-directory case at the load boundary.
This commit is contained in:
Tianyi Cui
2026-07-20 17:41:40 +08:00
parent 73e3f658c6
commit c9d3d5d557
7 changed files with 28 additions and 15 deletions

View File

@@ -17,7 +17,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
| Key | Type | Notes |
|---|---|---|
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). |
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). An existing root must be a readable directory; an absent root is created on first materialization. |
`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix.

View File

@@ -8,6 +8,7 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { readdirSync } from 'node:fs'
import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
@@ -25,7 +26,9 @@ export interface Config {
/**
* Root directory for all session files. Required (no default): a default of
* `process.cwd()` would scatter session files as the process's cwd changes
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An
* existing root must be a readable directory; an absent root is created on
* first materialization.
*/
root: string
}
@@ -64,6 +67,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
this.root = resolve(config.root)
this.assertUsableRoot()
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
}
@@ -304,6 +308,16 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return matches[0]
}
/** Require an existing configured root to be a readable directory. */
private assertUsableRoot(): void {
try {
readdirSync(this.root)
} catch (error) {
if (isENOENT(error)) return
throw error
}
}
/** Reject metadata that does not identify the selected physical log. */
private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void {
if (expectedId !== undefined && meta.id !== expectedId) {

View File

@@ -748,15 +748,12 @@ 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". Making the root a
// regular file forces ENOTDIR from `readdir`, which must propagate.
it('plugin load rejects an existing root that is not a directory', async () => {
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 expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath })).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})