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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
170 changes: 170 additions & 0 deletions bin/pty-kill-releases-socket-test
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
Expand Down
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 16 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ Examples:

kill: `Usage: pty kill <ref>

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`,
Expand Down Expand Up @@ -556,7 +557,7 @@ Modify:
Lifecycle:
pty restart <ref> SIGTERM + respawn using stored metadata (prompts if running)
pty restart -y <ref> Same, no prompt
pty kill <ref> SIGTERM a running session's daemon
pty kill <ref> Terminate a running session and its descendants
pty recover <name> --snapshot <file> Rebind a supporting live daemon after registry unlink
pty rm <ref> Remove an exited session's metadata (alias: pty remove)
pty evidence remove --id <id> --expected-generation <opaque>
Expand Down Expand Up @@ -2640,17 +2641,26 @@ async function cmdKill(name: string): Promise<void> {
process.kill(session.pid, "SIGTERM");
} catch {
console.error(`Failed to kill session "${name}".`);
cleanupSocket(name);
process.exitCode = 1;
return;
}

// Wait for the daemon to fully exit before returning. Its shutdown re-flushes
// 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.`);

Expand Down
Loading
Loading