diff --git a/CHANGELOG.md b/CHANGELOG.md index 37225b6..29e787c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +### Live daemon registry recovery + +- `pty recover-live --metadata ` lets the original live + daemon rebind an accidentally unlinked pathname socket and republish its + pid/metadata sidecars without restarting the daemon, its child, or existing + clients. Recovery validates PID, generation, OS process-start identity, and + launch identity; it refuses conflicting registry owners. +- Recovery uses a short-lived authenticated request watched by the daemon; it + sends no process signal. Supporting daemons explicitly stamp + `recoveryProtocol: 1` and `daemonStartToken`; an unsupported snapshot fails + closed with transcript/existing-attachment guidance. + ### Read-only session listing - `listSessions()` and `pty list` are now strictly observational: they no @@ -45,9 +57,12 @@ ### Storage format -`.json` gains optional `generation` and `daemonPid` lifecycle fields. +`.json` gains optional `generation`, `daemonPid`, `recoveryProtocol`, and +`daemonStartToken` lifecycle fields. The generation is an opaque cleanup-ownership token; the daemon PID lets -`pty rm` wait for deferred shutdown after the child has exited. +`pty rm` wait for deferred shutdown after the child has exited. The recovery +fields prove the daemon can safely handle a live rebind request and bind the +snapshot to the same OS process start. ### Restartable launch parity and bounded fleet listing diff --git a/README.md b/README.md index 66f81fb..f5b0036 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,22 @@ sessions — see [Auto-running gc](#auto-running-gc). `pty list` is strictly observational and never removes registry state. `keep=true` and `strategy=permanent` are exempt from the gc sweep. +If a live daemon's socket, pid, and metadata path are accidentally unlinked, +do not rerun its launch command: that can create a second writer while the +original child is still alive. A metadata snapshot captured from a supporting +daemon can ask that exact process to rebind its listener without restarting: + +```sh +pty recover-live --metadata ./backups/myserver.json myserver +``` + +Recovery validates the daemon PID, generation, process-start identity, name, +and launch definition. It refuses snapshots from older daemons, leaves a +foreign replacement socket untouched, and preserves existing attached clients. +An already-running daemon that predates this protocol cannot be upgraded +in-place; keep it alive and use an existing attachment or provider transcript +fallback. + ### Events Sessions automatically log terminal events — bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions — plus metadata mutations: `display_name_change` on rename, `tags_change` on tag updates, and any `user.*` events published via `pty emit`. Everything goes into per-session JSONL files. diff --git a/completions/pty.bash b/completions/pty.bash index d9c17d8..40357a6 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -5,7 +5,7 @@ _pty() { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test remote-serve" + commands="run attach a exec peek send events list ls stats restart recover-live kill rm remove gc tag tag-multi emit rename up down test remote-serve" if [[ ${COMP_CWORD} -eq 1 ]]; then if [[ "${cur}" == -* ]]; then @@ -74,6 +74,9 @@ _pty() { COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi ;; + recover-live) + COMPREPLY=($(compgen -W "--metadata --timeout-ms" -- "${cur}")) + ;; kill) if [[ "${cur}" == -* ]]; then COMPREPLY=($(compgen -W "" -- "${cur}")) diff --git a/completions/pty.fish b/completions/pty.fish index 78c6c27..b7f1898 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -52,6 +52,7 @@ complete -c pty -n __pty_needs_command -a list -d 'List sessions' complete -c pty -n __pty_needs_command -a ls -d 'List sessions' complete -c pty -n __pty_needs_command -a stats -d 'Live CPU / memory / PIDs' complete -c pty -n __pty_needs_command -a restart -d 'SIGTERM + respawn' +complete -c pty -n __pty_needs_command -a recover-live -d 'Rebind a stranded live daemon without restarting it' complete -c pty -n __pty_needs_command -a kill -d 'SIGTERM a running session' complete -c pty -n __pty_needs_command -a rm -d 'Remove exited metadata' complete -c pty -n __pty_needs_command -a remove -d 'Remove exited metadata' @@ -113,6 +114,8 @@ complete -c pty -n '__pty_using_command stats' -a '(__pty_sessions)' -d 'Session complete -c pty -n '__pty_using_command restart' -l yes -s y -d 'Skip confirmation' complete -c pty -n '__pty_using_command restart' -l force -d 'Attach after restart even from inside another pty' complete -c pty -n '__pty_using_command restart' -a '(__pty_sessions)' -d 'Session' +complete -c pty -n '__pty_using_command recover-live' -l metadata -d 'Captured live metadata snapshot' +complete -c pty -n '__pty_using_command recover-live' -l timeout-ms -d 'Recovery timeout in milliseconds' complete -c pty -n '__pty_using_command kill' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command rm remove' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command gc' -l dry-run -s n -d 'Preview without changing anything' diff --git a/completions/pty.zsh b/completions/pty.zsh index 84cb4ac..a69771c 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -25,6 +25,7 @@ _pty() { 'ls:Alias for list' 'stats:Live CPU / memory / PIDs' 'restart:SIGTERM + respawn' + 'recover-live:Rebind a stranded live daemon without restarting it' 'kill:SIGTERM a running session' 'rm:Remove exited metadata' 'remove:Alias for rm' @@ -128,6 +129,11 @@ _pty() { '--force[Attach after restart even from inside another pty]' \ '1:session:_pty_sessions' ;; + recover-live) + _arguments \ + '--metadata[Captured live metadata snapshot]' \ + '--timeout-ms[Recovery timeout in milliseconds]' + ;; kill) _arguments \ '1:session:_pty_sessions' diff --git a/docs/disk-layout.md b/docs/disk-layout.md index 69d3e56..39cd643 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -15,6 +15,7 @@ For non-Node tools that want to read pty's state without paying Node startup. Th | `.sock` | daemon IPC socket (Unix) | 2 | | `.pid` | daemon pid (decimal) | 2 | | `.lock` | creation-race lock | 2 | +| `.recover-request.` | authenticated one-shot live-daemon recovery request | 2 | | `theme` | last-selected TUI theme | 2 | | `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 | | `.json.tmp..` | atomic-write tmp — readers MUST ignore | n/a | @@ -35,6 +36,8 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`. { generation?: string; // opaque daemon generation; guards cleanup ownership daemonPid?: number; // daemon owning this generation, retained after child exit + recoveryProtocol?: 1; // daemon supports fail-closed live registry recovery + daemonStartToken?: string; // OS process-start identity, when available command: string; // resolved binary path args: string[]; displayCommand: string; // command as the user typed it @@ -60,6 +63,11 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`. removes files still owned by its generation, and `pty rm` waits for that daemon to finish deferred shutdown before it reports success. Readers should treat the generation token as opaque. +- `recoveryProtocol` and `daemonStartToken` are written together by daemons + that can safely handle `pty recover-live`. The CLI refuses recovery unless a + captured snapshot contains both fields and the live process still has the + same start token. Recovery uses a short-lived request file watched by that + daemon and sends no process signal. - Reserved tag keys (`ptyfile*`, `strategy`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`. - User-facing tags that drive pty behavior but are visible by default: - `strategy=permanent` — `pty gc` respawns the session when its daemon exits (the historic supervisor's role; now stateless and run on a cron). diff --git a/src/cli.ts b/src/cli.ts index ae6684a..93bf9df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import * as net from "node:net"; import * as readline from "node:readline/promises"; import { spawnSync, execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; @@ -27,9 +28,13 @@ import { allRefs, readMetadata, readSessionPid, + readProcessStartToken, writeMetadata, atomicWriteFileSync, getSessionDir, + getSocketPath, + createLiveRecoveryRequest, + removeLiveRecoveryRequest, DEFAULT_SESSION_DIR, type SessionInfo, type SessionMetadata, @@ -259,6 +264,18 @@ Examples: pty restart myserver pty restart -y myserver`, + "recover-live": `Usage: pty recover-live --metadata [--timeout-ms ] + +Ask the original live daemon to republish a lost pathname socket and registry. +The daemon and its child are not restarted. The snapshot must have been +captured while the same daemon generation was live. + +This command fails closed unless the snapshot proves that the daemon supports +the recovery protocol; older daemons are never poked or restarted. + +Examples: + pty recover-live --metadata ./myserver.json myserver`, + kill: `Usage: pty kill SIGTERM a running session's daemon. Metadata is kept — restart or \`pty rm\` it later. @@ -465,6 +482,7 @@ Modify: pty emit user. [...] Same, targeting a specific session Lifecycle: + pty recover-live --metadata Rebind a stranded live daemon without restarting it pty restart SIGTERM + respawn using stored metadata (prompts if running) pty restart -y Same, no prompt pty kill SIGTERM a running session's daemon @@ -1267,6 +1285,35 @@ async function main(): Promise { break; } + case "recover-live": { + let metadataPath: string | null = null; + let recoveryName: string | null = null; + let timeoutMs = 5000; + for (let ai = 1; ai < args.length; ai++) { + const a = args[ai]; + if (a === "--metadata" && ai + 1 < args.length) { + metadataPath = args[++ai]; + } else if (a === "--timeout-ms" && ai + 1 < args.length) { + timeoutMs = Number(args[++ai]); + } else if (!recoveryName) { + recoveryName = a; + } else { + console.error(`pty recover-live: unexpected argument "${a}"`); + process.exit(1); + } + } + if (!metadataPath || !recoveryName) { + console.error("Usage: pty recover-live --metadata [--timeout-ms ] "); + process.exit(1); + } + if (!Number.isFinite(timeoutMs) || timeoutMs < 100 || timeoutMs > 60_000) { + console.error("pty recover-live: --timeout-ms must be between 100 and 60000"); + process.exit(1); + } + await cmdRecoverLive(recoveryName, metadataPath, timeoutMs); + break; + } + case "kill": { if (args.length < 2) { console.error("Usage: pty kill "); @@ -2474,6 +2521,121 @@ function renameUsage(): void { console.error(COMMAND_HELP.rename); } +function probeLocalSocket(socketPath: string, timeoutMs = 250): Promise { + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + const timer = setTimeout(() => { + socket.destroy(); + resolve(false); + }, timeoutMs); + socket.on("connect", () => { + clearTimeout(timer); + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + +async function cmdRecoverLive( + name: string, + metadataPath: string, + timeoutMs: number, +): Promise { + validateName(name); + + let snapshot: SessionMetadata; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("snapshot must be a JSON object"); + } + snapshot = parsed as SessionMetadata; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot read recovery snapshot "${metadataPath}": ${message}`); + } + + // This marker is written only after the supporting daemon has installed its + // request watcher. Its absence is a hard stop: old daemons cannot be upgraded + // in place, and recovery must never infer permission to relaunch them. + if (snapshot.recoveryProtocol !== 1) { + throw new Error( + "Snapshot predates live recovery support; refusing recovery. " + + "Keep the process alive and use an already-attached client or transcript fallback.", + ); + } + if ( + !Number.isInteger(snapshot.daemonPid) || + Number(snapshot.daemonPid) <= 1 || + typeof snapshot.generation !== "string" || + snapshot.generation.length === 0 + ) { + throw new Error("Snapshot must contain a daemonPid and generation"); + } + if ( + typeof snapshot.daemonStartToken !== "string" || + snapshot.daemonStartToken.length === 0 + ) { + throw new Error("Snapshot lacks a stable daemonStartToken; refusing recovery"); + } + + const daemonPid = Number(snapshot.daemonPid); + const observedStartToken = readProcessStartToken(daemonPid); + if (observedStartToken !== snapshot.daemonStartToken) { + throw new Error( + `Daemon PID ${daemonPid} no longer has the captured process-start identity`, + ); + } + try { + process.kill(daemonPid, 0); + } catch { + throw new Error(`Daemon PID ${daemonPid} is not alive`); + } + + const nonce = randomBytes(16).toString("hex"); + createLiveRecoveryRequest({ + protocol: 1, + name, + nonce, + createdAt: new Date().toISOString(), + expectedPid: daemonPid, + expectedGeneration: snapshot.generation, + expectedStartToken: snapshot.daemonStartToken, + snapshot, + }); + + try { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const metadata = readMetadata(name); + const pid = readSessionPid(name); + if ( + metadata?.generation === snapshot.generation && + metadata.daemonPid === daemonPid && + metadata.daemonStartToken === snapshot.daemonStartToken && + pid === daemonPid && + await probeLocalSocket(getSocketPath(name)) + ) { + console.log( + `Session "${name}" recovered in daemon PID ${daemonPid} ` + + `(generation ${snapshot.generation}).`, + ); + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `Live daemon ${daemonPid} did not republish session "${name}" within ${timeoutMs}ms`, + ); + } finally { + removeLiveRecoveryRequest(name, nonce); + } +} + async function cmdRename(rawArgs: string[]): Promise { const insideSession = !!process.env.PTY_SESSION; diff --git a/src/completions.ts b/src/completions.ts index 528083e..f7bd633 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -161,6 +161,14 @@ const COMMANDS: readonly CommandSpec[] = [ { name: "force", desc: "Attach after restart even from inside another pty" }, ], }, + { + name: "recover-live", + desc: "Rebind a stranded live daemon without restarting it", + flags: [ + { name: "metadata", desc: "Captured live metadata snapshot" }, + { name: "timeout-ms", desc: "Recovery timeout in milliseconds" }, + ], + }, { name: "kill", desc: "SIGTERM a running session", diff --git a/src/server.ts b/src/server.ts index 762597f..c3564c4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,9 +27,17 @@ import { cleanupOwnedAll, writeMetadata, readMetadata, + readSessionPid, + readProcessStartToken, + acquireLock, + releaseLock, + listLiveRecoveryRequests, + removeLiveRecoveryRequest, + LIVE_RECOVERY_REQUEST_TTL_MS, shouldReapAtExit, reapOnExitDefault, type SessionMetadata, + type LiveRecoveryRequest, } from "./sessions.ts"; import { EventWriter, clearEvents, EventType, type EventRecord } from "./events.ts"; @@ -223,7 +231,7 @@ export class PtyServer { private terminal: Terminal; private serialize: SerializeAddon; private ptyProcess: pty.IPty; - private socketServer: net.Server; + private socketServer: net.Server | null; private clients = new Map(); private exited = false; private exitCode = 0; @@ -252,6 +260,8 @@ export class PtyServer { private lastResizeTime = 0; private eventWriter: EventWriter; private generation: string; + private daemonStartToken: string | undefined; + private socketIdentity: { dev: bigint; ino: bigint } | null = null; private lastTitle = ""; readonly ready: Promise; // Resolves when the child process's onExit has fired — used by close() to @@ -264,6 +274,7 @@ export class PtyServer { this.name = options.name; this.options = options; this.generation = options.generation ?? randomBytes(16).toString("hex"); + this.daemonStartToken = readProcessStartToken(process.pid) ?? undefined; this.eventWriter = new EventWriter(options.name); this.childExited = new Promise((resolve) => { this.resolveChildExited = resolve; @@ -541,26 +552,29 @@ export class PtyServer { fs.unlinkSync(socketPath); } catch {} - this.socketServer = net.createServer((socket) => - this.handleClient(socket) - ); + const initialSocketServer = this.createSocketServer(); + this.socketServer = initialSocketServer; // Tighten umask around listen() so the socket inode is never transiently // group/world-readable (BUG-5). The chmodSync below is kept as // belt-and-suspenders for good measure. const prevUmask = process.umask(0o077); this.ready = new Promise((resolve, reject) => { let settled = false; - this.socketServer.once("error", (err) => { + initialSocketServer.once("error", (err) => { if (settled) return; settled = true; reject(err); }); - this.socketServer.listen(socketPath, () => { + initialSocketServer.listen(socketPath, () => { try { fs.chmodSync(socketPath, 0o600); } catch {} + this.socketIdentity = this.readSocketIdentity(socketPath); fs.writeFileSync(getPidPath(this.name), process.pid.toString()); writeMetadata(this.name, { generation: this.generation, daemonPid: process.pid, + ...(this.daemonStartToken + ? { recoveryProtocol: 1 as const, daemonStartToken: this.daemonStartToken } + : {}), command: options.command, args: options.args, displayCommand: options.displayCommand, @@ -585,12 +599,172 @@ export class PtyServer { }); process.umask(prevUmask); - // Post-listen errors (e.g., socket file unlinked out from under us) must - // not crash the process, but they also mustn't interfere with the - // initial ready resolution above. - this.socketServer.on("error", (err) => { - console.error(`Socket server error: ${err.message}`); + } + + private createSocketServer(logErrors = true): net.Server { + const server = net.createServer((socket) => this.handleClient(socket)); + server.on("error", (err) => { + if (logErrors) console.error(`Socket server error: ${err.message}`); }); + return server; + } + + private readSocketIdentity(socketPath: string): { dev: bigint; ino: bigint } | null { + try { + const stat = fs.statSync(socketPath, { bigint: true }); + return { dev: stat.dev, ino: stat.ino }; + } catch { + return null; + } + } + + private socketIdentityMatchesCurrentPath(socketPath: string): boolean { + const current = this.readSocketIdentity(socketPath); + return current !== null && + this.socketIdentity !== null && + current.dev === this.socketIdentity.dev && + current.ino === this.socketIdentity.ino; + } + + private listenReplacement(server: net.Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + try { fs.chmodSync(socketPath, 0o600); } catch {} + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(socketPath); + }); + } + + /** Restore a listener and registry sidecars inside the original daemon. + * + * This is the only safe way to recover an unlinked pathname socket: an + * external process cannot relink the listener inode. Identity checks happen + * while holding the ordinary per-name creation lock so a replacement launch + * cannot race the rebind. Existing client sockets stay on the old server and + * are not disconnected. */ + async recoverLiveRegistry( + request: LiveRecoveryRequest, + testHooks: { beforeReplacementListen?: () => void | Promise } = {}, + ): Promise { + const requestAgeMs = Date.now() - Date.parse(request.createdAt); + if ( + !Number.isFinite(requestAgeMs) || + requestAgeMs < -5000 || + requestAgeMs > LIVE_RECOVERY_REQUEST_TTL_MS + ) { + throw new Error("recovery request is stale or has an invalid timestamp"); + } + if ( + request.protocol !== 1 || + request.name !== this.name || + request.expectedPid !== process.pid || + request.expectedGeneration !== this.generation + ) { + throw new Error("recovery request does not identify this daemon"); + } + if ( + this.daemonStartToken === undefined || + request.expectedStartToken !== this.daemonStartToken + ) { + throw new Error("recovery request process-start token does not match"); + } + + const snapshot = request.snapshot; + if ( + snapshot.recoveryProtocol !== 1 || + typeof snapshot.daemonStartToken !== "string" || + snapshot.daemonPid !== process.pid || + snapshot.generation !== this.generation || + snapshot.command !== this.options.command || + snapshot.displayCommand !== this.options.displayCommand || + snapshot.cwd !== this.options.cwd || + JSON.stringify(snapshot.args) !== JSON.stringify(this.options.args) || + snapshot.rows !== this.options.rows || + snapshot.cols !== this.options.cols || + (snapshot.ephemeral === true) !== (this.options.ephemeral === true) || + (snapshot.isolateEnv === true) !== (this.options.isolateEnv === true) || + JSON.stringify(snapshot.extraEnv ?? null) !== + JSON.stringify(this.options.extraEnv ?? null) || + JSON.stringify(snapshot.env ?? null) !== + JSON.stringify(this.options.env ?? null) + ) { + throw new Error("recovery snapshot does not match the live launch identity"); + } + if (snapshot.daemonStartToken !== this.daemonStartToken) { + throw new Error("recovery snapshot process-start token does not match"); + } + + if (!acquireLock(this.name)) { + throw new Error("session creation lock is held; refusing recovery"); + } + try { + const existingMetadata = readMetadata(this.name); + if ( + existingMetadata?.generation !== undefined && + existingMetadata.generation !== this.generation + ) { + throw new Error("registry metadata belongs to another generation"); + } + const existingPid = readSessionPid(this.name); + if (existingPid !== null && existingPid !== process.pid) { + throw new Error("registry pid belongs to another daemon"); + } + + const socketPath = getSocketPath(this.name); + const socketExists = this.readSocketIdentity(socketPath) !== null; + if (socketExists && !this.socketIdentityMatchesCurrentPath(socketPath)) { + throw new Error("socket pathname belongs to another listener"); + } + + if (!socketExists) { + const previousServer = this.socketServer; + this.socketServer = null; + // Stop accepts on the unlinked listener before publishing the + // replacement. Node unlinks a pathname as part of server.close(); if + // this happened after listenReplacement, that deferred cleanup could + // erase the new socket. Existing client connections remain valid. + if (previousServer?.listening) previousServer.close(); + await testHooks.beforeReplacementListen?.(); + const replacementServer = this.createSocketServer(false); + try { + await this.listenReplacement(replacementServer, socketPath); + } catch (error) { + try { replacementServer.close(); } catch {} + throw error; + } + this.socketServer = replacementServer; + this.socketIdentity = this.readSocketIdentity(socketPath); + } + + fs.writeFileSync(getPidPath(this.name), process.pid.toString(), { mode: 0o600 }); + writeMetadata(this.name, { + ...snapshot, + generation: this.generation, + daemonPid: process.pid, + recoveryProtocol: 1, + daemonStartToken: this.daemonStartToken, + command: this.options.command, + args: this.options.args, + displayCommand: this.options.displayCommand, + cwd: this.options.cwd, + rows: this.options.rows, + cols: this.options.cols, + ephemeral: this.options.ephemeral === true, + ...(this.options.isolateEnv ? { isolateEnv: true } : { isolateEnv: undefined }), + ...(this.options.extraEnv ? { extraEnv: this.options.extraEnv } : { extraEnv: undefined }), + ...(this.options.env ? { env: this.options.env } : { env: undefined }), + }); + } finally { + releaseLock(this.name); + } } private handleClient(socket: net.Socket): void { @@ -931,6 +1105,9 @@ export class PtyServer { writeMetadata(this.name, { generation: this.generation, daemonPid: process.pid, + ...(this.daemonStartToken + ? { recoveryProtocol: 1 as const, daemonStartToken: this.daemonStartToken } + : {}), command: this.options.command, args: this.options.args, displayCommand: this.options.displayCommand, @@ -963,7 +1140,9 @@ export class PtyServer { for (const client of this.clients.values()) { client.socket.destroy(); } - this.socketServer.close(async () => { + const listener = this.socketServer; + this.socketServer = null; + const finish = async () => { cleanupOwnedSocket(this.name, { generation: this.generation, pid: process.pid, @@ -982,7 +1161,12 @@ export class PtyServer { ]); try { await this.eventWriter.flush(); } catch {} resolve(); - }); + }; + if (listener?.listening) { + listener.close(() => void finish()); + } else { + void finish(); + } }); } @@ -1118,8 +1302,14 @@ if (process.argv[1]?.endsWith("/server.js")) { // can all trigger shutdown, sometimes overlapping. Only the first arms the // deadline and drives close(); later callers get the same in-flight promise. let shutdownPromise: Promise | null = null; + let recoveryPromise: Promise | null = null; + let recoveryPoll: NodeJS.Timeout | null = null; function cleanShutdown(code: number): Promise { if (shutdownPromise) return shutdownPromise; + if (recoveryPoll) { + clearInterval(recoveryPoll); + recoveryPoll = null; + } const deadline = setTimeout(() => { console.error( `pty daemon "${config.name}": graceful shutdown exceeded ` + @@ -1134,7 +1324,14 @@ if (process.argv[1]?.endsWith("/server.js")) { } catch {} process.exit(code); }, SHUTDOWN_DEADLINE_MS); - shutdownPromise = server.close().then(() => { + // If a registry rebind is already inside its lock/listen critical section, + // let it settle before closing whichever listener it published. A shutdown + // that starts first clears the poll and therefore wins without a recovery + // being admitted behind it. + shutdownPromise = (recoveryPromise ?? Promise.resolve()) + .catch(() => {}) + .then(() => server.close()) + .then(() => { clearTimeout(deadline); // `close()` has already re-flushed exit metadata with the final // `lastLines`, so this reads the same tags a `pty gc` sweep would @@ -1178,6 +1375,26 @@ if (process.argv[1]?.endsWith("/server.js")) { }; process.on("SIGTERM", () => killedExternally(0)); process.on("SIGINT", () => killedExternally(0)); + // Recovery is exceptional, so keep the steady-state cost modest: one file + // read attempt per daemon every 500ms, with no long-lived shared supervisor. + recoveryPoll = setInterval(() => { + if (recoveryPromise || shutdownPromise) return; + const request = listLiveRecoveryRequests(config.name)[0]; + if (!request) return; + const operation = server.recoverLiveRegistry(request); + recoveryPromise = operation; + void operation.then(() => { + removeLiveRecoveryRequest(config.name, request.nonce); + }).catch(() => { + // The CLI observes refusal through its bounded registry/socket wait. Do + // not write to the daemon's launch-time stderr pipe: detached spawners + // have already exited and that pipe may no longer have a reader. + removeLiveRecoveryRequest(config.name, request.nonce); + }).finally(() => { + if (recoveryPromise === operation) recoveryPromise = null; + }); + }, 500); + recoveryPoll.unref?.(); // Spawner-PID watchdog (opt-in via PTY_SPAWNER_PID). // diff --git a/src/sessions.ts b/src/sessions.ts index 9d2bdb2..d3223f9 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import * as os from "node:os"; import * as net from "node:net"; import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; // Circular import: events.ts imports getEventsPath/ensureSessionDir from // this file. Cycle is safe — `appendEventSync` is only called at runtime // from inside functions, never at module-init time. @@ -117,6 +118,16 @@ export function getEventsPath(name: string): string { return path.join(getSessionDir(), `${name}.events.jsonl`); } +/** Out-of-band request consumed by a live daemon whose ordinary pathname + * socket was unlinked. Nonce-specific paths make concurrent publish/removal + * linearizable: one caller can never unlink another caller's request. */ +export function getRecoveryRequestPath(name: string, nonce: string): string { + if (!/^[a-f0-9]{32}$/.test(nonce)) { + throw new Error("Recovery request nonce must be 32 lowercase hex characters"); + } + return path.join(getSessionDir(), `${name}.recover-request.${nonce}`); +} + // PUBLIC FORMAT — this is the on-disk shape of `.json`. Any change // to fields here (add / rename / remove / type change) MUST be reflected in // `docs/disk-layout.md` and called out under `### Storage format` in the @@ -130,6 +141,11 @@ export interface SessionMetadata { * sidecar pidfile, this survives socket cleanup long enough for `pty rm` * to wait until deferred daemon shutdown is complete. */ daemonPid?: number; + /** Recovery protocol marker. Presence proves the daemon watches for + * authenticated, non-destructive registry-rebind requests. */ + recoveryProtocol?: 1; + /** OS process-start identity captured by the daemon when available. */ + daemonStartToken?: string; command: string; args: string[]; displayCommand: string; // original command as the user typed it @@ -164,6 +180,93 @@ export interface SessionMetadata { lastAttachAt?: string; } +export interface LiveRecoveryRequest { + protocol: 1; + name: string; + nonce: string; + createdAt: string; + expectedPid: number; + expectedGeneration: string; + expectedStartToken: string; + snapshot: SessionMetadata; +} + +export const LIVE_RECOVERY_REQUEST_TTL_MS = 30_000; + +export function readLiveRecoveryRequest( + name: string, + nonce: string, +): LiveRecoveryRequest | null { + try { + const parsed: unknown = JSON.parse( + fs.readFileSync(getRecoveryRequestPath(name, nonce), "utf-8"), + ); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const request = parsed as Partial; + if ( + request.protocol !== 1 || + request.name !== name || + request.nonce !== nonce || + typeof request.createdAt !== "string" || + !Number.isInteger(request.expectedPid) || + typeof request.expectedGeneration !== "string" || + typeof request.expectedStartToken !== "string" || + request.expectedStartToken.length === 0 || + typeof request.snapshot !== "object" || + request.snapshot === null || + request.snapshot.recoveryProtocol !== 1 || + typeof request.snapshot.daemonStartToken !== "string" || + request.snapshot.daemonStartToken.length === 0 + ) { + return null; + } + return request as LiveRecoveryRequest; + } catch { + return null; + } +} + +/** Read every syntactically valid request for one name, newest first. */ +export function listLiveRecoveryRequests(name: string): LiveRecoveryRequest[] { + let entries: string[]; + try { + entries = fs.readdirSync(getSessionDir()); + } catch { + return []; + } + const prefix = `${name}.recover-request.`; + return entries + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => entry.slice(prefix.length)) + .filter((nonce) => /^[a-f0-9]{32}$/.test(nonce)) + .map((nonce) => readLiveRecoveryRequest(name, nonce)) + .filter((request): request is LiveRecoveryRequest => request !== null) + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); +} + +/** Atomically publish the latest recovery request. + * + * Nonce-specific files mean a crash-left request never blocks a fresh caller, + * and each caller can remove only its own inode. */ +export function createLiveRecoveryRequest(request: LiveRecoveryRequest): void { + ensureSessionDir(); + fs.writeFileSync( + getRecoveryRequestPath(request.name, request.nonce), + JSON.stringify(request, null, 2), + { flag: "wx", mode: 0o600 }, + ); +} + +/** Remove exactly this nonce's file; concurrent requests have different paths. */ +export function removeLiveRecoveryRequest(name: string, nonce: string): boolean { + try { + fs.unlinkSync(getRecoveryRequestPath(name, nonce)); + return true; + } catch { + return false; + } +} + export interface SessionInfo { name: string; socketPath: string; @@ -1483,6 +1586,35 @@ async function probeSocketsWithinBudget( return results; } +/** Stable process identity when the host exposes one. Linux starttime is the + * kernel clock-tick value from /proc//stat field 22; unlike wall time it + * cannot collide merely because a PID was reused within the same second. */ +export function readProcessStartToken(pid: number): string | null { + if (process.platform === "linux") { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8"); + const commEnd = stat.lastIndexOf(")"); + if (commEnd < 0) return null; + const fieldsFromState = stat.slice(commEnd + 2).trim().split(/\s+/); + const startTicks = fieldsFromState[19]; + return startTicks ? `linux:${startTicks}` : null; + } catch { + return null; + } + } + if (process.platform === "win32") return null; + try { + const started = execFileSync( + "ps", + ["-o", "lstart=", "-p", String(pid)], + { encoding: "utf-8", timeout: 1000 }, + ).trim(); + return started ? `${process.platform}:${started}` : null; + } catch { + return null; + } +} + function isSocketReachable(socketPath: string): Promise { return new Promise((resolve) => { const socket = net.createConnection(socketPath); @@ -1510,6 +1642,15 @@ export function cleanupSocket(name: string): void { try { fs.unlinkSync(getPidPath(name)); } catch {} + try { + const prefix = `${name}.recover-request.`; + for (const entry of fs.readdirSync(getSessionDir())) { + const nonce = entry.slice(prefix.length); + if (entry.startsWith(prefix) && /^[a-f0-9]{32}$/.test(nonce)) { + try { fs.unlinkSync(path.join(getSessionDir(), entry)); } catch {} + } + } + } catch {} } /** Remove everything including metadata. */ diff --git a/tests/help.test.ts b/tests/help.test.ts index 698550e..62f5aa1 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -12,7 +12,7 @@ const cliSource = fs.readFileSync(path.join(__dirname, "..", "src", "cli.ts"), " // Canonical subcommands that must each ship focused `--help`. const COMMANDS = [ "run", "attach", "exec", "peek", "send", "events", "list", "stats", - "restart", "kill", "rm", "gc", "tag", "tag-multi", "emit", "rename", + "restart", "recover-live", "kill", "rm", "gc", "tag", "tag-multi", "emit", "rename", "up", "down", "test", "remote-serve", ]; // Aliases that must resolve to the same help. diff --git a/tests/live-recovery.test.ts b/tests/live-recovery.test.ts new file mode 100644 index 0000000..8e8e040 --- /dev/null +++ b/tests/live-recovery.test.ts @@ -0,0 +1,430 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { terminateAndWait } from "./setup/processes.ts"; +import { encodeAttach, encodeData } from "../src/protocol.ts"; +import { PtyServer } from "../src/server.ts"; +import { + cleanupSocket, + getRecoveryRequestPath, + readLiveRecoveryRequest, + removeLiveRecoveryRequest, +} from "../src/sessions.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeBin = process.execPath; +const cliPath = path.join(__dirname, "..", "dist", "cli.js"); +const serverPath = path.join(__dirname, "..", "dist", "server.js"); +const testBase = fs.mkdtempSync(path.join(os.tmpdir(), "pty-live-recovery-")); + +let daemonPids: number[] = []; +let clients: net.Socket[] = []; +let foreignServers: net.Server[] = []; + +afterEach(async () => { + for (const client of clients) client.destroy(); + clients = []; + for (const server of foreignServers) { + await new Promise((resolve) => server.close(() => resolve())); + } + foreignServers = []; + await terminateAndWait(daemonPids); + daemonPids = []; +}); + +afterAll(() => { + fs.rmSync(testBase, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); +}); + +async function waitForPath(target: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(target)) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for ${target}`); +} + +async function startDaemon(name: string): Promise<{ + root: string; + pid: number; + providerPid: number; + snapshotPath: string; + socketPath: string; +}> { + const root = fs.mkdtempSync(path.join(testBase, "root-")); + const providerPidPath = path.join(testBase, `${name}-provider.pid`); + const config = JSON.stringify({ + name, + command: "/bin/sh", + args: [ + "-c", + `printf '%s\\n' "$$" > '${providerPidPath}'; ` + + "printf 'PROVIDER-READY\\n'; " + + "while IFS= read -r line; do printf 'PROVIDER-ACK:%s\\n' \"$line\"; done", + ], + displayCommand: "synthetic-live-provider", + cwd: os.tmpdir(), + rows: 24, + cols: 80, + tags: { keep: "true" }, + }); + const child = spawn(nodeBin, [serverPath], { + detached: true, + stdio: ["ignore", "ignore", "inherit"], + env: { + ...process.env, + PTY_ROOT: root, + PTY_SESSION_DIR: "", + PTY_SERVER_CONFIG: config, + }, + }); + child.unref(); + if (!child.pid) throw new Error("daemon did not receive a PID"); + daemonPids.push(child.pid); + + const socketPath = path.join(root, `${name}.sock`); + await waitForPath(socketPath); + await waitForPath(path.join(root, `${name}.json`)); + await waitForPath(providerPidPath); + const snapshotPath = path.join(testBase, `${name}-${child.pid}.snapshot`); + fs.copyFileSync(path.join(root, `${name}.json`), snapshotPath); + return { + root, + pid: child.pid, + providerPid: Number(fs.readFileSync(providerPidPath, "utf-8")), + snapshotPath, + socketPath, + }; +} + +function runRecover( + root: string, + name: string, + snapshotPath: string, + timeoutMs = 2000, +) { + return spawnSync( + nodeBin, + [ + cliPath, + "recover-live", + "--metadata", + snapshotPath, + "--timeout-ms", + String(timeoutMs), + name, + ], + { + encoding: "utf-8", + timeout: timeoutMs + 3000, + env: { + ...process.env, + PTY_ROOT: root, + PTY_SESSION_DIR: "", + PTY_SESSION: "", + }, + }, + ); +} + +function connect(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function unlinkRegistry(root: string, name: string): void { + fs.unlinkSync(path.join(root, `${name}.sock`)); + fs.unlinkSync(path.join(root, `${name}.pid`)); + fs.unlinkSync(path.join(root, `${name}.json`)); +} + +function waitForSocketText( + socket: net.Socket, + text: string, + timeoutMs = 3000, +): Promise { + return new Promise((resolve, reject) => { + let seen = ""; + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for socket text ${text}`)), + timeoutMs, + ); + const onData = (data: Buffer) => { + seen += data.toString("utf-8"); + if (!seen.includes(text)) return; + clearTimeout(timer); + socket.off("data", onData); + resolve(); + }; + socket.on("data", onData); + }); +} + +describe("live daemon registry recovery", () => { + it("rebinds in the same daemon and preserves an established client", async () => { + const name = "recover-success"; + const { root, pid, providerPid, snapshotPath, socketPath } = await startDaemon(name); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf-8")); + const established = await connect(socketPath); + clients.push(established); + const ready = waitForSocketText(established, "PROVIDER-READY"); + established.write(encodeAttach(24, 80)); + await ready; + + unlinkRegistry(root, name); + expect(() => process.kill(pid, 0)).not.toThrow(); + + const result = runRecover(root, name, snapshotPath); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`daemon PID ${pid}`); + expect(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf-8"))).toBe(pid); + const recovered = JSON.parse( + fs.readFileSync(path.join(root, `${name}.json`), "utf-8"), + ); + expect(recovered.generation).toBe(snapshot.generation); + expect(recovered.daemonStartToken).toBe(snapshot.daemonStartToken); + expect(established.destroyed).toBe(false); + expect(() => process.kill(providerPid, 0)).not.toThrow(); + const ack = waitForSocketText(established, "PROVIDER-ACK:continuity"); + established.write(encodeData("continuity\n")); + await ack; + + const newClient = await connect(socketPath); + clients.push(newClient); + expect(newClient.destroyed).toBe(false); + }); + + it("refuses an unsupported snapshot without poking the daemon", async () => { + const name = "recover-unsupported"; + const { root, pid, snapshotPath } = await startDaemon(name); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf-8")); + delete snapshot.recoveryProtocol; + delete snapshot.daemonStartToken; + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot)); + unlinkRegistry(root, name); + + const result = runRecover(root, name, snapshotPath, 200); + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing recovery"); + expect(() => process.kill(pid, 0)).not.toThrow(); + }); + + it("never replaces a foreign listener at the recovered pathname", async () => { + const name = "recover-conflict"; + const { root, pid, snapshotPath, socketPath } = await startDaemon(name); + unlinkRegistry(root, name); + + const foreign = net.createServer(); + foreignServers.push(foreign); + await new Promise((resolve, reject) => { + foreign.once("error", reject); + foreign.listen(socketPath, () => resolve()); + }); + + const beforeIdentity = fs.statSync(socketPath, { bigint: true }); + const result = runRecover(root, name, snapshotPath, 1200); + expect(result.status).toBe(1); + expect(result.stderr).toContain("did not republish"); + expect(() => process.kill(pid, 0)).not.toThrow(); + expect(fs.existsSync(path.join(root, `${name}.pid`))).toBe(false); + expect(fs.existsSync(path.join(root, `${name}.json`))).toBe(false); + const afterIdentity = fs.statSync(socketPath, { bigint: true }); + expect(afterIdentity.dev).toBe(beforeIdentity.dev); + expect(afterIdentity.ino).toBe(beforeIdentity.ino); + const probe = await connect(socketPath); + clients.push(probe); + expect(probe.destroyed).toBe(false); + + probe.destroy(); + await new Promise((resolve) => foreign.close(() => resolve())); + foreignServers = []; + const retry = runRecover(root, name, snapshotPath); + expect(retry.status, retry.stderr).toBe(0); + expect(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf-8"))).toBe(pid); + }); + + it("ignores a stale request and accepts a fresh replacement", async () => { + const name = "recover-stale"; + const { root, pid, snapshotPath } = await startDaemon(name); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf-8")); + unlinkRegistry(root, name); + + fs.writeFileSync( + path.join(root, `${name}.recover-request.00000000000000000000000000000000`), + JSON.stringify({ + protocol: 1, + name, + nonce: "00000000000000000000000000000000", + createdAt: new Date(Date.now() - 60_000).toISOString(), + expectedPid: pid, + expectedGeneration: snapshot.generation, + expectedStartToken: snapshot.daemonStartToken, + snapshot, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 750)); + expect(fs.existsSync(path.join(root, `${name}.sock`))).toBe(false); + expect(() => process.kill(pid, 0)).not.toThrow(); + + const fresh = runRecover(root, name, snapshotPath); + expect(fresh.status, fresh.stderr).toBe(0); + expect(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf-8"))).toBe(pid); + }); + + it("recovers on retry after a bind race fails after listener close", async () => { + const root = fs.mkdtempSync(path.join(testBase, "direct-root-")); + const previousRoot = process.env.PTY_ROOT; + const previousLegacyRoot = process.env.PTY_SESSION_DIR; + process.env.PTY_ROOT = root; + delete process.env.PTY_SESSION_DIR; + const name = "recover-bind-race"; + const server = new PtyServer({ + name, + command: "/bin/sh", + args: ["-c", "while :; do sleep 1; done"], + displayCommand: "synthetic-bind-race", + cwd: os.tmpdir(), + rows: 24, + cols: 80, + }); + let foreign: net.Server | null = null; + let recoveredClient: net.Socket | null = null; + try { + await server.ready; + const socketPath = path.join(root, `${name}.sock`); + const snapshot = JSON.parse( + fs.readFileSync(path.join(root, `${name}.json`), "utf-8"), + ); + const request = { + protocol: 1 as const, + name, + nonce: "11111111111111111111111111111111", + createdAt: new Date().toISOString(), + expectedPid: process.pid, + expectedGeneration: snapshot.generation, + expectedStartToken: snapshot.daemonStartToken, + snapshot, + }; + unlinkRegistry(root, name); + + await expect(server.recoverLiveRegistry(request, { + beforeReplacementListen: async () => { + foreign = net.createServer(); + await new Promise((resolve, reject) => { + foreign!.once("error", reject); + foreign!.listen(socketPath, () => resolve()); + }); + }, + })).rejects.toMatchObject({ code: "EADDRINUSE" }); + expect(fs.existsSync(path.join(root, `${name}.pid`))).toBe(false); + + await new Promise((resolve) => foreign!.close(() => resolve())); + foreign = null; + await server.recoverLiveRegistry({ + ...request, + nonce: "22222222222222222222222222222222", + createdAt: new Date().toISOString(), + }); + recoveredClient = await connect(socketPath); + expect(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf-8"))) + .toBe(process.pid); + } finally { + recoveredClient?.destroy(); + if (foreign) { + await new Promise((resolve) => foreign!.close(() => resolve())); + } + await server.close(); + if (previousRoot === undefined) delete process.env.PTY_ROOT; + else process.env.PTY_ROOT = previousRoot; + if (previousLegacyRoot === undefined) delete process.env.PTY_SESSION_DIR; + else process.env.PTY_SESSION_DIR = previousLegacyRoot; + } + }); + + it("removing one nonce can never delete a concurrently published request", () => { + const root = fs.mkdtempSync(path.join(testBase, "nonce-root-")); + const previousRoot = process.env.PTY_ROOT; + process.env.PTY_ROOT = root; + const name = "recover-concurrent"; + const nonceA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const nonceB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + try { + fs.writeFileSync(getRecoveryRequestPath(name, nonceA), "{}"); + fs.writeFileSync(getRecoveryRequestPath(name, nonceB), "{}"); + expect(removeLiveRecoveryRequest(name, nonceA)).toBe(true); + expect(fs.existsSync(getRecoveryRequestPath(name, nonceA))).toBe(false); + expect(fs.existsSync(getRecoveryRequestPath(name, nonceB))).toBe(true); + } finally { + if (previousRoot === undefined) delete process.env.PTY_ROOT; + else process.env.PTY_ROOT = previousRoot; + } + }); + + it("rejects a request whose payload nonce differs from its filename", () => { + const root = fs.mkdtempSync(path.join(testBase, "nonce-mismatch-root-")); + const previousRoot = process.env.PTY_ROOT; + process.env.PTY_ROOT = root; + const name = "recover-nonce-mismatch"; + const nonceA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const nonceB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + try { + fs.writeFileSync( + getRecoveryRequestPath(name, nonceA), + JSON.stringify({ + protocol: 1, + name, + nonce: nonceB, + createdAt: new Date().toISOString(), + expectedPid: process.pid, + expectedGeneration: "generation", + expectedStartToken: "start-token", + snapshot: { + recoveryProtocol: 1, + daemonStartToken: "start-token", + }, + }), + ); + fs.writeFileSync(getRecoveryRequestPath(name, nonceB), "{}"); + + expect(readLiveRecoveryRequest(name, nonceA)).toBeNull(); + expect(fs.existsSync(getRecoveryRequestPath(name, nonceB))).toBe(true); + } finally { + if (previousRoot === undefined) delete process.env.PTY_ROOT; + else process.env.PTY_ROOT = previousRoot; + } + }); + + it("cleanup cannot delete another legal session name's request", () => { + const root = fs.mkdtempSync(path.join(testBase, "cleanup-isolation-root-")); + const previousRoot = process.env.PTY_ROOT; + process.env.PTY_ROOT = root; + const name = "recover-cleanup"; + const otherName = `${name}.recover-request.child`; + const nonce = "cccccccccccccccccccccccccccccccc"; + try { + fs.writeFileSync(getRecoveryRequestPath(name, nonce), "{}"); + fs.writeFileSync(getRecoveryRequestPath(otherName, nonce), "{}"); + + cleanupSocket(name); + + expect(fs.existsSync(getRecoveryRequestPath(name, nonce))).toBe(false); + expect(fs.existsSync(getRecoveryRequestPath(otherName, nonce))).toBe(true); + } finally { + if (previousRoot === undefined) delete process.env.PTY_ROOT; + else process.env.PTY_ROOT = previousRoot; + } + }); +});