@crouter/api 0.3.387 → 0.3.389

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/api/dto/config.d.ts +11 -1
  5. package/dist/core/asset-root.d.ts +7 -0
  6. package/dist/core/asset-root.js +18 -0
  7. package/dist/core/canvas/boot-id.d.ts +6 -0
  8. package/dist/core/canvas/boot-id.js +26 -0
  9. package/dist/core/canvas/paths.d.ts +72 -0
  10. package/dist/core/canvas/paths.js +163 -0
  11. package/dist/core/canvas/pid.d.ts +391 -0
  12. package/dist/core/canvas/pid.js +948 -0
  13. package/dist/core/command-plugins/bundle.d.ts +149 -0
  14. package/dist/core/command-plugins/bundle.js +588 -0
  15. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  16. package/dist/core/command-plugins/endpoint.js +51 -0
  17. package/dist/core/config.d.ts +233 -0
  18. package/dist/core/config.js +1120 -0
  19. package/dist/core/env-name.d.ts +6 -0
  20. package/dist/core/env-name.js +9 -0
  21. package/dist/core/errors.d.ts +38 -0
  22. package/dist/core/errors.js +90 -0
  23. package/dist/core/events/emit.d.ts +6 -0
  24. package/dist/core/events/emit.js +42 -0
  25. package/dist/core/events/envelope.d.ts +2 -0
  26. package/dist/core/events/envelope.js +84 -0
  27. package/dist/core/events/errors.d.ts +4 -0
  28. package/dist/core/events/errors.js +69 -0
  29. package/dist/core/events/operation-id.d.ts +4 -0
  30. package/dist/core/events/operation-id.js +24 -0
  31. package/dist/core/events/serialize.d.ts +4 -0
  32. package/dist/core/events/serialize.js +199 -0
  33. package/dist/core/events/source.d.ts +16 -0
  34. package/dist/core/events/source.js +31 -0
  35. package/dist/core/events/types.d.ts +68 -0
  36. package/dist/core/events/types.js +11 -0
  37. package/dist/core/exclusive-lock.d.ts +34 -0
  38. package/dist/core/exclusive-lock.js +197 -0
  39. package/dist/core/fs-utils.d.ts +44 -0
  40. package/dist/core/fs-utils.js +208 -0
  41. package/dist/core/help.d.ts +309 -0
  42. package/dist/core/help.js +406 -0
  43. package/dist/core/human/page-catalog.d.ts +57 -0
  44. package/dist/core/human/page-catalog.js +172 -0
  45. package/dist/core/installed-plugins.d.ts +2 -0
  46. package/dist/core/installed-plugins.js +79 -0
  47. package/dist/core/io.d.ts +122 -0
  48. package/dist/core/io.js +373 -0
  49. package/dist/core/keybindings/attach-control.d.ts +49 -0
  50. package/dist/core/keybindings/attach-control.js +42 -0
  51. package/dist/core/keybindings/catalog.d.ts +18 -0
  52. package/dist/core/keybindings/catalog.js +257 -0
  53. package/dist/core/keybindings/types.d.ts +42 -0
  54. package/dist/core/keybindings/types.js +1 -0
  55. package/dist/core/layout.d.ts +26 -0
  56. package/dist/core/layout.js +94 -0
  57. package/dist/core/locked-file.d.ts +27 -0
  58. package/dist/core/locked-file.js +118 -0
  59. package/dist/core/log.d.ts +9 -0
  60. package/dist/core/log.js +89 -0
  61. package/dist/core/manifest.d.ts +5 -0
  62. package/dist/core/manifest.js +15 -0
  63. package/dist/core/plugin-env.d.ts +8 -0
  64. package/dist/core/plugin-env.js +31 -0
  65. package/dist/core/plugin-extensions.d.ts +29 -0
  66. package/dist/core/plugin-extensions.js +191 -0
  67. package/dist/core/plugin-swap-lock.d.ts +9 -0
  68. package/dist/core/plugin-swap-lock.js +31 -0
  69. package/dist/core/preview-result-path.d.ts +4 -0
  70. package/dist/core/preview-result-path.js +26 -0
  71. package/dist/core/profiles/env-store.d.ts +22 -0
  72. package/dist/core/profiles/env-store.js +163 -0
  73. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  74. package/dist/core/profiles/fuzzy-match.js +92 -0
  75. package/dist/core/profiles/manifest.d.ts +120 -0
  76. package/dist/core/profiles/manifest.js +529 -0
  77. package/dist/core/rate-limit-scope.d.ts +25 -0
  78. package/dist/core/rate-limit-scope.js +64 -0
  79. package/dist/core/render.d.ts +12 -0
  80. package/dist/core/render.js +138 -0
  81. package/dist/core/resolver.d.ts +14 -0
  82. package/dist/core/resolver.js +111 -0
  83. package/dist/core/runtime/branded-host.d.ts +25 -0
  84. package/dist/core/runtime/branded-host.js +264 -0
  85. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  86. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  87. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  88. package/dist/core/runtime/broker/signal-stream.js +149 -0
  89. package/dist/core/scope.d.ts +32 -0
  90. package/dist/core/scope.js +184 -0
  91. package/dist/core/scoped-state/db.d.ts +17 -0
  92. package/dist/core/scoped-state/db.js +247 -0
  93. package/dist/core/scoped-state/migrate.d.ts +8 -0
  94. package/dist/core/scoped-state/migrate.js +187 -0
  95. package/dist/core/scoped-state/paths.d.ts +9 -0
  96. package/dist/core/scoped-state/paths.js +27 -0
  97. package/dist/core/scoped-state/profiles.d.ts +27 -0
  98. package/dist/core/scoped-state/profiles.js +93 -0
  99. package/dist/core/scoped-state/providers.d.ts +24 -0
  100. package/dist/core/scoped-state/providers.js +19 -0
  101. package/dist/core/scoped-state/schema.d.ts +6 -0
  102. package/dist/core/scoped-state/schema.js +43 -0
  103. package/dist/core/scoped-state/settings.d.ts +28 -0
  104. package/dist/core/scoped-state/settings.js +83 -0
  105. package/dist/core/spaces/open-beneath.d.ts +71 -0
  106. package/dist/core/spaces/open-beneath.js +581 -0
  107. package/dist/core/sqlite-statements.d.ts +4 -0
  108. package/dist/core/sqlite-statements.js +17 -0
  109. package/dist/core/subscription-state.d.ts +121 -0
  110. package/dist/core/subscription-state.js +287 -0
  111. package/dist/core/user-settings.d.ts +377 -0
  112. package/dist/core/user-settings.js +458 -0
  113. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  114. package/dist/daemon/broker-signals/bus.js +87 -0
  115. package/dist/daemon/manage.d.ts +176 -0
  116. package/dist/daemon/manage.js +664 -0
  117. package/dist/daemon/pidfile.d.ts +8 -0
  118. package/dist/daemon/pidfile.js +37 -0
  119. package/dist/daemon/startup-policy.d.ts +1 -0
  120. package/dist/daemon/startup-policy.js +1 -0
  121. package/dist/native/linux.d.ts +29 -0
  122. package/dist/native/linux.js +20 -0
  123. package/dist/shared/env.d.ts +116 -0
  124. package/dist/shared/env.js +271 -0
  125. package/dist/shared/inbox-entry-body.d.ts +22 -0
  126. package/dist/shared/inbox-entry-body.js +116 -0
  127. package/dist/shared/working-activity.d.ts +9 -0
  128. package/dist/shared/working-activity.js +27 -0
  129. package/dist/types.d.ts +562 -0
  130. package/dist/types.js +186 -0
  131. package/package.json +1 -1
@@ -0,0 +1,948 @@
1
+ // src/core/canvas/pid.ts
2
+ //
3
+ // The ONE shared signal-0 liveness probe. It lives at the canvas/ layer — the
4
+ // LOWEST shared layer — so canvas/, runtime/, AND daemon/ can all import it
5
+ // "down" without a reverse-layer violation (runtime/ sits above canvas/, daemon/
6
+ // above runtime/). It is a pure process-existence utility, NOT data-model access,
7
+ // so it is exempt from the "only canvas.ts touches the db" rule. canvas.ts,
8
+ // revive.ts, placement.ts, and crtrd.ts all probe liveness through it.
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { readKernelBootId } from './boot-id.js';
13
+ /** Classify the current `ps` row for a pid. BSD ps and procps-ng both report a
14
+ * leading `Z` for zombies and exit silently with empty output when no process
15
+ * matches. Only a probe that could not run is unknown; that is the sole
16
+ * fail-open result. */
17
+ function psProcessState(pid) {
18
+ if (procfsUsable())
19
+ return procProcessState(pid);
20
+ try {
21
+ const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
22
+ if (r.error != null || r.signal != null || typeof r.stdout !== 'string')
23
+ return 'unknown';
24
+ if (typeof r.stderr === 'string' && r.stderr.trim() !== '')
25
+ return 'unknown';
26
+ const stat = r.stdout.trim();
27
+ if (stat === '')
28
+ return 'gone';
29
+ return stat.charAt(0) === 'Z' ? 'zombie' : 'live';
30
+ }
31
+ catch {
32
+ return 'unknown';
33
+ }
34
+ }
35
+ /** True if a process with `pid` is currently alive. `kill(pid, 0)` provides the
36
+ * initial existence check; EPERM means the foreign process exists. For our
37
+ * processes, the later `ps` read is authoritative because it also distinguishes
38
+ * zombies and processes reaped between the two probes. Only an unavailable
39
+ * `ps` probe fails open to alive. A null/undefined pid reads dead. */
40
+ export function isPidAlive(pid) {
41
+ if (pid == null)
42
+ return false;
43
+ try {
44
+ process.kill(pid, 0);
45
+ }
46
+ catch (e) {
47
+ return e.code === 'EPERM';
48
+ }
49
+ const state = psProcessState(pid);
50
+ return state !== 'zombie' && state !== 'gone';
51
+ }
52
+ /** Existence-only liveness for READ-ONLY display projections: the signal-0
53
+ * probe alone, no `ps`. A zombie reads alive here, which a dashboard column
54
+ * tolerates and which no lifecycle decision may rely on — revive, teardown,
55
+ * and every other authority stays on `isPidAlive`. Exists because the
56
+ * dashboard snapshot runs this per live row per request; a `ps` spawn each
57
+ * time was a third of daemon CPU on a large canvas. */
58
+ export function isPidPresent(pid) {
59
+ if (pid == null)
60
+ return false;
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ }
65
+ catch (e) {
66
+ return e.code === 'EPERM';
67
+ }
68
+ }
69
+ /** Matches the kernel `boot_id` UUID shape (`/proc/sys/kernel/random/boot_id`),
70
+ * used to tell a NEW-format identity base (a per-boot UUID) apart from a
71
+ * LEGACY one (a `ps lstart` wall-clock timestamp). A boot_id is a lowercase
72
+ * hex UUID; an `lstart` string is a human-readable date — the two never
73
+ * collide, so this cleanly classifies which comparison rule an identity's
74
+ * base obeys (see `identitiesMatch`). */
75
+ const BOOT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
76
+ /** Split a `composeIdentity` fingerprint (`<base>` or `<base>#<ticks>`, where
77
+ * `base` is a per-boot `boot_id` UUID on Linux-with-procfs or the portable
78
+ * `ps lstart` timestamp otherwise) into its coarse base and fine `ticks`
79
+ * (Linux-only jiffies) parts. `#` never appears in a `boot_id` or an `lstart`
80
+ * timestamp, so the first `#` unambiguously separates them. Only a NON-EMPTY,
81
+ * purely-numeric suffix counts as ticks (the shape `procStartTicks` and
82
+ * `composeIdentity` guarantee); a malformed or empty suffix (`A#`, `A#bad`,
83
+ * `A#1#2`) is treated as ABSENT ticks, so the base compare then applies — a
84
+ * garbage suffix must never be trusted as a fine discriminator. */
85
+ function splitIdentity(identity) {
86
+ const i = identity.indexOf('#');
87
+ if (i === -1)
88
+ return { base: identity, ticks: undefined };
89
+ const suffix = identity.slice(i + 1);
90
+ return { base: identity.slice(0, i), ticks: /^\d+$/.test(suffix) ? suffix : undefined };
91
+ }
92
+ /** THE single precision-aware identity compare, used EVERYWHERE two
93
+ * `composeIdentity` fingerprints are compared (this module's own
94
+ * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
95
+ * `killProcessTreePids`'s reuse check) — centralized so no call site can
96
+ * drift from this rule.
97
+ *
98
+ * Two hazards this closes, both in the false-DEAD direction (the dangerous
99
+ * one — a false DEAD lets `reviveNode`'s double-launch guard relaunch a
100
+ * SECOND broker onto a still-live session):
101
+ *
102
+ * 1. `composeIdentity` appends the `#<ticks>` Linux discriminator only when
103
+ * `/proc/<pid>/stat` happens to be readable at capture time — so the SAME
104
+ * live process can be recorded plain `lstart` on one capture and
105
+ * `lstart#ticks` on another (a transient `/proc` read failure, or
106
+ * capturing on a platform/container without procfs). A naive `===` reads
107
+ * that as a reuse (different string) and reports `dead` for a broker that
108
+ * never died.
109
+ * 2. `lstart` is NOT stable for a live process on every host. It is derived
110
+ * from `/proc/stat` btime (wall-clock boot epoch) + starttime jiffies, so
111
+ * a clock correction / suspend-resume shifts btime and re-anchors the
112
+ * SAME live process's `lstart` by seconds between two captures. Observed
113
+ * on Blaxel unikraft guests: pid+ticks identical across probes, `lstart`
114
+ * drifting +81s — a base-first compare then reads the live home-node
115
+ * broker as reused/dead every cycle, driving an uncapped revive loop.
116
+ *
117
+ * A third hazard the base itself once carried: `ticks` (jiffies-since-boot)
118
+ * RESET on every boot and can repeat across boots, so ticks-alone is only
119
+ * safe WITHIN one boot. `composeIdentity` therefore boot-SCOPES the base on
120
+ * Linux-with-procfs — the base IS the kernel `boot_id` UUID — so a
121
+ * new-format identity is `<bootId>#<ticks>` and equality means same boot AND
122
+ * same process. This closes the cross-boot false match at the root, without
123
+ * plumbing boot reconciliation ahead of every caller.
124
+ *
125
+ * Rule: split both on `#`.
126
+ * - BOTH sides carry `#ticks` AND both bases are `boot_id` UUIDs
127
+ * (new-format vs new-format): match iff base AND ticks are equal. A
128
+ * different boot_id with equal ticks is NOT a match (the cross-boot
129
+ * collision).
130
+ * - BOTH sides carry `#ticks` but a base is NOT a boot_id UUID on EITHER
131
+ * side — a LEGACY `lstart#ticks` row recorded before this repo boot-scoped
132
+ * the base, or a Linux host that has procfs ticks but no readable boot_id:
133
+ * ticks DECIDE it alone (the prior landed semantics). This is a NARROW
134
+ * format-migration path so an in-place upgrade never false-kills a live
135
+ * broker whose row still holds `lstart#ticks`; it is NOT a general
136
+ * fallback — new-format vs new-format always demands base+ticks above.
137
+ * - EITHER side lacks a valid `#ticks` suffix (macOS / no procfs / transient
138
+ * `/proc` miss / malformed suffix): fall back to the coarse base —
139
+ * matching iff the bases agree (fail-open to same-process, matching this
140
+ * module's universal discipline: only a POSITIVE mismatch counts as reuse,
141
+ * never an absence of evidence). */
142
+ export function identitiesMatch(a, b) {
143
+ const ia = splitIdentity(a);
144
+ const ib = splitIdentity(b);
145
+ if (ia.ticks !== undefined && ib.ticks !== undefined) {
146
+ if (BOOT_ID_RE.test(ia.base) && BOOT_ID_RE.test(ib.base)) {
147
+ return ia.base === ib.base && ia.ticks === ib.ticks;
148
+ }
149
+ return ia.ticks === ib.ticks; // legacy lstart#ticks (either side) → ticks-alone migration
150
+ }
151
+ return ia.base === ib.base;
152
+ }
153
+ export function recordedPidLiveness(pid, expectedIdentity, snapshot) {
154
+ const debug = Boolean(process.env.CRTR_DEBUG_PID_LIVENESS);
155
+ if (pid == null)
156
+ return 'dead';
157
+ if (snapshot === null) {
158
+ if (debug)
159
+ logPidLivenessDecision(pid, false, expectedIdentity, 'probe-null', 'ps snapshot failed → indeterminate', 'indeterminate');
160
+ return 'indeterminate';
161
+ }
162
+ const snapshotProcess = snapshot?.processes.get(pid);
163
+ const alive = snapshot === undefined ? isPidAlive(pid) : snapshotProcess !== undefined && !snapshotProcess.zombie;
164
+ if (!alive) {
165
+ if (debug)
166
+ logPidLivenessDecision(pid, alive, expectedIdentity, snapshot === undefined ? 'no-probe' : 'no-entry', 'process absent/zombie → dead', 'dead');
167
+ return 'dead';
168
+ }
169
+ if (expectedIdentity == null) {
170
+ if (debug)
171
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust process presence', 'alive');
172
+ return 'alive';
173
+ }
174
+ let actual;
175
+ if (snapshot === undefined) {
176
+ const current = capturePidIdentities([pid]);
177
+ if (current == null) {
178
+ if (debug)
179
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed → indeterminate', 'indeterminate');
180
+ return 'indeterminate';
181
+ }
182
+ actual = current.get(pid);
183
+ if (actual === undefined) {
184
+ if (debug)
185
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) → dead', 'dead');
186
+ return 'dead';
187
+ }
188
+ }
189
+ else {
190
+ actual = snapshotProcess.identity;
191
+ }
192
+ const matches = identitiesMatch(actual, expectedIdentity);
193
+ if (debug)
194
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'value', matches ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', matches ? 'alive' : 'dead', actual);
195
+ return matches ? 'alive' : 'dead';
196
+ }
197
+ /** Diagnostic-only decision trace for `recordedPidLiveness`, gated entirely
198
+ * behind `CRTR_DEBUG_PID_LIVENESS` — callers must check the env var
199
+ * themselves before calling this so the (tiny) formatting cost is paid ONLY
200
+ * when the env var is set; this function does no env check of its own.
201
+ * Written to trace subtle PID-reuse decisions at the remaining signal and
202
+ * teardown authorization sites: the goal is for a single stderr line per call
203
+ * to make both an identity match and a reused/indeterminate identity fully
204
+ * self-explaining, without changing any return value. `capturePidIdentities`
205
+ * outcome is reported as one of three DISTINCT cases — `probe-null` (the
206
+ * `ps` probe itself failed), `no-entry` (probe succeeded but the map has no
207
+ * row for this pid), or `value` (probe succeeded and found an identity,
208
+ * printed verbatim) — plus `no-probe` for the two branches that return
209
+ * before `capturePidIdentities` is ever called. Kept as permanent
210
+ * observability for this primitive, not a throwaway debug print: pid-reuse
211
+ * liveness is subtle enough that the next person chasing a wedge here should
212
+ * find this already in place. */
213
+ function logPidLivenessDecision(pid, isPidAliveResult, expectedIdentity, probeOutcome, branch, returned, actualIdentity) {
214
+ const parts = [
215
+ `pid=${String(pid)}`,
216
+ `isPidAlive=${isPidAliveResult}`,
217
+ `expectedIdentity=${expectedIdentity === undefined ? 'undefined' : expectedIdentity === null ? 'null' : JSON.stringify(expectedIdentity)}`,
218
+ `capturePidIdentities=${probeOutcome}${probeOutcome === 'value' ? `(${JSON.stringify(actualIdentity)})` : ''}`,
219
+ `branch=${JSON.stringify(branch)}`,
220
+ `returned=${returned}`,
221
+ ];
222
+ process.stderr.write(`[recordedPidLiveness] ${parts.join(' ')}\n`);
223
+ }
224
+ /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
225
+ * swallowing ESRCH (already gone). Used by cron cancellation, timeout, and
226
+ * stale-lease recovery. Lives beside isPidAlive: canvas/ is
227
+ * the lowest shared layer every process-liveness/-teardown primitive sits at.
228
+ * Guards `pid` before signaling: never hit pid 0
229
+ * (which means "my own group") or a negative/non-integer value. */
230
+ export function killProcessGroup(pid, signal = 'SIGTERM') {
231
+ if (!Number.isInteger(pid) || pid <= 0)
232
+ return;
233
+ try {
234
+ process.kill(-pid, signal);
235
+ }
236
+ catch {
237
+ /* already gone */
238
+ }
239
+ }
240
+ /** Linux-only higher-resolution discriminator layered onto `lstart`:
241
+ * `ps lstart` is only second-granular, so a PID reused
242
+ * within the same wall-clock second compares equal and slips past the reuse
243
+ * guard. `/proc/<pid>/stat`'s `starttime` field is the kernel's own jiffies-
244
+ * since-boot counter for that process — effectively unique per pid even for
245
+ * same-second reuse. `comm` (field 2) is parenthesized and may itself contain
246
+ * spaces/parens, so this splits on the LAST `)` rather than counting fields
247
+ * from the front. Returns `null` (never throws) on any read/parse failure —
248
+ * no `/proc` (macOS, containers without procfs), a raced-away pid, or a
249
+ * malformed line — so this is purely additive precision, never a hard
250
+ * dependency: `composeIdentity` falls back to plain `lstart` in every such
251
+ * case, exactly matching this module's pre-existing cross-platform coarse
252
+ * behavior. */
253
+ function procStartTicks(pid) {
254
+ try {
255
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
256
+ const closeParen = raw.lastIndexOf(')');
257
+ if (closeParen === -1)
258
+ return null;
259
+ // Fields after `(comm)`, 0-indexed from `state` (proc(5) field 3): state(0)
260
+ // ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) flags(6) minflt(7)
261
+ // cminflt(8) majflt(9) cmajflt(10) utime(11) stime(12) cutime(13)
262
+ // cstime(14) priority(15) nice(16) num_threads(17) itrealvalue(18)
263
+ // starttime(19) — proc(5) field 22.
264
+ const rest = raw.slice(closeParen + 2).trim().split(/\s+/);
265
+ const starttime = rest[19];
266
+ return starttime !== undefined && /^\d+$/.test(starttime) ? starttime : null;
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ /** How long a FAILED (`null`) boot_id read is remembered as negative before the
273
+ * next call is allowed to re-read. Bounds the cost on a genuinely no-boot_id
274
+ * host (macOS / no procfs) to one throwing `/proc` read per window rather than
275
+ * one per identity composition — identity is composed per supervised row per
276
+ * daemon tick and per process in a full ps-table capture, so an uncapped
277
+ * retry-on-null is a synchronous read storm there. Kept short so a transient
278
+ * miss on a real boot_id host still recovers promptly. */
279
+ const BOOT_ID_NEGATIVE_TTL_MS = 60_000;
280
+ /** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the
281
+ * whole lifetime of this process — it only changes on a kernel boot, which
282
+ * necessarily kills this process — so once a real value is read it is cached
283
+ * for good, rather than re-read for every row of a whole-process-table `ps`
284
+ * scan. Only SUCCESSFUL reads are cached forever: a `null` read is negative
285
+ * for `BOOT_ID_NEGATIVE_TTL_MS`, after which the next call retries — so a
286
+ * transient `/proc` miss on a boot_id-capable host still recovers (never
287
+ * permanently downgraded to legacy `lstart#ticks`), while a genuinely
288
+ * no-boot_id host is not hammered with a synchronous throwing read on every
289
+ * identity composition. `now` is injectable for tests. */
290
+ export function makeBootIdCache(read, now = Date.now, negativeTtlMs = BOOT_ID_NEGATIVE_TTL_MS) {
291
+ let cached = null;
292
+ let retryAfter = 0;
293
+ return () => {
294
+ if (cached !== null)
295
+ return cached;
296
+ const t = now();
297
+ if (t < retryAfter)
298
+ return null; // within the negative-cache window — don't re-read
299
+ const v = read();
300
+ if (v !== null)
301
+ return (cached = v);
302
+ retryAfter = t + negativeTtlMs;
303
+ return null;
304
+ };
305
+ }
306
+ const bootIdBase = makeBootIdCache(readKernelBootId);
307
+ /** Is `identity` a LEGACY (pre-boot-scoped) baseline — a `<lstart>#<ticks>` row
308
+ * whose base is NOT a boot_id UUID but which DOES carry ticks? These are the
309
+ * rows the one-time startup migration re-records once this platform can
310
+ * compose boot-scoped identities (see `migrateLegacyPidIdentities`). */
311
+ export function isLegacyTicksIdentity(identity) {
312
+ const { base, ticks } = splitIdentity(identity);
313
+ return ticks !== undefined && !BOOT_ID_RE.test(base);
314
+ }
315
+ /** Is `identity` a NEW-format boot-scoped baseline — `<bootId>#<ticks>` with a
316
+ * real boot_id UUID base AND ticks? The startup migration only re-records a
317
+ * legacy row when the pid's CURRENT identity comes out in this shape (i.e. the
318
+ * platform now composes new-format), never otherwise. */
319
+ export function isBootScopedIdentity(identity) {
320
+ const { base, ticks } = splitIdentity(identity);
321
+ return ticks !== undefined && BOOT_ID_RE.test(base);
322
+ }
323
+ /** Compose the stable, BOOT-SCOPED process-identity fingerprint used
324
+ * everywhere in this module. ALWAYS used to build an identity string, never a
325
+ * raw field directly, so every identity captured anywhere (launch-time
326
+ * `recordPid`, the teardown snapshot, and the escalation-window re-check) is
327
+ * comparable apples-to-apples.
328
+ *
329
+ * Platform capability, not layered fallbacks — the base is the finest STABLE
330
+ * per-boot anchor available:
331
+ * - Linux-with-procfs (the production guest shape): `<bootId>#<ticks>` — the
332
+ * per-boot `boot_id` UUID plus the per-process jiffies-since-boot. Ticks
333
+ * reset per boot and can repeat, so scoping the base to the boot is what
334
+ * makes an equal-ticks compare safe ACROSS boots (see `identitiesMatch`).
335
+ * - Linux-with-procfs but no readable `boot_id` (if that combination
336
+ * exists): `<lstart>#<ticks>` — the prior landed behavior.
337
+ * - No procfs ticks (macOS / containers without procfs): `<lstart>` alone —
338
+ * unchanged coarse behavior. The base is only ever a `boot_id` when a
339
+ * `ticks` discriminator is ALSO present; a bare `boot_id` (shared by every
340
+ * process on the boot) would be a useless per-process identity. */
341
+ export function composeIdentity(pid, lstart, deps = {}) {
342
+ // `deps` is a TEST-ONLY seam: it lets a unit test drive the composition with
343
+ // an injected ticks/boot_id pair (so `<bootId>#<ticks>` composition is
344
+ // provable off-Linux, where real `/proc` reads return null), without adding
345
+ // any runtime branch — production callers pass no `deps` and read the real
346
+ // `/proc` values exactly as before.
347
+ const ticks = deps.ticks !== undefined ? deps.ticks : procStartTicks(pid);
348
+ if (ticks === null)
349
+ return lstart;
350
+ const bootId = deps.bootId !== undefined ? deps.bootId : bootIdBase();
351
+ return `${bootId ?? lstart}#${ticks}`;
352
+ }
353
+ /** The `ps` invocation both snapshot captures share, so the synchronous and
354
+ * asynchronous forms can never sample different columns. */
355
+ const PS_SNAPSHOT_ARGS = ['-Ao', 'pid=,ppid=,stat=,lstart=,command='];
356
+ const PS_SNAPSHOT_TIMEOUT_MS = 2000;
357
+ export function captureLivenessSnapshot() {
358
+ if (procfsUsable())
359
+ return readProcfsSnapshot();
360
+ let stdout;
361
+ try {
362
+ const r = spawnSync('ps', [...PS_SNAPSHOT_ARGS], {
363
+ encoding: 'utf8',
364
+ timeout: PS_SNAPSHOT_TIMEOUT_MS,
365
+ maxBuffer: 8 * 1024 * 1024,
366
+ });
367
+ if (r.status !== 0 || typeof r.stdout !== 'string')
368
+ return null;
369
+ stdout = r.stdout;
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ return parseLivenessTable(stdout);
375
+ }
376
+ /** The same sample without blocking the event loop, for callers running inside
377
+ * crtrd's detached work: one `ps` read classifies every pid they care about,
378
+ * where a per-pid `spawnSync` probe would stall broker supervision and the API
379
+ * socket for the duration of each probe. `null` means the probe failed, which
380
+ * every caller must preserve as unknown rather than absence. */
381
+ export async function captureLivenessSnapshotAsync() {
382
+ if (procfsUsable())
383
+ return readProcfsSnapshotAsync();
384
+ const stdout = await new Promise((resolve) => {
385
+ let child;
386
+ try {
387
+ child = spawn('ps', [...PS_SNAPSHOT_ARGS]);
388
+ }
389
+ catch {
390
+ return resolve(null);
391
+ }
392
+ let out = '';
393
+ let settled = false;
394
+ const settle = (value) => {
395
+ if (settled)
396
+ return;
397
+ settled = true;
398
+ clearTimeout(timer);
399
+ resolve(value);
400
+ };
401
+ const timer = setTimeout(() => {
402
+ try {
403
+ child.kill('SIGKILL');
404
+ }
405
+ catch { /* already gone */ }
406
+ settle(null);
407
+ }, PS_SNAPSHOT_TIMEOUT_MS);
408
+ timer.unref?.();
409
+ child.stdout.on('data', (d) => (out += d.toString()));
410
+ child.stderr.on('data', () => { });
411
+ child.on('error', () => settle(null));
412
+ child.on('close', (status) => settle(status === 0 ? out : null));
413
+ });
414
+ return stdout === null ? null : parseLivenessTable(stdout);
415
+ }
416
+ export function parseProcStat(raw) {
417
+ const open = raw.indexOf('(');
418
+ const close = raw.lastIndexOf(')');
419
+ if (open === -1 || close === -1 || close < open)
420
+ return null;
421
+ const rest = raw.slice(close + 2).trim().split(/\s+/);
422
+ // state(0) ppid(1) ... starttime(19) — proc(5) fields 3, 4 and 22.
423
+ const state = rest[0];
424
+ const ppid = Number(rest[1]);
425
+ const starttime = rest[19];
426
+ if (state === undefined || state === '' || !Number.isInteger(ppid) || ppid < 0)
427
+ return null;
428
+ if (starttime === undefined || !/^\d+$/.test(starttime))
429
+ return null;
430
+ return { comm: raw.slice(open + 1, close), state, ppid, starttime };
431
+ }
432
+ /** `/proc/<pid>/cmdline` (NUL-separated argv) as the single space-joined line
433
+ * `ps -o command=` prints; kernel threads and zombies, whose cmdline is
434
+ * empty, show as `[comm]` exactly as `ps` renders them. */
435
+ export function procCommandLine(rawCmdline, comm) {
436
+ const joined = rawCmdline.replace(/\0+$/, '').split('\0').join(' ').trim();
437
+ return joined === '' ? `[${comm}]` : joined;
438
+ }
439
+ let procfsAvailable;
440
+ /** True when this host serves identities from `/proc` (Linux, procfs mounted,
441
+ * boot_id readable). Decided once per process: it cannot change underneath a
442
+ * running daemon, and a per-probe re-check would defeat the point. */
443
+ function procfsUsable() {
444
+ if (procfsAvailable === undefined) {
445
+ procfsAvailable = process.platform === 'linux' && readKernelBootId() !== null && procStatOf(process.pid) !== null;
446
+ }
447
+ return procfsAvailable;
448
+ }
449
+ const LIVE_PROC = { root: '/proc', bootId: () => bootIdBase() };
450
+ /** One pid's parsed stat; `null` when it is absent OR unreadable (callers that
451
+ * must tell those apart use `readProcStat`). */
452
+ function procStatOf(pid, src = LIVE_PROC) {
453
+ const r = readProcStat(pid, src);
454
+ return r === 'unreadable' || r === 'denied' || r === 'gone' ? null : r;
455
+ }
456
+ /** `denied` is a permission refusal (EACCES/EPERM) — what a `hidepid=1` procfs
457
+ * answers for another user's pid. It is kept apart from `unreadable` (any other
458
+ * failure, or a stat that does not parse) because only a refusal on a process
459
+ * this user does not own is safe to leave out of a whole-table sample. */
460
+ function readProcStat(pid, src = LIVE_PROC) {
461
+ let raw;
462
+ try {
463
+ raw = readFileSync(`${src.root}/${pid}/stat`, 'utf8');
464
+ }
465
+ catch (e) {
466
+ return classifyProcReadError(e);
467
+ }
468
+ return parseProcStat(raw) ?? 'unreadable';
469
+ }
470
+ function classifyProcReadError(e) {
471
+ const code = e.code;
472
+ if (code === 'ENOENT' || code === 'ESRCH')
473
+ return 'gone';
474
+ return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unreadable';
475
+ }
476
+ /** Whether a process whose `stat` was refused is provably someone else's: its
477
+ * `/proc/<pid>` directory is owned by a different uid than ours. Anything we
478
+ * cannot establish (no uid, the directory unreadable for another reason) is
479
+ * treated as possibly ours — a refusal on a process that may matter must fail
480
+ * the sample, never read as absence. `gone` means it vanished meanwhile. */
481
+ function procOwnership(pid, src) {
482
+ const self = src.selfUid !== undefined ? src.selfUid() : (process.getuid?.() ?? null);
483
+ if (self === null)
484
+ return 'own-or-unknown';
485
+ try {
486
+ return statSync(`${src.root}/${pid}`).uid === self ? 'own-or-unknown' : 'foreign';
487
+ }
488
+ catch (e) {
489
+ return classifyProcReadError(e) === 'gone' ? 'gone' : 'own-or-unknown';
490
+ }
491
+ }
492
+ /** What a whole-table scan does with a pid whose stat could not be read:
493
+ * `skip` (vanished, or another user's process this user is not allowed to see
494
+ * — absent, exactly as `ps` omits it under `hidepid`) or `fail`. */
495
+ function scanDisposition(failure, pid, src) {
496
+ if (failure === 'gone')
497
+ return 'skip';
498
+ if (failure === 'unreadable')
499
+ return 'fail';
500
+ const owner = procOwnership(pid, src);
501
+ return owner === 'own-or-unknown' ? 'fail' : 'skip';
502
+ }
503
+ function procIdentity(stat, src = LIVE_PROC) {
504
+ const bootId = src.bootId();
505
+ return bootId === null ? null : `${bootId}#${stat.starttime}`;
506
+ }
507
+ function procProcessState(pid, src = LIVE_PROC) {
508
+ const stat = readProcStat(pid, src);
509
+ if (stat === 'gone')
510
+ return 'gone';
511
+ if (stat === 'denied' || stat === 'unreadable')
512
+ return 'unknown';
513
+ return stat.state === 'Z' || stat.state === 'X' ? 'zombie' : 'live';
514
+ }
515
+ function procIdentities(pids, src = LIVE_PROC) {
516
+ const out = new Map();
517
+ for (const pid of pids) {
518
+ const stat = readProcStat(pid, src);
519
+ if (stat === 'gone')
520
+ continue;
521
+ if (stat === 'unreadable' || stat === 'denied')
522
+ return null;
523
+ const identity = procIdentity(stat, src);
524
+ if (identity === null)
525
+ return null;
526
+ out.set(pid, identity);
527
+ }
528
+ return out;
529
+ }
530
+ function procCommand(pid, src = LIVE_PROC) {
531
+ const stat = readProcStat(pid, src);
532
+ if (stat === 'gone' || stat === 'unreadable' || stat === 'denied')
533
+ return null;
534
+ try {
535
+ const command = procCommandLine(readFileSync(`${src.root}/${pid}/cmdline`, 'utf8'), stat.comm);
536
+ return command === '' ? null : command;
537
+ }
538
+ catch {
539
+ return null;
540
+ }
541
+ }
542
+ function addProcfsRow(src, rows, pid, stat, rawCmdline) {
543
+ const identity = procIdentity(stat, src);
544
+ if (identity === null)
545
+ return false;
546
+ rows.processes.set(pid, { zombie: stat.state === 'Z' || stat.state === 'X', identity, command: procCommandLine(rawCmdline, stat.comm) });
547
+ const siblings = rows.childrenOf.get(stat.ppid);
548
+ if (siblings === undefined)
549
+ rows.childrenOf.set(stat.ppid, [pid]);
550
+ else
551
+ siblings.push(pid);
552
+ return true;
553
+ }
554
+ function procPids(root) {
555
+ try {
556
+ return readdirSync(root).filter((name) => /^\d+$/.test(name)).map(Number).filter((pid) => pid > 0);
557
+ }
558
+ catch {
559
+ return null;
560
+ }
561
+ }
562
+ /** One whole-table sample from procfs. Skipped as absent, as in a `ps` sample:
563
+ * a pid that vanishes between the listing and its read, and a pid whose stat
564
+ * is refused (EACCES/EPERM — `hidepid=1`) when its `/proc/<pid>` directory is
565
+ * owned by another uid (this user cannot see it, and `ps` would not list it).
566
+ * Every other read failure — including a refusal on a process we own or whose
567
+ * owner we cannot establish — fails the whole sample, which every caller
568
+ * treats as unknown. */
569
+ export function readProcfsSnapshot(src = LIVE_PROC) {
570
+ const pids = procPids(src.root);
571
+ if (pids === null)
572
+ return null;
573
+ const rows = { processes: new Map(), childrenOf: new Map() };
574
+ for (const pid of pids) {
575
+ const stat = readProcStat(pid, src);
576
+ if (stat === 'gone' || stat === 'denied' || stat === 'unreadable') {
577
+ if (scanDisposition(stat === 'gone' ? 'gone' : stat, pid, src) === 'skip')
578
+ continue;
579
+ return null;
580
+ }
581
+ let cmdline = '';
582
+ try {
583
+ cmdline = readFileSync(`${src.root}/${pid}/cmdline`, 'utf8');
584
+ }
585
+ catch (e) {
586
+ const code = e.code;
587
+ if (code === 'ENOENT' || code === 'ESRCH')
588
+ continue;
589
+ // An unreadable cmdline only costs the command text (shown as `[comm]`);
590
+ // the identity and parentage — what every safety decision uses — came
591
+ // from `stat`.
592
+ }
593
+ if (!addProcfsRow(src, rows, pid, stat, cmdline))
594
+ return null;
595
+ }
596
+ return rows;
597
+ }
598
+ /** The same sample without blocking the event loop (crtrd's detached work). */
599
+ export async function readProcfsSnapshotAsync(src = LIVE_PROC) {
600
+ const pids = procPids(src.root);
601
+ if (pids === null)
602
+ return null;
603
+ const rows = { processes: new Map(), childrenOf: new Map() };
604
+ const BATCH = 64;
605
+ for (let i = 0; i < pids.length; i += BATCH) {
606
+ const batch = pids.slice(i, i + BATCH);
607
+ const read = await Promise.all(batch.map(async (pid) => {
608
+ try {
609
+ const stat = parseProcStat(await readFile(`${src.root}/${pid}/stat`, 'utf8'));
610
+ if (stat === null)
611
+ return 'failed';
612
+ const cmdline = await readFile(`${src.root}/${pid}/cmdline`, 'utf8').catch((e) => {
613
+ if (e.code === 'ENOENT' || e.code === 'ESRCH')
614
+ throw e;
615
+ return ''; // unreadable cmdline costs only the command text, as in the sync sample
616
+ });
617
+ return { pid, stat, cmdline };
618
+ }
619
+ catch (e) {
620
+ // `stat` is read first, so an ENOENT/ESRCH can also come from the
621
+ // cmdline read of a pid that exited between the two — gone either way.
622
+ return scanDisposition(classifyProcReadError(e), pid, src) === 'skip' ? 'gone' : 'failed';
623
+ }
624
+ }));
625
+ for (const item of read) {
626
+ if (item === 'gone')
627
+ continue;
628
+ if (item === 'failed')
629
+ return null;
630
+ if (!addProcfsRow(src, rows, item.pid, item.stat, item.cmdline))
631
+ return null;
632
+ }
633
+ }
634
+ return rows;
635
+ }
636
+ /** Every live process's pid, parent pid and full command line, for callers that
637
+ * only match command text or walk parentage (stray-daemon sweep, viewer-pane
638
+ * detection). `null` when no sample could be taken. Same backend rule as the
639
+ * rest of this module: `/proc` where usable, `ps` elsewhere. */
640
+ export function listProcessTable() {
641
+ if (procfsUsable()) {
642
+ const snapshot = readProcfsSnapshot();
643
+ if (snapshot === null)
644
+ return null;
645
+ const ppidOf = new Map();
646
+ for (const [ppid, kids] of snapshot.childrenOf)
647
+ for (const kid of kids)
648
+ ppidOf.set(kid, ppid);
649
+ return [...snapshot.processes].map(([pid, p]) => ({ pid, ppid: ppidOf.get(pid) ?? 0, command: p.command }));
650
+ }
651
+ try {
652
+ const r = spawnSync('ps', ['-ax', '-o', 'pid=,ppid=,command='], { encoding: 'utf8', timeout: 2000, maxBuffer: 8 * 1024 * 1024 });
653
+ if (r.status !== 0 || typeof r.stdout !== 'string')
654
+ return null;
655
+ const out = [];
656
+ for (const line of r.stdout.split('\n')) {
657
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
658
+ if (m === null)
659
+ continue;
660
+ out.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3].trim() });
661
+ }
662
+ return out;
663
+ }
664
+ catch {
665
+ return null;
666
+ }
667
+ }
668
+ /** Test seam: exercise the `/proc` backend against a fake tree. */
669
+ export const procfsBackendForTest = { procIdentities, procProcessState, procCommand, readProcfsSnapshot, readProcfsSnapshotAsync };
670
+ function parseLivenessTable(stdout) {
671
+ const processes = new Map();
672
+ const childrenOf = new Map();
673
+ for (const line of stdout.split('\n')) {
674
+ const trimmed = line.trim();
675
+ if (trimmed === '')
676
+ continue;
677
+ // lstart is five whitespace-delimited fields on BSD ps and procps-ng.
678
+ const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(\S+(?:\s+\S+){4})\s+(.+)$/.exec(trimmed);
679
+ if (m === null)
680
+ continue;
681
+ const pid = Number(m[1]);
682
+ const ppid = Number(m[2]);
683
+ const stat = m[3];
684
+ const lstart = m[4];
685
+ const command = m[5].trim();
686
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0 || command === '')
687
+ continue;
688
+ processes.set(pid, { zombie: stat.startsWith('Z'), identity: composeIdentity(pid, lstart), command });
689
+ const siblings = childrenOf.get(ppid);
690
+ if (siblings === undefined)
691
+ childrenOf.set(ppid, [pid]);
692
+ else
693
+ siblings.push(pid);
694
+ }
695
+ return { processes, childrenOf };
696
+ }
697
+ /** BFS over an already-parsed ppid → children-pid[] map, starting from
698
+ * `rootPid`'s children. Pure (no `ps` spawn) — split out of `descendantPids`
699
+ * so `captureTeardownSnapshot` can walk the SAME process snapshot it
700
+ * already paid for, rather than re-spawning `ps`. */
701
+ function descendantPidsFrom(rootPid, childrenOf) {
702
+ const out = [];
703
+ const seen = new Set([rootPid]);
704
+ const queue = [...(childrenOf.get(rootPid) ?? [])];
705
+ while (queue.length > 0) {
706
+ const pid = queue.shift();
707
+ if (seen.has(pid))
708
+ continue; // guard against a malformed/cyclic ps snapshot
709
+ seen.add(pid);
710
+ out.push(pid);
711
+ const kids = childrenOf.get(pid);
712
+ if (kids !== undefined)
713
+ queue.push(...kids);
714
+ }
715
+ return out;
716
+ }
717
+ /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
718
+ * pid/ppid table (BFS) — NOT process-GROUP membership. The pi SDK's bash tool
719
+ * spawns its shell child with
720
+ * `detached: true` (verified in `@earendil-works/pi-agent-core`'s
721
+ * `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
722
+ * brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
723
+ * `setsid()` changes only SID/PGID, NEVER ppid, so the child (and its own
724
+ * descendants) is still discoverable by walking parentage from `rootPid`, as
725
+ * long as `rootPid`'s OS process is still alive when we walk (a fully-dead
726
+ * parent is reparented away by the kernel immediately on exit, severing this
727
+ * link — the wedged-but-alive broker this fix targets never hits that). Best-
728
+ * effort: returns `[]` (never throws) on any `ps` failure, exactly like
729
+ * `killProcessGroup`'s ESRCH-swallowing. */
730
+ export function descendantPids(rootPid) {
731
+ if (!Number.isInteger(rootPid) || rootPid <= 0)
732
+ return [];
733
+ const table = captureLivenessSnapshot();
734
+ if (table === null)
735
+ return [];
736
+ return descendantPidsFrom(rootPid, table.childrenOf);
737
+ }
738
+ /** Capture a portable process-identity fingerprint — `ps`'s `lstart` column,
739
+ * the process's full wall-clock start time — for each of `pids`, in ONE
740
+ * batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
741
+ * BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
742
+ * other Linux-only machinery. Used to guard against PID REUSE during the
743
+ * multi-second teardown escalation ladder: a
744
+ * captured pid can exit and the OS can recycle it for an unrelated process
745
+ * before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
746
+ * cannot tell the difference.
747
+ *
748
+ * Returns `null` — NOT an empty map — when the `ps` PROBE ITSELF failed (it
749
+ * could not be spawned, or was killed by the timeout): a probe failure means
750
+ * we have NO information, which is a fundamentally different outcome from a
751
+ * probe that SUCCEEDED and simply found no row for a given pid (that pid is
752
+ * genuinely gone). Collapsing these two into the same empty map is unsafe:
753
+ * `killProcessTreePids` cannot tell "pid confirmed gone" from "we don't know"
754
+ * without this
755
+ * distinction, and misreading a failed probe as positive proof of
756
+ * absence/reuse either signals a reused stranger or — worse — silently skips
757
+ * reaping a real live orphan. A successful call may still return a map with
758
+ * no entry for some/all of `pids` (those pids have no current row — genuinely
759
+ * gone); that is the valid empty-or-partial case and is NOT a failure.
760
+ *
761
+ * EXIT STATUS IS NOT THE FAILURE SIGNAL. `ps` exits 1 — on BOTH BSD ps and
762
+ * procps-ng — when it matched no process at all, which is precisely the
763
+ * "probe succeeded, that pid is gone" case, and exits 1 with PARTIAL stdout
764
+ * when only some of a batch's pids still exist. Reading a non-zero exit as a
765
+ * probe failure therefore turned every confirmed-dead pid into
766
+ * `'indeterminate'` (`recordedPidLiveness`), so a just-killed broker read as
767
+ * "can't confirm" — never as dead. Callers that fail open on indeterminate
768
+ * (`ensureAttach`'s revive decision, the daemon's exit policy) then treat a
769
+ * dead broker as live forever. Discriminate on whether the PROCESS RAN
770
+ * (`r.error`/`r.signal`) and whether it complained (`stderr`), never on what
771
+ * it exited with: a real argument/usage failure writes a diagnostic to stderr
772
+ * (`ps: process id too large`), while "matched nothing" is silent. */
773
+ export function capturePidIdentities(pids) {
774
+ const valid = pids.filter((pid) => Number.isInteger(pid) && pid > 0);
775
+ const out = new Map();
776
+ if (valid.length === 0)
777
+ return out;
778
+ if (procfsUsable())
779
+ return procIdentities(valid);
780
+ try {
781
+ const r = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', valid.join(',')], { encoding: 'utf8', timeout: 2000 });
782
+ if (r.error != null || r.signal != null || typeof r.stdout !== 'string')
783
+ return null;
784
+ if (typeof r.stderr === 'string' && r.stderr.trim() !== '')
785
+ return null;
786
+ for (const line of r.stdout.split('\n')) {
787
+ const trimmed = line.trim();
788
+ if (trimmed === '')
789
+ continue;
790
+ // Only the FIRST token (pid) is split on whitespace — `lstart` is a
791
+ // human-readable timestamp that itself CONTAINS spaces (e.g. "Wed Jul
792
+ // 3 10:23:45 2026"), so everything after the first space is kept
793
+ // verbatim as the identity string. Exact string equality later is
794
+ // sufficient; nothing here needs to parse it as a real date.
795
+ const spaceIdx = trimmed.indexOf(' ');
796
+ if (spaceIdx === -1)
797
+ continue;
798
+ const pid = Number(trimmed.slice(0, spaceIdx));
799
+ const startedAt = trimmed.slice(spaceIdx + 1).trim();
800
+ if (!Number.isInteger(pid) || pid <= 0 || startedAt === '')
801
+ continue;
802
+ out.set(pid, composeIdentity(pid, startedAt));
803
+ }
804
+ return out;
805
+ }
806
+ catch {
807
+ return null;
808
+ }
809
+ }
810
+ /** Read one pid's current full command line for a destructive caller that
811
+ * must freshly re-confirm process ownership immediately before signaling.
812
+ * Failure and absence both return null, so uncertainty never authorizes a
813
+ * signal. */
814
+ export function capturePidCommand(pid) {
815
+ if (!Number.isInteger(pid) || pid <= 0)
816
+ return null;
817
+ if (procfsUsable())
818
+ return procCommand(pid);
819
+ try {
820
+ const r = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
821
+ if (r.status !== 0 || typeof r.stdout !== 'string')
822
+ return null;
823
+ const command = r.stdout.trim();
824
+ return command === '' ? null : command;
825
+ }
826
+ catch {
827
+ return null;
828
+ }
829
+ }
830
+ export function captureTeardownSnapshot(rootPid, expectedIdentity, excludePid) {
831
+ if (!Number.isInteger(rootPid) || rootPid <= 0)
832
+ return { tree: [], identities: null, reused: false };
833
+ const table = captureLivenessSnapshot();
834
+ if (table === null)
835
+ return { tree: [rootPid], identities: null, reused: false };
836
+ const currentRootIdentity = table.processes.get(rootPid)?.identity;
837
+ const reused = expectedIdentity != null &&
838
+ currentRootIdentity !== undefined &&
839
+ !identitiesMatch(currentRootIdentity, expectedIdentity);
840
+ if (reused)
841
+ return { tree: [], identities: null, reused: true };
842
+ const excluded = excludePid !== undefined && Number.isInteger(excludePid) && excludePid > 0
843
+ ? new Set([excludePid, ...descendantPidsFrom(excludePid, table.childrenOf)])
844
+ : undefined;
845
+ const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)].filter((pid) => !excluded?.has(pid));
846
+ const identities = new Map();
847
+ for (const pid of tree) {
848
+ const id = table.processes.get(pid)?.identity;
849
+ if (id !== undefined)
850
+ identities.set(pid, id);
851
+ }
852
+ return { tree, identities, reused: false };
853
+ }
854
+ /** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot
855
+ * (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the
856
+ * caller before sending any signal — see the doc on `isProcessTreeAlive` for
857
+ * why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
858
+ * pid's OWN process group (a detached SDK-spawned descendant is its own
859
+ * group leader — the pi bash tool's shell child)
860
+ * PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
861
+ * NOT its own group leader). Every signal is best-effort — `killProcessGroup`
862
+ * swallows ESRCH and the plain `kill` here does too — so firing this against
863
+ * an already-fully-dead snapshot is a harmless no-op.
864
+ *
865
+ * `identities`, when supplied, is a
866
+ * `capturePidIdentities` snapshot taken alongside the pid list at the SAME
867
+ * moment — `null` if that INITIAL capture's `ps` probe itself failed (see
868
+ * `capturePidIdentities`'s doc). Before signaling EITHER the group or the
869
+ * plain kill for a pid, this re-derives CURRENT identities in one fresh
870
+ * batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
871
+ * failure or absence of evidence falls through to the normal signal path
872
+ * (fail-open, matching this module's unguarded behavior):
873
+ *
874
+ * - baseline identity present AND the current probe SUCCEEDED AND the
875
+ * current identity for this pid is present AND DIFFERENT — the OS has
876
+ * recycled this pid for an unrelated process since the snapshot — SKIP
877
+ * (the sole skip condition; this is the actual reused-pid hazard finding
878
+ * 3 targets).
879
+ * - baseline identity present but the pid is now simply GONE (current
880
+ * probe succeeded, no row for this pid) — it died naturally in the
881
+ * meantime, not reused — SIGNAL as normal (harmless no-op on the plain
882
+ * kill; load-bearing for the group kill, which can still reach a
883
+ * surviving process that shares this now-dead pid's process GROUP).
884
+ * - baseline had NO identity at all for this pid (the pid was ALREADY dead
885
+ * at snapshot time — e.g. a broker that crashed before `teardown()` ever
886
+ * ran) — SIGNAL as normal regardless of the current probe: this is
887
+ * exactly how `killProcessGroup` on an already-dead root still reaches a
888
+ * live process sharing its process group (the pgid persists after the
889
+ * group leader exits; see `host-teardown-process-group.test.ts`'s "dead
890
+ * broker" case).
891
+ * - the INITIAL capture's probe failed outright (`identities === null`) —
892
+ * we have NO baseline to compare against for ANY pid — SIGNAL as normal
893
+ * for the whole list; the CURRENT probe is not even attempted (nothing
894
+ * to compare it to).
895
+ * - the baseline succeeded but the CURRENT probe (taken here) fails — we
896
+ * have NO current information — SIGNAL as normal for the whole list; a
897
+ * failed probe must never be read as "gone" (would risk reaching a
898
+ * reused stranger, the residual risk this guard cannot close) NOR as
899
+ * "reused" (would wrongly disable the reap for every real live pid).
900
+ *
901
+ * Callers that omit `identities` entirely (pre-existing call sites) keep the
902
+ * prior unguarded behavior. */
903
+ export function killProcessTreePids(pids, signal = 'SIGTERM', identities) {
904
+ const current = identities != null ? capturePidIdentities(pids) : undefined;
905
+ for (const pid of pids) {
906
+ if (identities != null && current != null) {
907
+ const expected = identities.get(pid);
908
+ const actual = current.get(pid);
909
+ const reused = expected !== undefined && actual !== undefined && !identitiesMatch(actual, expected);
910
+ if (reused)
911
+ continue;
912
+ }
913
+ killProcessGroup(pid, signal);
914
+ if (!Number.isInteger(pid) || pid <= 0)
915
+ continue;
916
+ try {
917
+ process.kill(pid, signal);
918
+ }
919
+ catch {
920
+ /* already gone */
921
+ }
922
+ }
923
+ }
924
+ /** Is `rootPid`'s process TREE — itself or any CURRENT descendant — still
925
+ * alive, re-deriving the descendant set fresh via `ps` on every call? Safe
926
+ * ONLY before any signal has been sent to this tree. Once a signal kills the
927
+ * root (it's typically its own process-group leader — see `launch()` in
928
+ * host.ts), the kernel reparents any surviving child away from `rootPid`
929
+ * IMMEDIATELY (standard orphan handling, confirmed empirically: a killed
930
+ * parent's child shows `ppid=1` within the same tick, not lazily) —
931
+ * silently "losing" that child from a fresh `descendantPids` walk even
932
+ * though it is still very much alive. Use this ONLY to watch a tree you have
933
+ * not touched yet (e.g. waiting out a graceful-shutdown grace window); once
934
+ * you start signaling, capture the pid list ONCE and check it with
935
+ * `isAnyPidAlive` instead — see `killProcessTreePids`. */
936
+ export function isProcessTreeAlive(rootPid) {
937
+ if (isPidAlive(rootPid))
938
+ return true;
939
+ return descendantPids(rootPid).some((pid) => isPidAlive(pid));
940
+ }
941
+ /** Is any pid in an ALREADY-CAPTURED pid list (see `killProcessTreePids`)
942
+ * still alive? Pure signal-0 check against the fixed list — NEVER re-walks
943
+ * `ps` — the correct liveness check once you've started signaling a tree
944
+ * (see `isProcessTreeAlive`'s doc for why re-deriving after that point would
945
+ * silently drop a still-alive orphan). */
946
+ export function isAnyPidAlive(pids) {
947
+ return pids.some((pid) => isPidAlive(pid));
948
+ }