From db3c5d71c55d77b6b04e7b41d33c6ebb46884447 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 25 Aug 2026 14:53:28 +0200 Subject: [PATCH] Stop exact descendants during pty kill --- CHANGELOG.md | 16 ++ README.md | 2 +- bin/pty-kill-releases-socket-test | 170 +++++++++++++++++++++ flake.nix | 2 +- package-lock.json | 7 +- package.json | 3 +- src/cli.ts | 22 ++- src/process-tree.ts | 124 +++++++++++++++ src/server.ts | 45 +++++- src/sessions.ts | 4 +- tests/kill-releases-socket-command.test.ts | 20 +++ tests/process-tree.test.ts | 80 ++++++++++ tests/rm-kill-ephemeral.test.ts | 14 ++ 13 files changed, 490 insertions(+), 19 deletions(-) create mode 100755 bin/pty-kill-releases-socket-test create mode 100644 src/process-tree.ts create mode 100644 tests/kill-releases-socket-command.test.ts create mode 100644 tests/process-tree.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d17bd..3a603cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### Complete session termination + +- `pty kill` now stops the PTY child and its complete descendant tree. A + background descendant that previously survived `pty kill` now stops. +- Descendants are captured before the PTY child exits. Each PID remains bound + to its process-start identity before every exact signal, which prevents a + reused PID from becoming a target. The shutdown never signals a process + group. It sends TERM first and uses KILL only for an exact descendant that + ignores the bounded grace period. +- `pty kill` now returns a nonzero error when its daemon remains alive after + seven seconds. The error names the daemon PID and socket instead of reporting + success before a blocked replacement start. +- The installed `pty-kill-releases-socket-test` command performs a real + kill-then-restart cycle. It succeeds only when the replacement owns the same + Unix socket without manual cleanup. + ### Key input notation - Named key input is case-insensitive and accepts `+`, `-`, or `_` modifier diff --git a/README.md b/README.md index d842070..b463777 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ pty emit myserver user.build.finished --json '{"ok":true}' # with JSON payload pty emit myserver user.note --text "checkpoint reached" # with a text payload pty restart myserver # restart an exited session (must have been preserved) -pty kill myserver # terminate a running session +pty kill myserver # terminate a running session and its descendants pty --root /state/pty recover myserver --snapshot ./myserver.json # rebind a live supporting daemon after external unlink pty rm myserver # remove an exited session's metadata pty gc # reconcile: kill orphan children, respawn permanents, sweep vanished diff --git a/bin/pty-kill-releases-socket-test b/bin/pty-kill-releases-socket-test new file mode 100755 index 0000000..d33af22 --- /dev/null +++ b/bin/pty-kill-releases-socket-test @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +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 { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const self = fileURLToPath(import.meta.url); + +function waitForFile(file, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const check = () => { + if (fs.existsSync(file)) return resolve(); + if (Date.now() >= deadline) return reject(new Error(`timeout waiting for ${file}`)); + setTimeout(check, 25); + }; + check(); + }); +} + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function waitForExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return !isAlive(pid); +} + +async function runSocketOwner(socketPath, readyPath, pidPath) { + const server = net.createServer(); + let stopping = false; + const stop = () => { + if (stopping) return; + stopping = true; + server.close(() => { + try { fs.unlinkSync(socketPath); } catch {} + process.exit(0); + }); + }; + process.on("SIGHUP", () => {}); + process.on("SIGTERM", () => {}); + process.on("SIGINT", stop); + const ready = () => { + fs.writeFileSync(pidPath, `${process.pid}\n`); + fs.writeFileSync(readyPath, "ready\n"); + }; + server.once("error", (error) => { + if (error.code !== "EADDRINUSE") { + process.stderr.write(`socket owner failed: ${error.message}\n`); + process.exit(73); + } + const probe = net.createConnection(socketPath); + probe.once("connect", () => { + probe.destroy(); + process.stderr.write("socket owner failed: live owner still accepts connections\n"); + process.exit(73); + }); + probe.once("error", () => { + try { fs.unlinkSync(socketPath); } catch {} + server.listen(socketPath, ready); + }); + }); + server.listen(socketPath, ready); +} + +function runLauncher(socketPath, readyPath, pidPath) { + const middle = spawn(process.execPath, [self, "--middle", socketPath, readyPath, pidPath], { + stdio: "ignore", + }); + middle.unref(); + setInterval(() => {}, 1 << 30); +} + +function runMiddle(socketPath, readyPath, pidPath) { + const owner = spawn(process.execPath, [self, "--socket-owner", socketPath, readyPath, pidPath], { + stdio: "ignore", + }); + owner.unref(); + setInterval(() => {}, 1 << 30); +} + +if (process.argv[2] === "--socket-owner") { + await runSocketOwner(process.argv[3], process.argv[4], process.argv[5]); +} else if (process.argv[2] === "--launcher") { + runLauncher(process.argv[3], process.argv[4], process.argv[5]); +} else if (process.argv[2] === "--middle") { + runMiddle(process.argv[3], process.argv[4], process.argv[5]); +} else { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-kill-socket-")); + const registry = path.join(root, "registry"); + fs.mkdirSync(registry, { mode: 0o700 }); + const socketPath = path.join(root, "owned.sock"); + const firstReady = path.join(root, "first.ready"); + const firstPidPath = path.join(root, "first.pid"); + const secondReady = path.join(root, "second.ready"); + const secondPidPath = path.join(root, "second.pid"); + const session = `kill-socket-${process.pid}`; + const ptyBin = process.env.PTY_TEST_BIN || "pty"; + const env = { + ...process.env, + PTY_ROOT: registry, + PTY_ROOT_LEGACY_SILENT: "1", + }; + const runPty = (...args) => spawnSync(ptyBin, args, { + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + let firstOwner = null; + let secondOwner = null; + let result = 1; + try { + const firstStart = runPty( + "run", "-d", "--id", session, "--no-display-name", "--", + process.execPath, self, "--launcher", socketPath, firstReady, firstPidPath, + ); + if (firstStart.status !== 0) { + throw new Error(`initial start failed: ${firstStart.stderr || firstStart.stdout}`); + } + await waitForFile(firstReady, 5000); + firstOwner = Number(fs.readFileSync(firstPidPath, "utf8").trim()); + + const killed = runPty("kill", session); + if (killed.status !== 0) { + throw new Error(`pty kill failed: ${killed.stderr || killed.stdout}`); + } + + const secondStart = runPty( + "run", "-a", "-d", "--id", session, "--no-display-name", "--", + process.execPath, self, "--launcher", socketPath, secondReady, secondPidPath, + ); + if (secondStart.status !== 0) { + throw new Error(`replacement start failed: ${secondStart.stderr || secondStart.stdout}`); + } + await waitForFile(secondReady, 5000); + secondOwner = Number(fs.readFileSync(secondPidPath, "utf8").trim()); + process.stdout.write( + `PASS ${process.platform}: pty kill released the owned socket and replacement start completed\n`, + ); + result = 0; + } catch (error) { + const owner = firstOwner && isAlive(firstOwner) ? `; surviving socket owner pid ${firstOwner}` : ""; + process.stderr.write(`FAIL ${process.platform}: ${error.message}${owner}\n`); + } finally { + runPty("kill", session); + for (const pid of [firstOwner, secondOwner]) { + if (!pid || !isAlive(pid)) continue; + try { process.kill(pid, "SIGTERM"); } catch {} + if (!await waitForExit(pid, 1000)) { + try { process.kill(pid, "SIGKILL"); } catch {} + } + } + try { fs.unlinkSync(socketPath); } catch {} + try { fs.rmSync(root, { recursive: true, force: true }); } catch {} + } + process.exit(result); +} diff --git a/flake.nix b/flake.nix index 2079489..31cdb71 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ # Generated from package-lock.json. # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json - npmDepsHash = "sha256-tpcQNvod3UWOkY0/QT5RWUH2y9fP9TygV4NmnZ3LKpw="; + npmDepsHash = "sha256-ffjAx6D1ulczpn7o6bLVjMBkJpjm0bnNpTbjQIr4mM8="; # node-pty has native code that needs these at build time nativeBuildInputs = with pkgs; [ python3 pkg-config ]; diff --git a/package-lock.json b/package-lock.json index a08a4f9..db67cc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@compoundingtech/pty", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@compoundingtech/pty", - "version": "0.11.0", + "version": "0.12.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -17,7 +17,8 @@ "smol-toml": "^1.6.1" }, "bin": { - "pty": "bin/pty" + "pty": "bin/pty", + "pty-kill-releases-socket-test": "bin/pty-kill-releases-socket-test" }, "devDependencies": { "@preact/signals-core": "^1.14.0", diff --git a/package.json b/package.json index 51d7a2f..f8e0988 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "LICENSE" ], "bin": { - "pty": "./bin/pty" + "pty": "./bin/pty", + "pty-kill-releases-socket-test": "./bin/pty-kill-releases-socket-test" }, "exports": { "./testing": { diff --git a/src/cli.ts b/src/cli.ts index d4c0ef0..d135714 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -292,7 +292,8 @@ Examples: kill: `Usage: pty kill -SIGTERM a running session's daemon. Metadata is kept — restart or \`pty rm\` it later. +Terminate a running session's daemon and exact descendant tree. Metadata is kept — +restart or \`pty rm\` it later. Examples: pty kill myserver`, @@ -556,7 +557,7 @@ Modify: Lifecycle: pty restart SIGTERM + respawn using stored metadata (prompts if running) pty restart -y Same, no prompt - pty kill SIGTERM a running session's daemon + pty kill Terminate a running session and its descendants pty recover --snapshot Rebind a supporting live daemon after registry unlink pty rm Remove an exited session's metadata (alias: pty remove) pty evidence remove --id --expected-generation @@ -2640,7 +2641,7 @@ async function cmdKill(name: string): Promise { process.kill(session.pid, "SIGTERM"); } catch { console.error(`Failed to kill session "${name}".`); - cleanupSocket(name); + process.exitCode = 1; return; } @@ -2648,9 +2649,18 @@ async function cmdKill(name: string): Promise { // exit metadata to disk (an atomic tmp-write + rename); if we returned while // that was still in flight, a caller that immediately `pty rm`s the session // could race the late write and leave a stray temp file behind. Bounded — the - // SIGTERM shutdown path settles in ~2s; if it somehow overruns we clean up and - // return anyway (the daemon finishes on its own). - await waitForProcessExit(session.pid, 3000); + // SIGTERM shutdown path settles in ~2s. If it overruns, preserve the socket + // evidence and return a loud failure. Returning success while the daemon is alive + // makes the next start look broken and hides the process that blocked it. + const exited = await waitForProcessExit(session.pid, 7000); + if (!exited) { + console.error( + `Failed to kill session "${name}": daemon PID ${session.pid} is still running ` + + `after 7s. Socket ${getSocketPath(name)} may still be owned.`, + ); + process.exitCode = 1; + return; + } cleanupSocket(name); console.log(`Session "${name}" killed.`); diff --git a/src/process-tree.ts b/src/process-tree.ts new file mode 100644 index 0000000..73402c1 --- /dev/null +++ b/src/process-tree.ts @@ -0,0 +1,124 @@ +import { execFileSync } from "node:child_process"; +import { readProcessStartToken } from "./recovery.ts"; + +export interface ProcessIdentity { + pid: number; + processStartToken: string; + depth: number; +} + +interface ProcessTreeDeps { + listProcesses?: () => string; + readStartToken?: (pid: number) => string | null; + signal?: (pid: number, signal: NodeJS.Signals) => void; + sleep?: (ms: number) => Promise; +} + +function listProcesses(): string { + return execFileSync("ps", ["-axo", "pid=,ppid="], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2_000, + }); +} + +/** Take one parent-chain snapshot before the PTY leader can exit and lose its + * descendants to init or a subreaper. Every PID is bound to its process start + * identity so later signals cannot target a reused PID. */ +export function snapshotDescendantProcesses( + rootPid: number, + deps: ProcessTreeDeps = {}, +): ProcessIdentity[] { + const output = (deps.listProcesses ?? listProcesses)(); + const readStartToken = deps.readStartToken ?? readProcessStartToken; + const children = new Map(); + for (const line of output.split("\n")) { + const match = line.trim().match(/^(\d+)\s+(\d+)$/); + if (!match) continue; + const pid = Number(match[1]); + const ppid = Number(match[2]); + const siblings = children.get(ppid) ?? []; + siblings.push(pid); + children.set(ppid, siblings); + } + + const descendants: ProcessIdentity[] = []; + const seen = new Set([rootPid]); + const queue = (children.get(rootPid) ?? []).map((pid) => ({ pid, depth: 1 })); + while (queue.length > 0) { + const current = queue.shift()!; + if (seen.has(current.pid)) continue; + seen.add(current.pid); + const processStartToken = readStartToken(current.pid); + if (processStartToken !== null) { + descendants.push({ ...current, processStartToken }); + } + for (const pid of children.get(current.pid) ?? []) { + queue.push({ pid, depth: current.depth + 1 }); + } + } + return descendants.sort((a, b) => b.depth - a.depth || b.pid - a.pid); +} + +function isSameProcess( + identity: ProcessIdentity, + readStartToken: (pid: number) => string | null, +): boolean { + return readStartToken(identity.pid) === identity.processStartToken; +} + +/** Signal only identities that still match their snapshot. A token mismatch + * means the original process exited and the PID may now belong to anything. */ +export function signalProcessIdentities( + identities: ProcessIdentity[], + signal: NodeJS.Signals, + deps: ProcessTreeDeps = {}, +): number[] { + const readStartToken = deps.readStartToken ?? readProcessStartToken; + const sendSignal = deps.signal ?? ((pid, value) => process.kill(pid, value)); + const signalled: number[] = []; + for (const identity of identities) { + if (!isSameProcess(identity, readStartToken)) continue; + try { + sendSignal(identity.pid, signal); + signalled.push(identity.pid); + } catch {} + } + return signalled; +} + +async function waitForIdentitiesToExit( + identities: ProcessIdentity[], + timeoutMs: number, + deps: ProcessTreeDeps, +): Promise { + const readStartToken = deps.readStartToken ?? readProcessStartToken; + const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const deadline = Date.now() + timeoutMs; + let survivors = identities.filter((identity) => isSameProcess(identity, readStartToken)); + while (survivors.length > 0 && Date.now() < deadline) { + await sleep(25); + survivors = survivors.filter((identity) => isSameProcess(identity, readStartToken)); + } + return survivors; +} + +/** Stop an exact descendant snapshot without a process-group signal. TERM + * gives cooperative servers time to release sockets. KILL is a bounded + * backstop for descendants that ignore TERM. */ +export async function terminateProcessIdentities( + identities: ProcessIdentity[], + options: { termWaitMs?: number; killWaitMs?: number } = {}, + deps: ProcessTreeDeps = {}, +): Promise { + if (identities.length === 0) return []; + signalProcessIdentities(identities, "SIGTERM", deps); + const afterTerm = await waitForIdentitiesToExit( + identities, + options.termWaitMs ?? 1_500, + deps, + ); + if (afterTerm.length === 0) return []; + signalProcessIdentities(afterTerm, "SIGKILL", deps); + return waitForIdentitiesToExit(afterTerm, options.killWaitMs ?? 500, deps); +} diff --git a/src/server.ts b/src/server.ts index a0471d0..b710342 100644 --- a/src/server.ts +++ b/src/server.ts @@ -65,6 +65,12 @@ import { type RecoveryResultPayload, } from "./recovery.ts"; import type { StatsResult } from "./client.ts"; +import { + signalProcessIdentities, + snapshotDescendantProcesses, + terminateProcessIdentities, + type ProcessIdentity, +} from "./process-tree.ts"; interface Client { socket: net.Socket; @@ -306,6 +312,7 @@ export class PtyServer { private recoveryInFlight = false; private recoveryWatcher: fs.FSWatcher | null = null; private lastTitle = ""; + private shutdownDescendants: ProcessIdentity[] = []; readonly ready: Promise; // Resolves when the child process's onExit has fired — used by close() to // make sure session_exit has been queued to the event chain before we @@ -1330,7 +1337,17 @@ export class PtyServer { } /** Clean up resources. Does not call process.exit(). */ - close(): Promise { + close(options: { terminateDescendants?: boolean } = {}): Promise { + if (options.terminateDescendants && this.shutdownDescendants.length === 0) { + try { + this.shutdownDescendants = snapshotDescendantProcesses(this.ptyProcess.pid); + } catch (error) { + console.error( + `pty daemon "${this.name}": could not snapshot child processes: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } if (this.recoveryRoot) { try { this.recoveryWatcher?.close(); } catch {} this.recoveryWatcher = null; @@ -1357,15 +1374,32 @@ export class PtyServer { try { this.ptyProcess.kill(); } catch {} + const descendantsDone = options.terminateDescendants + ? terminateProcessIdentities(this.shutdownDescendants) + : Promise.resolve([]); // Wait for the child's onExit to fire (which enqueues session_exit) // before draining the writer. Without this, SIGTERM-initiated // shutdowns race: kill() returns synchronously but onExit fires // later, after we've already flushed. Bound with a short timeout in // case the child never exits (shouldn't happen — we just killed it). - await Promise.race([ - this.childExited, - new Promise((r) => setTimeout(r, 2000)), + const childExited = await Promise.race([ + this.childExited.then(() => true), + new Promise((r) => setTimeout(() => r(false), 2000)), ]); + if (!childExited) { + try { this.ptyProcess.kill("SIGKILL"); } catch {} + await Promise.race([ + this.childExited, + new Promise((r) => setTimeout(r, 500)), + ]); + } + const survivingDescendants = await descendantsDone; + if (survivingDescendants.length > 0) { + console.error( + `pty daemon "${this.name}": ${survivingDescendants.length} child process(es) ` + + "did not exit after exact TERM and KILL signals", + ); + } if (this.exited) await this.saveExitMetadataUntilSettled(this.exitCode); try { await this.eventWriter.flush(); } catch {} resolve(); @@ -1380,6 +1414,7 @@ export class PtyServer { * Best-effort — a SIGKILL is unblockable, but the child may already be gone. */ forceKillChild(): void { try { this.ptyProcess.kill("SIGKILL"); } catch {} + signalProcessIdentities(this.shutdownDescendants, "SIGKILL"); } } @@ -1521,7 +1556,7 @@ if (process.argv[1]?.endsWith("/server.js")) { } catch {} process.exit(code); }, SHUTDOWN_DEADLINE_MS); - shutdownPromise = server.close().then(() => { + shutdownPromise = server.close({ terminateDescendants: externalKill }).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 diff --git a/src/sessions.ts b/src/sessions.ts index a6f93b4..da677a8 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -2120,10 +2120,10 @@ export function isProcessAlive(pid: number): boolean { export async function waitForProcessExit(pid: number, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - if (!isProcessAlive(pid)) return true; + if (hasProcessExitedForReap(pid)) return true; await new Promise((r) => setTimeout(r, 50)); } - return !isProcessAlive(pid); + return hasProcessExitedForReap(pid); } async function probeSocketsWithinBudget( diff --git a/tests/kill-releases-socket-command.test.ts b/tests/kill-releases-socket-command.test.ts new file mode 100644 index 0000000..6ce1456 --- /dev/null +++ b/tests/kill-releases-socket-command.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const checkCommand = path.join(repoRoot, "bin", "pty-kill-releases-socket-test"); +const ptyCommand = path.join(repoRoot, "bin", "pty"); + +describe("pty-kill-releases-socket-test", () => { + it("completes a real kill-then-restart cycle with the same owned socket", () => { + const output = execFileSync(checkCommand, { + cwd: repoRoot, + env: { ...process.env, PTY_TEST_BIN: ptyCommand }, + encoding: "utf8", + timeout: 15_000, + }); + expect(output).toContain(`PASS ${process.platform}`); + }, 20_000); +}); diff --git a/tests/process-tree.test.ts b/tests/process-tree.test.ts new file mode 100644 index 0000000..3d0b568 --- /dev/null +++ b/tests/process-tree.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + signalProcessIdentities, + snapshotDescendantProcesses, + terminateProcessIdentities, + type ProcessIdentity, +} from "../src/process-tree.ts"; + +describe("exact descendant process shutdown", () => { + it("snapshots only descendants and records depth plus process-start identity", () => { + const tokens = new Map([ + [11, "start-11"], + [12, "start-12"], + [13, "start-13"], + ]); + const snapshot = snapshotDescendantProcesses(10, { + listProcesses: () => [ + "10 1", + "11 10", + "12 11", + "13 10", + "14 99", + ].join("\n"), + readStartToken: (pid) => tokens.get(pid) ?? null, + }); + + expect(snapshot).toEqual([ + { pid: 12, processStartToken: "start-12", depth: 2 }, + { pid: 13, processStartToken: "start-13", depth: 1 }, + { pid: 11, processStartToken: "start-11", depth: 1 }, + ]); + }); + + it("never signals a PID whose process-start identity changed", () => { + const identities: ProcessIdentity[] = [ + { pid: 20, processStartToken: "original-20", depth: 1 }, + { pid: 21, processStartToken: "original-21", depth: 1 }, + ]; + const signals: Array<[number, NodeJS.Signals]> = []; + + const signalled = signalProcessIdentities(identities, "SIGTERM", { + readStartToken: (pid) => pid === 20 ? "reused-20" : "original-21", + signal: (pid, signal) => { signals.push([pid, signal]); }, + }); + + expect(signalled).toEqual([21]); + expect(signals).toEqual([[21, "SIGTERM"]]); + }); + + it("uses exact TERM then exact KILL without a process-group signal", async () => { + const identities: ProcessIdentity[] = [ + { pid: 30, processStartToken: "start-30", depth: 2 }, + { pid: 31, processStartToken: "start-31", depth: 1 }, + ]; + const live = new Map(identities.map((identity) => [identity.pid, identity.processStartToken])); + const signals: Array<[number, NodeJS.Signals]> = []; + + const survivors = await terminateProcessIdentities( + identities, + { termWaitMs: 0, killWaitMs: 1 }, + { + readStartToken: (pid) => live.get(pid) ?? null, + signal: (pid, signal) => { + signals.push([pid, signal]); + if (signal === "SIGKILL") live.delete(pid); + }, + sleep: async () => {}, + }, + ); + + expect(survivors).toEqual([]); + expect(signals).toEqual([ + [30, "SIGTERM"], + [31, "SIGTERM"], + [30, "SIGKILL"], + [31, "SIGKILL"], + ]); + expect(signals.every(([pid]) => pid > 0)).toBe(true); + }); +}); diff --git a/tests/rm-kill-ephemeral.test.ts b/tests/rm-kill-ephemeral.test.ts index 86c9c46..3bea3c2 100644 --- a/tests/rm-kill-ephemeral.test.ts +++ b/tests/rm-kill-ephemeral.test.ts @@ -156,6 +156,20 @@ describe("pty kill", () => { expect(result.status).not.toBe(0); expect(result.stdout).toContain("not found"); }, 15000); + + it("returns a loud nonzero error when the daemon does not stop", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const daemonPid = await startDaemon(dir, name, "cat"); + process.kill(daemonPid, "SIGSTOP"); + + const result = runCli(dir, "kill", name); + expect(result.status).not.toBe(0); + expect(result.stdout).toContain(`daemon PID ${daemonPid} is still running after 7s`); + expect(result.stdout).toContain(`${name}.sock may still be owned`); + + process.kill(daemonPid, "SIGKILL"); + }, 15000); }); // --- pty rm ---