Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertPrivateRecoveryPaths,
atomicWritePrivate,
recoveryRevisionPath,
readProcessStartToken,
signRecoveryRevision,
stampRecoveryMetadata,
} from "./recovery.ts";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -944,7 +945,7 @@ export async function listSessions(options: ListSessionsOptions = {}): Promise<S

// A live pid remains authoritative even if its socket inode is temporarily
// absent. Listing observes that mismatch; it never "repairs" it.
const pid = readPid(name);
const pid = readPid(name, metadata);
if (pid !== null && isProcessAlive(pid)) {
sessions.push({
name,
Expand Down Expand Up @@ -1273,7 +1274,7 @@ export async function gc(
for (const s of withParent) {
const parentRef = s.metadata!.tags!.parent;
const parentMeta = readMetadata(parentRef);
const parentPid = parentMeta ? readPid(parentRef) : null;
const parentPid = parentMeta ? readPid(parentRef, parentMeta) : null;
const parentAlive = parentMeta != null && parentPid !== null && isProcessAlive(parentPid);
if (parentAlive) continue;
const reason: "missing" | "dead" = parentMeta ? "dead" : "missing";
Expand Down Expand Up @@ -1801,7 +1802,16 @@ export function readSessionPid(name: string): number | null {
}
}

const readPid = readSessionPid;
function readPid(name: string, metadata?: SessionMetadata | null): number | null {
const sidecarPid = readSessionPid(name);
if (sidecarPid !== null) return sidecarPid;

const retained = metadata ?? readMetadata(name);
const daemonPid = retained?.daemonPid;
const processStartToken = retained?.recovery?.processStartToken;
if (daemonPid === undefined || processStartToken === undefined) return null;
return readProcessStartToken(daemonPid) === processStartToken ? daemonPid : null;
}

export function isProcessAlive(pid: number): boolean {
try {
Expand Down
49 changes: 49 additions & 0 deletions tests/recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ function unlinkRegistry(root: string, name: string): void {
}
}

function unlinkSocketAndPid(root: string, name: string): void {
for (const suffix of ["sock", "pid"]) {
fs.unlinkSync(path.join(root, `${name}.${suffix}`));
}
}

async function waitFor(check: () => boolean | Promise<boolean>, timeout = 5000): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
Expand Down Expand Up @@ -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");
Expand Down
Loading