Skip to content

Commit 94cd891

Browse files
authored
Make pty kill verify and finish the kill, read the process table in one place, and stop waiting to say a name is taken (#170)
* Say what the kill verified, not what it hoped `pty kill` signals the daemon and waits for that one pid. It then prints `Session "X" killed.` The child, and everything the child started, is never looked at. The word is a claim about a session made on evidence about a daemon. This takes a snapshot of the daemon's process tree before the signal, and re-checks it after the daemon exits. The success line now appears only when every process in the snapshot is gone. Otherwise the command prints `Session "X" daemon stopped.`, which is the part it verified, and names the survivors on standard error. The snapshot must come first. After the daemon exits its children reparent away, so the links that identify them are gone. The pre-kill snapshot is also taken at a calm moment, while the daemon takes its own during shutdown, so the command can see a process the daemon's teardown skipped. A pid is reported as surviving only when its start token still matches. A token that cannot be read on a process that has not exited is reported separately as undecided, rather than being folded into either answer. A zombie is not a survivor. It answers `kill(pid, 0)` and keeps its start token, so the check reads the process state through `hasProcessExitedForReap`. Two supporting changes: A daemon that could not kill a descendant now appends `session_descendants_survived` to the session event log, and names the pids in its stderr warning. The daemon's stderr has no reader. The Rust tool already writes this event; this closes the gap. 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. The new check depends on this predicate, so it had to stop reading silence as death. This sends no additional signals. * Make the exit status agree with the words `pty kill` printed a survivor report and exited 0. A caller that reads only the status reached the opposite conclusion from one that reads the output, which leaves the honest line as decoration. It now exits non-zero when anything survived, and when a start token could not be read so the outcome is undecided. "I could not confirm the tree is empty" is not success. This is a compatibility break. 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. Nathan decided the survivor case on 2026-09-03. Silber.cos decided the undecidable one. * Finish the kill instead of reporting that it did not The daemon tears down the child's tree on its way out, so the teardown races its own exit. Whatever it does not manage is nobody's work after that, and the command that outlives it does nothing about it. `pty kill` now re-reads the process table after the daemon has gone. 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. It never reports the sending. Process groups rather than pids, because a group signal needs no identity. The snapshot drops a descendant whose start token cannot be read, and that process is then never signalled; groups are collected from the raw listing, so it is reached anyway. The blind spot is not solved, it is made irrelevant. A sweep also costs one `ps` in total, against one per descendant for tokens. The daemon's own group is never a target. The pty child calls setsid, so the daemon sits alone in its group and signalling it reaches the daemon only. The running process's own group is never a target either, so the command survives to print its result. A zombie is not a group member. `ps` lists it with its group, so counting it makes the sweep report a group it has already emptied. A test against a real process group found this; inspection did not. * Do not call a tree empty while the escalation left something running The success line and the exit status read only the pre-kill snapshot. That snapshot drops a descendant whose start token could not be read, and it never contained a process spawned after it was taken. So a process the sweep found and could not kill can be absent from it entirely, and the command would print `killed` and exit 0 over a process that had just survived SIGKILL. That is the defect this command exists to stop making, reintroduced by the escalation that was supposed to end it. Both halves are now required: the snapshot must be clear AND the sweep must have left nothing behind. Found by reading the diff rather than by a failing test, so the test came after; it fails without the fix. * Read the process table in one place, and stop spawning ps on Linux Every caller that needed a fact about a process ran its own `ps` and treated the output as fact. A subprocess can be slow, truncated or silent, and all three look exactly like "the process is gone". Node cannot make the syscalls the Rust tool uses, so this does what Node can. On Linux there is now no subprocess at all: `/proc` carries ppid, pgid, state and starttime, which is every fact the callers ask for. On macOS `ps` stays, but the table is read once per operation rather than once per process per poll. In the teardown loop that is the difference between 240 spawns inside a 1500 ms deadline and 60. Silence is a third answer everywhere. Every query separates the fact from "the table was read and this process is not in it" from "I could not find out", with no default and no conversion that turns the last into the middle by accident. Treating silence as death requires calling `orAbsentWhenUnknown`, which greps in one command. That makes the mistake visible rather than impossible. A listing that does not contain the process that read it was truncated, not empty. `ps` always lists itself. `recovery.processStartToken` is untouched and still comes from `ps -o lstart=`. Its exact text is a contract with the Rust tool through a shared registry, so the parser takes the tail verbatim rather than re-joining split fields, which would have rewritten `Wed Sep 3` as `Wed Sep 3`. The in-memory identity is a separate branded type so the two can never be compared. Production `ps` call sites: six to three, none in a per-process poll loop. * Do not count a corpse as a surviving descendant An unreaped descendant keeps its `/proc` row and its identity on Linux, so matching on identity alone counted it as alive. The teardown would wait out its whole TERM budget for a process that had already died, and then report it as having survived a SIGKILL. That is the kill over-claiming again, in the other direction. macOS never had this, because `ps` stops listing a process the moment it exits. The two platforms disagreeing is what exposed it. Two test fixes from a real Mac run, reported by Silber.pty: macOS has the `setsid` system call but no `setsid` executable. The real process-group test spawned the binary, so on the one platform where process groups are the whole escalation story, the test could not run at all. It now uses `detached: true`, which is the same thing without the command. The zombie test asserted the Linux mechanism rather than the conclusion. On Linux the corpse keeps a row with state Z; on macOS it is dropped from the listing at once. The test now asserts what every caller depends on, which is the same on both. A single-pid query no longer reads the whole table. `hasProcessExitedForReap` sits inside poll loops that run every 25 ms, and asking about one pid should not pay for every process on the machine. * Parse what ps writes, not what the parser expected The single-process read handed the subprocess's raw stdout to the single-line parser. `ps` ends its output with a newline and that parser's `$` does not match before one, so on macOS a live process read as "field-empty" and a zombie read as NOT EXITED. The teardown would then have waited out its whole budget for a corpse. That is the corpse defect a third time, reintroduced by a second parsing path that Linux never exercised, because on Linux this read goes to `/proc`. There is one parser now. The single-process read goes through `parsePsListing`, which splits lines first and checks that the row for the pid it asked about is actually present, so both jobs are done by the code that was already tested. The other two `ps` call sites were checked for the same seam. `server.ts` and `recovery.ts` both trim before parsing; only the path added by this branch did not. Nothing caught it because every test built its input the way the parser expected. They all agreed with each other and none of them agreed with `ps`. So there is now a test that runs the real command with the real arguments and feeds the parser exactly what the subprocess wrote, newline and all. Reverting to the old behaviour fails it. Found on a real Mac by Silber.pty. * Say "is already running" at once instead of after thirty seconds A `pty run` that loses a creation race waited out the whole start budget and then reported a generic publication timeout. `wait_for_publication` compares the published metadata against its OWN pid, so for a loser that check is false for the rest of the budget. Its only other way out of the loop is noticing its own daemon die. When that is slow — a loaded machine, a daemon still starting up — the loop spends the entire thirty second default and reports a timeout, when the true answer was on disk in the first iteration. Measured on a Mac by Silber.pty on 2026-09-03: 30.06 s against a 30 s budget, saying "Timed out waiting for daemon publication" instead of "is already running". The loop now checks whether the name is published by a live process that is not us, and stops with the sentence `pty run` already prints when it sees a running session before it spawns. Losing the race later should not produce a different explanation of the same situation. All three conditions matter and each is tested. Published, or a name whose metadata is still being written would be refused. A different pid, or a successful spawn would refuse itself. A live one, or stale metadata from a dead daemon would make the name permanently unusable. The safety property was never in question. The test that found this asserts exactly one winner before it checks the loser message, and that assertion passed every time. What was wrong is what the loser said, and how long it took. * Do not call a zombie daemon a live owner The new check refuses a session name when it is published by a live process that is not us. It asked `pid_alive` / `isProcessAlive`, and a zombie answers `kill(pid, 0)`. So an unreaped daemon would have counted as live and the name would have been refused for as long as the corpse went unreaped. That is the exact failure the liveness condition exists to prevent, arrived at by the check that was supposed to prevent it. An unreaped daemon is the precise case that matters here: dead, not reaped, still in the process list. Both now ask `has_process_exited_for_reap` / `hasProcessExitedForReap`, which reads the process state and counts a zombie as gone. Tested against a real corpse in both languages, with the predicate the production path actually passes. Reverting to the cheap predicate fails both. Node also exports `hasProcessExitedForReap`, which was private. Silber.cos asked what this check does with a zombie daemon on macOS. The answer was worse than the question: it was wrong on both platforms. * Do not call a name in use when nothing alive is using it `pty run --id X` refused when a session record named X existed on disk, without asking whether anything was answering for it. A record whose owner is a zombie has no socket, so the name was unusable until something reaped the corpse. That is the same failure the liveness check in the spawner exists to prevent, one command earlier and still shipping. The Rust tool already asks `session_exists(name) && client::is_alive(name)` here. This adds the missing half: `isSessionAlive`, a faithful port of `client::is_alive`, which is a socket connect. Measured on a Mac by Silber.pty on 2026-09-03 with a real zombie owner: Rust created a replacement in 186 ms, Node exited 1 with "is already in use". Verified end to end on Linux with a real zombie-owned record and no socket: Node now creates the session, and reverting the liveness condition refuses it again.
1 parent f990e38 commit 94cd891

18 files changed

Lines changed: 1598 additions & 82 deletions

CHANGELOG.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,73 @@
22

33
## Unreleased
44

5+
### One reader for the process table
6+
7+
- Process facts now come from one module. On Linux it reads `/proc` and spawns
8+
nothing at all. On macOS `ps` remains, but it is read once per operation
9+
rather than once per process per poll — the difference between 240 spawns
10+
inside a 1500 ms deadline and 60.
11+
- Every query separates three answers: the fact, "the table was read and this
12+
process is not in it", and "I could not find out". A `ps` that is slow,
13+
truncated or silent now produces the third rather than the second. There is
14+
no default and no conversion that turns silence into absence by accident.
15+
- A listing that does not contain the process that read it is treated as
16+
truncated rather than as an empty machine. `ps` always lists at least itself.
17+
- `recovery.processStartToken` is unchanged and still comes from
18+
`ps -o lstart=`. Its exact text, including the two spaces before a
19+
single-digit day, is a contract with the Rust tool through a shared registry.
20+
The in-memory identity used by the teardown is a separate branded type so the
21+
two cannot be compared by accident.
22+
- Production `ps` call sites: six down to three, and none of them inside a
23+
per-process poll loop.
24+
25+
### `pty kill` finishes the job
26+
27+
- After the daemon has gone, `pty kill` re-reads the process table. If anything
28+
from its pre-kill snapshot is still alive, it signals the process groups the
29+
session left behind, waits, escalates to SIGKILL, reads the table again, and
30+
reports what is still there. The daemon tears the tree down on its way out, so
31+
its teardown races its own exit; the command outlives the daemon and can
32+
finish the work.
33+
- The sweep targets process groups rather than PIDs, because a group signal
34+
needs no per-process identity. A descendant whose process-start token cannot
35+
be read is dropped from the snapshot and never signalled individually; its
36+
group is still swept. The sweep also costs one `ps` call in total, against one
37+
per descendant for tokens.
38+
- The daemon's own process group is never signalled. The PTY child calls
39+
`setsid`, so the daemon is alone in its group. The group of the process
40+
running `pty kill` is never signalled either, so the command survives to
41+
report. Group id 1 and below are never signalled.
42+
- A zombie no longer counts as a process-group member. `ps` lists it with its
43+
group, so counting it made the sweep report a group it had already emptied.
44+
45+
### `pty kill` reports what it verified
46+
47+
- `pty kill` prints `Session "X" killed.` only when the session's process tree
48+
is gone. It takes a snapshot of the tree before it signals the daemon, and
49+
re-checks that snapshot after the daemon exits. Before this, the command
50+
asked about the daemon and reported about the session, so the success line
51+
was a claim about processes it never looked at.
52+
- When something outlives the kill, the command prints
53+
`Session "X" daemon stopped.` on standard output, which is the part it
54+
verified, and names the surviving PIDs on standard error. A PID is called a
55+
survivor only when its process-start token still matches. A PID that has not
56+
exited but whose token cannot be read is reported separately as undecided.
57+
- A daemon that could not kill a descendant now appends
58+
`session_descendants_survived` to the session event log, and its standard
59+
error warning names the PIDs. The daemon's standard error has no reader, so
60+
the log line is the copy a person can find.
61+
- `pty kill` sends no additional signals and waits no longer than before.
62+
- **Compatibility break.** `pty kill` now exits non-zero when anything survived,
63+
and when a start token could not be read so the outcome is undecided. A script
64+
that checks the status of `pty kill` will fail where it used to pass, because
65+
it was passing on a false success. The fix for such a caller is to stop
66+
treating an unverified kill as a completed one. A verified empty tree still
67+
exits 0.
68+
- On macOS, an empty `ps -o stat=` field no longer counts as a dead process.
69+
An empty field means the process is gone or `ps` did not answer, and under
70+
load `ps` is the thing that goes quiet, so the kernel is asked again.
71+
572
### Complete session termination
673

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

2996
### Storage format
3097

98+
- New event type `session_descendants_survived`, carrying `data: { pids }`.
99+
A daemon appends it when it signalled its child's process tree with TERM and
100+
then KILL and found processes still alive. It records what the daemon could
101+
not kill; a process that left the tree before the snapshot is not in it.
102+
103+
31104
- Supporting live daemons now advertise a `recovery` capability in session
32105
metadata. `pty recover <name> --snapshot <file>` uses that captured
33106
capability to authenticate a signal-free listener/registry rebind after an

docs/disk-layout.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ
133133
| `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) |
134134
| `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) |
135135
| `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) |
136+
| `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 |
136137
| `display_name_change` | `previous: string\|null, value: string\|null` |
137138
| `tags_change` | `previous, value` (full snapshots) |
138139
| `metadata_change` | `previous, value` containing only changed `displayName` and tag keys; absent tag values are `null` |

src/cli.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import * as path from "node:path";
44
import * as readline from "node:readline/promises";
55
import { spawnSync, execFileSync } from "node:child_process";
66
import { randomBytes } from "node:crypto";
7-
import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult } from "./client.ts";
7+
import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult,
8+
isSessionAlive,
9+
} from "./client.ts";
810
import { printVersion } from "./version.ts";
911
import { parseSeqValue } from "./keys.ts";
1012
import {
@@ -42,9 +44,16 @@ import {
4244
getPidPath,
4345
getMetadataPath,
4446
DEFAULT_SESSION_DIR,
47+
hasProcessExitedForReap,
4548
type SessionInfo,
4649
type SessionMetadata,
4750
} from "./sessions.ts";
51+
import { snapshotDescendantProcesses } from "./process-tree.ts";
52+
import { aftermathOf, allGone, killOutcomeLines, verifiedEmpty } from "./kill-report.ts";
53+
import {
54+
groupsInTree, membersOfGroups, ownProcessGroup, signalGroup, sweepGroups,
55+
} from "./process-groups.ts";
56+
import { openSource, valueOf } from "./proc-table.ts";
4857
import { spawnDaemon, resolveCommand } from "./spawn.ts";
4958
import {
5059
acquireEventLock, appendEventSyncLocked, EventFollower, EventWriter, EventType, releaseEventLock,
@@ -295,6 +304,14 @@ Examples:
295304
Terminate a running session's daemon and exact descendant tree. Metadata is kept —
296305
restart or \`pty rm\` it later.
297306
307+
The daemon tears the tree down on its way out, which races its own exit. So once
308+
the daemon has gone, \`pty kill\` re-reads the process table, signals any process
309+
group the session left behind, and reports what is still there. It exits non-zero
310+
unless it verified the tree is empty.
311+
312+
A descendant that calls setsid leaves the session and the group, and neither the
313+
tree walk nor the group sweep can reach it.
314+
298315
Examples:
299316
pty kill myserver`,
300317

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

2645+
/** How long the escalation gives a group to answer SIGTERM before it stops
2646+
* asking. A coding agent was measured ignoring SIGTERM for ten seconds, so
2647+
* this grace is a courtesy, not a plan. */
2648+
const ESCALATE_TERM_WAIT_MS = 2_000;
2649+
/** How long to wait after SIGKILL before reporting what is still there. */
2650+
const ESCALATE_KILL_WAIT_MS = 1_000;
2651+
2652+
/** TERM the groups, wait, KILL what is left, wait, then re-read the process
2653+
* table. Returns the pids still alive in those groups. */
2654+
async function escalateOverGroups(groups: number[]): Promise<number[]> {
2655+
return sweepGroups(groups, ownProcessGroup(), ESCALATE_TERM_WAIT_MS, ESCALATE_KILL_WAIT_MS, {
2656+
live: (targets) => membersOfGroups(targets, openSource()),
2657+
signal: signalGroup,
2658+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
2659+
});
2660+
}
2661+
26182662
async function cmdKill(name: string): Promise<void> {
26192663
const session = await getSession(name);
26202664

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

2684+
// Take the tree BEFORE the signal. After the daemon exits its children are
2685+
// reparented to init or a subreaper, so the parent links that identify them
2686+
// as this session's processes are gone. This snapshot is the only chance to
2687+
// learn which processes the word "killed" would be a claim about.
2688+
const before = snapshotDescendantProcesses(session.pid);
2689+
// Groups come from the raw listing, NOT from `before`. The snapshot drops a
2690+
// descendant whose start token cannot be read, and that process is then never
2691+
// signalled. A group needs no identity, so this reaches it anyway.
2692+
const groups = groupsInTree(session.pid, openSource());
2693+
26402694
try {
26412695
process.kill(session.pid, "SIGTERM");
26422696
} catch {
@@ -2662,7 +2716,24 @@ async function cmdKill(name: string): Promise<void> {
26622716
return;
26632717
}
26642718
cleanupSocket(name);
2665-
console.log(`Session "${name}" killed.`);
2719+
let after = aftermathOf(before, readProcessStartToken, hasProcessExitedForReap);
2720+
let escalated: number[] | undefined;
2721+
if (!allGone(after)) {
2722+
escalated = await escalateOverGroups(groups);
2723+
// Re-measure. The report must describe the machine now, not the signals
2724+
// that were sent at it.
2725+
const again = openSource();
2726+
after = aftermathOf(
2727+
before,
2728+
(pid) => valueOf(again.identity(pid)),
2729+
hasProcessExitedForReap,
2730+
);
2731+
}
2732+
const outcome = killOutcomeLines(name, after, escalated);
2733+
for (const line of outcome.out) console.log(line);
2734+
for (const line of outcome.err) console.error(line);
2735+
// Anything left is a failure, and the status says so.
2736+
if (!verifiedEmpty(after, escalated)) process.exitCode = 1;
26662737

26672738
if (wasPermanent && session.metadata?.tags?.ptyfile) {
26682739
console.error(`Note: this session is managed by ${session.metadata.tags.ptyfile}`);

src/client.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ import { getSocketPath } from "./sessions.ts";
1717
import { stripAnsi } from "./tui/colors.ts";
1818
import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts";
1919

20+
/** Can anything answer on this session's socket?
21+
*
22+
* The Rust tool's `client::is_alive`, which is `connect(name).is_ok()`. A name
23+
* that merely EXISTS on disk is not a name that is in use: a record whose
24+
* owner is a zombie has no socket to answer, and refusing it would make that
25+
* session name unusable until something reaped the corpse.
26+
*/
27+
export function isSessionAlive(name: string, timeoutMs = 250): Promise<boolean> {
28+
return new Promise((resolve) => {
29+
let settled = false;
30+
const done = (alive: boolean) => {
31+
if (settled) return;
32+
settled = true;
33+
try { socket.destroy(); } catch {}
34+
resolve(alive);
35+
};
36+
const socket = net.createConnection(getSocketPath(name));
37+
socket.setTimeout(timeoutMs, () => done(false));
38+
socket.once("connect", () => done(true));
39+
socket.once("error", () => done(false));
40+
});
41+
}
42+
2043
const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding)
2144
const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol)
2245

src/events.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const EventType = {
1919
SESSION_RESPAWN: "session_respawn",
2020
SESSION_ABANDONED: "session_abandoned",
2121
SESSION_FLAPPING: "session_flapping",
22+
SESSION_DESCENDANTS_SURVIVED: "session_descendants_survived",
2223
} as const;
2324

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

89+
/** Emitted by a daemon that signalled its child's process tree with TERM and
90+
* then KILL and found processes still alive. The daemon also warns on its own
91+
* standard error, which has had no reader since the command that launched it
92+
* stopped listening — so this log line is the copy a person can find.
93+
*
94+
* A record, not a guarantee: the daemon reports what it could not kill, and
95+
* cannot report a process that left its tree before the snapshot. */
96+
export interface SessionDescendantsSurvivedEvent extends EventBase {
97+
type: "session_descendants_survived";
98+
/** The surviving pids, deepest descendant first. Nested under `data` to
99+
* match the Rust tool byte for byte; both render through the unknown-type
100+
* fallback, so the printed line is identical. */
101+
data: { pids: number[] };
102+
}
103+
88104
/** Emitted by `pty gc` whenever it respawns a `strategy=permanent`
89105
* session that's exited/vanished. Carries no payload beyond the
90106
* envelope — the restart is stateless, there is no attempt counter,
@@ -185,6 +201,7 @@ export type EventRecord =
185201
| SessionRespawnEvent
186202
| SessionAbandonedEvent
187203
| SessionFlappingEvent
204+
| SessionDescendantsSurvivedEvent
188205
| UserEvent
189206
| DisplayNameChangeEvent
190207
| TagsChangeEvent

0 commit comments

Comments
 (0)