diff --git a/docs/disk-layout.md b/docs/disk-layout.md index bab59ef..9d759b0 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -74,7 +74,11 @@ Removals are applied first and explicit assignments second, so an assignment wins when both mention the same key. Older metadata without `unsetEnv` keeps the historical ambient-inheritance behavior. -- Status (`running` / `exited` / `vanished`) is *derived* from socket + pid, not stored. +- Status (`running` / `exited` / `vanished`) is *derived*, not stored. A + reachable socket or live pidfile process proves the daemon is running. If + both paths are absent, + `daemonPid` is accepted only when the retained recovery process-start token + still matches that OS process. - `generation` and `daemonPid` are internal lifecycle guards. A daemon only removes files still owned by its generation, and `pty rm` waits for that daemon to finish deferred shutdown before it reports success. Readers should diff --git a/src/sessions.ts b/src/sessions.ts index b303bbe..f26373e 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -9,6 +9,7 @@ import { assertPrivateRecoveryPaths, atomicWritePrivate, recoveryRevisionPath, + readProcessStartToken, signRecoveryRevision, stampRecoveryMetadata, } from "./recovery.ts"; @@ -135,8 +136,8 @@ export interface SessionMetadata { * delete files whose metadata carries a different generation. */ generation?: string; /** PID of the daemon that owns this metadata generation. Unlike the - * sidecar pidfile, this survives socket cleanup long enough for `pty rm` - * to wait until deferred daemon shutdown is complete. */ + * sidecar pidfile, this survives socket cleanup. Inventory accepts it only + * when the recovery process-start token still proves the same OS process. */ daemonPid?: number; /** Capability advertised only by daemons that support authenticated, * signal-free recovery of an unlinked registry. Treat `secret` as opaque. */ @@ -944,7 +945,7 @@ export async function listSessions(options: ListSessionsOptions = {}): Promise boolean | Promise, timeout = 5000): Promise { const deadline = Date.now() + timeout; while (Date.now() < deadline) { @@ -120,6 +126,49 @@ afterEach(async () => { }); describe("live daemon registry recovery", () => { + it("keeps an identity-proven daemon actionable when its socket and pidfile are missing", async () => { + const root = makeRoot(); + const name = "partial-registry"; + const { pid } = startProvider(root, name); + + unlinkSocketAndPid(root, name); + const listed = run(root, ["list", "--json"]); + expect(listed.status, listed.stderr || listed.stdout).toBe(0); + expect(JSON.parse(listed.stdout)).toContainEqual(expect.objectContaining({ + name, + pid, + status: "running", + })); + + const collected = run(root, ["gc"]); + expect(collected.status, collected.stderr || collected.stdout).toBe(0); + expect(fs.existsSync(path.join(root, `${name}.json`))).toBe(true); + expect(readProcessStartToken(pid)).not.toBeNull(); + + const killed = run(root, ["kill", name]); + expect(killed.status, killed.stderr || killed.stdout).toBe(0); + await waitFor(() => readProcessStartToken(pid) === null); + }); + + it("does not trust a retained daemon pid with a mismatched process identity", () => { + const root = makeRoot(); + const name = "stale-daemon-pid"; + const { pid } = startProvider(root, name); + const stale = metadata(root, name); + stale.recovery!.processStartToken = "mismatched-process-start"; + fs.writeFileSync(path.join(root, `${name}.json`), JSON.stringify(stale)); + + unlinkSocketAndPid(root, name); + const listed = run(root, ["list", "--json"]); + expect(listed.status, listed.stderr || listed.stdout).toBe(0); + expect(JSON.parse(listed.stdout)).toContainEqual(expect.objectContaining({ + name, + pid: null, + status: "vanished", + })); + expect(readProcessStartToken(pid)).not.toBeNull(); + }); + it("refuses an existing creation lock without any liveness signal", () => { const root = makeRoot(); fs.writeFileSync(path.join(root, "locked.lock"), "2147483647");