Skip to content
Merged
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,73 @@

## Unreleased

### One reader for the process table

- Process facts now come from one module. On Linux it reads `/proc` and spawns
nothing at all. On macOS `ps` remains, but it is read once per operation
rather than once per process per poll — the difference between 240 spawns
inside a 1500 ms deadline and 60.
- Every query separates three answers: the fact, "the table was read and this
process is not in it", and "I could not find out". A `ps` that is slow,
truncated or silent now produces the third rather than the second. There is
no default and no conversion that turns silence into absence by accident.
- A listing that does not contain the process that read it is treated as
truncated rather than as an empty machine. `ps` always lists at least itself.
- `recovery.processStartToken` is unchanged and still comes from
`ps -o lstart=`. Its exact text, including the two spaces before a
single-digit day, is a contract with the Rust tool through a shared registry.
The in-memory identity used by the teardown is a separate branded type so the
two cannot be compared by accident.
- Production `ps` call sites: six down to three, and none of them inside a
per-process poll loop.

### `pty kill` finishes the job

- After the daemon has gone, `pty kill` re-reads the process table. If anything
from its pre-kill snapshot is still alive, it signals the process groups the
session left behind, waits, escalates to SIGKILL, reads the table again, and
reports what is still there. The daemon tears the tree down on its way out, so
its teardown races its own exit; the command outlives the daemon and can
finish the work.
- The sweep targets process groups rather than PIDs, because a group signal
needs no per-process identity. A descendant whose process-start token cannot
be read is dropped from the snapshot and never signalled individually; its
group is still swept. The sweep also costs one `ps` call in total, against one
per descendant for tokens.
- The daemon's own process group is never signalled. The PTY child calls
`setsid`, so the daemon is alone in its group. The group of the process
running `pty kill` is never signalled either, so the command survives to
report. Group id 1 and below are never signalled.
- A zombie no longer counts as a process-group member. `ps` lists it with its
group, so counting it made the sweep report a group it had already emptied.

### `pty kill` reports what it verified

- `pty kill` prints `Session "X" killed.` only when the session's process tree
is gone. It takes a snapshot of the tree before it signals the daemon, and
re-checks that snapshot after the daemon exits. Before this, the command
asked about the daemon and reported about the session, so the success line
was a claim about processes it never looked at.
- When something outlives the kill, the command prints
`Session "X" daemon stopped.` on standard output, which is the part it
verified, and names the surviving PIDs on standard error. A PID is called a
survivor only when its process-start token still matches. A PID that has not
exited but whose token cannot be read is reported separately as undecided.
- A daemon that could not kill a descendant now appends
`session_descendants_survived` to the session event log, and its standard
error warning names the PIDs. The daemon's standard error has no reader, so
the log line is the copy a person can find.
- `pty kill` sends no additional signals and waits no longer than before.
- **Compatibility break.** `pty kill` now exits non-zero when anything survived,
and when a start token could not be read so the outcome is undecided. A script
that checks the status of `pty kill` will fail where it used to pass, because
it was passing on a false success. The fix for such a caller is to stop
treating an unverified kill as a completed one. A verified empty tree still
exits 0.
- On macOS, an empty `ps -o stat=` field no longer counts as a dead process.
An empty field means the process is gone or `ps` did not answer, and under
load `ps` is the thing that goes quiet, so the kernel is asked again.

### Complete session termination

- `pty kill` now stops the PTY child and its complete descendant tree. A
Expand All @@ -28,6 +95,12 @@

### Storage format

- New event type `session_descendants_survived`, carrying `data: { pids }`.
A daemon appends it when it signalled its child's process tree with TERM and
then KILL and found processes still alive. It records what the daemon could
not kill; a process that left the tree before the snapshot is not in it.


- Supporting live daemons now advertise a `recovery` capability in session
metadata. `pty recover <name> --snapshot <file>` uses that captured
capability to authenticate a signal-free listener/registry rebind after an
Expand Down
1 change: 1 addition & 0 deletions docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ
| `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) |
| `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) |
| `session_flapping` | `counter, limit, window` — (`pty gc` flipped a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it) |
| `session_descendants_survived` | `data: { pids }` — a daemon signalled its child's process tree with TERM and then KILL and these processes were still alive. A record of what it could not kill, not a list of everything that outlived the session: a process that left the tree before the snapshot is not in it |
| `display_name_change` | `previous: string\|null, value: string\|null` |
| `tags_change` | `previous, value` (full snapshots) |
| `metadata_change` | `previous, value` containing only changed `displayName` and tag keys; absent tag values are `null` |
Expand Down
77 changes: 74 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import * as path from "node:path";
import * as readline from "node:readline/promises";
import { spawnSync, execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult } from "./client.ts";
import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult,
isSessionAlive,
} from "./client.ts";
import { printVersion } from "./version.ts";
import { parseSeqValue } from "./keys.ts";
import {
Expand Down Expand Up @@ -42,9 +44,16 @@ import {
getPidPath,
getMetadataPath,
DEFAULT_SESSION_DIR,
hasProcessExitedForReap,
type SessionInfo,
type SessionMetadata,
} from "./sessions.ts";
import { snapshotDescendantProcesses } from "./process-tree.ts";
import { aftermathOf, allGone, killOutcomeLines, verifiedEmpty } from "./kill-report.ts";
import {
groupsInTree, membersOfGroups, ownProcessGroup, signalGroup, sweepGroups,
} from "./process-groups.ts";
import { openSource, valueOf } from "./proc-table.ts";
import { spawnDaemon, resolveCommand } from "./spawn.ts";
import {
acquireEventLock, appendEventSyncLocked, EventFollower, EventWriter, EventType, releaseEventLock,
Expand Down Expand Up @@ -295,6 +304,14 @@ Examples:
Terminate a running session's daemon and exact descendant tree. Metadata is kept —
restart or \`pty rm\` it later.

The daemon tears the tree down on its way out, which races its own exit. So once
the daemon has gone, \`pty kill\` re-reads the process table, signals any process
group the session left behind, and reports what is still there. It exits non-zero
unless it verified the tree is empty.

A descendant that calls setsid leaves the session and the group, and neither the
tree walk nor the group sweep can reach it.

Examples:
pty kill myserver`,

Expand Down Expand Up @@ -935,7 +952,17 @@ async function main(): Promise<void> {
console.error(e.message);
process.exit(1);
}
if (existingNames.has(explicitId) && !attachExisting) {
// **"In use" has to mean in use by something alive.** A name that
// merely exists on disk is not taken: a record whose owner is a zombie
// has no socket to answer, and refusing it would make that session name
// unusable until something reaped the corpse — the exact failure the
// liveness check in `spawn.ts` exists to prevent, one command earlier.
//
// The Rust tool asks `session_exists(name) && client::is_alive(name)`
// here. This is the same question. Measured on a Mac by Silber.pty on
// 2026-09-03: with a zombie owner, Rust created a replacement in 186 ms
// and Node exited 1.
if (existingNames.has(explicitId) && !attachExisting && await isSessionAlive(explicitId)) {
console.error(`Session id "${explicitId}" is already in use.`);
process.exit(1);
}
Expand Down Expand Up @@ -2615,6 +2642,23 @@ function formatUptime(seconds: number | null): string {
return `${d}d ${h % 24}h`;
}

/** How long the escalation gives a group to answer SIGTERM before it stops
* asking. A coding agent was measured ignoring SIGTERM for ten seconds, so
* this grace is a courtesy, not a plan. */
const ESCALATE_TERM_WAIT_MS = 2_000;
/** How long to wait after SIGKILL before reporting what is still there. */
const ESCALATE_KILL_WAIT_MS = 1_000;

/** TERM the groups, wait, KILL what is left, wait, then re-read the process
* table. Returns the pids still alive in those groups. */
async function escalateOverGroups(groups: number[]): Promise<number[]> {
return sweepGroups(groups, ownProcessGroup(), ESCALATE_TERM_WAIT_MS, ESCALATE_KILL_WAIT_MS, {
live: (targets) => membersOfGroups(targets, openSource()),
signal: signalGroup,
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
});
}

async function cmdKill(name: string): Promise<void> {
const session = await getSession(name);

Expand All @@ -2637,6 +2681,16 @@ async function cmdKill(name: string): Promise<void> {
} catch {}
}

// Take the tree BEFORE the signal. After the daemon exits its children are
// reparented to init or a subreaper, so the parent links that identify them
// as this session's processes are gone. This snapshot is the only chance to
// learn which processes the word "killed" would be a claim about.
const before = snapshotDescendantProcesses(session.pid);
// Groups come from the raw listing, NOT from `before`. The snapshot drops a
// descendant whose start token cannot be read, and that process is then never
// signalled. A group needs no identity, so this reaches it anyway.
const groups = groupsInTree(session.pid, openSource());

try {
process.kill(session.pid, "SIGTERM");
} catch {
Expand All @@ -2662,7 +2716,24 @@ async function cmdKill(name: string): Promise<void> {
return;
}
cleanupSocket(name);
console.log(`Session "${name}" killed.`);
let after = aftermathOf(before, readProcessStartToken, hasProcessExitedForReap);
let escalated: number[] | undefined;
if (!allGone(after)) {
escalated = await escalateOverGroups(groups);
// Re-measure. The report must describe the machine now, not the signals
// that were sent at it.
const again = openSource();
after = aftermathOf(
before,
(pid) => valueOf(again.identity(pid)),
hasProcessExitedForReap,
);
}
const outcome = killOutcomeLines(name, after, escalated);
for (const line of outcome.out) console.log(line);
for (const line of outcome.err) console.error(line);
// Anything left is a failure, and the status says so.
if (!verifiedEmpty(after, escalated)) process.exitCode = 1;

if (wasPermanent && session.metadata?.tags?.ptyfile) {
console.error(`Note: this session is managed by ${session.metadata.tags.ptyfile}`);
Expand Down
23 changes: 23 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ import { getSocketPath } from "./sessions.ts";
import { stripAnsi } from "./tui/colors.ts";
import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts";

/** Can anything answer on this session's socket?
*
* The Rust tool's `client::is_alive`, which is `connect(name).is_ok()`. A name
* that merely EXISTS on disk is not a name that is in use: a record whose
* owner is a zombie has no socket to answer, and refusing it would make that
* session name unusable until something reaped the corpse.
*/
export function isSessionAlive(name: string, timeoutMs = 250): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const done = (alive: boolean) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
resolve(alive);
};
const socket = net.createConnection(getSocketPath(name));
socket.setTimeout(timeoutMs, () => done(false));
socket.once("connect", () => done(true));
socket.once("error", () => done(false));
});
}

const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding)
const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol)

Expand Down
17 changes: 17 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const EventType = {
SESSION_RESPAWN: "session_respawn",
SESSION_ABANDONED: "session_abandoned",
SESSION_FLAPPING: "session_flapping",
SESSION_DESCENDANTS_SURVIVED: "session_descendants_survived",
} as const;

export type EventType = (typeof EventType)[keyof typeof EventType];
Expand Down Expand Up @@ -85,6 +86,21 @@ export interface SessionExecEvent extends EventBase {
command: string;
}

/** Emitted by a daemon that signalled its child's process tree with TERM and
* then KILL and found processes still alive. The daemon also warns on its own
* standard error, which has had no reader since the command that launched it
* stopped listening — so this log line is the copy a person can find.
*
* A record, not a guarantee: the daemon reports what it could not kill, and
* cannot report a process that left its tree before the snapshot. */
export interface SessionDescendantsSurvivedEvent extends EventBase {
type: "session_descendants_survived";
/** The surviving pids, deepest descendant first. Nested under `data` to
* match the Rust tool byte for byte; both render through the unknown-type
* fallback, so the printed line is identical. */
data: { pids: number[] };
}

/** Emitted by `pty gc` whenever it respawns a `strategy=permanent`
* session that's exited/vanished. Carries no payload beyond the
* envelope — the restart is stateless, there is no attempt counter,
Expand Down Expand Up @@ -185,6 +201,7 @@ export type EventRecord =
| SessionRespawnEvent
| SessionAbandonedEvent
| SessionFlappingEvent
| SessionDescendantsSurvivedEvent
| UserEvent
| DisplayNameChangeEvent
| TagsChangeEvent
Expand Down
Loading
Loading