@crouton-kit/crouter 0.3.42 → 0.3.43

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/pid-zombie.test.ts
2
+ //
3
+ // Regression cover for the zombie-broker revive bug: `kill(pid, 0)` reports a
4
+ // ZOMBIE process (exited, awaiting reap by its parent) as perfectly alive —
5
+ // its pid is still occupied in the process table. A broker's spawning process
6
+ // that never gets to process its SIGCHLD (the real-world trigger: `bootRoot`'s
7
+ // foreground attach session used to block its OWN event loop for the whole
8
+ // session via a synchronous `spawnSync`, so it could never reap its detached
9
+ // broker child in real time — see the async-spawn fix in runtime/spawn.ts)
10
+ // leaves that broker a zombie indefinitely. The daemon's revive guard reads
11
+ // liveness through `isPidAlive(pi_pid)` alone (`revive.ts`), so misreading a
12
+ // zombie as alive wedges the relaunch forever. `isPidAlive` must classify a
13
+ // genuine zombie as dead.
14
+ //
15
+ // Fabricates a REAL zombie: spawn a child with no exit listener, then starve
16
+ // THIS process's own event loop with a synchronous busy-wait spanning the
17
+ // child's exit — mirroring exactly how a blocking spawnSync parks the parent's
18
+ // loop past a child's death, before any assertion ever runs.
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { spawn } from 'node:child_process';
22
+ import { isPidAlive } from '../canvas/pid.js';
23
+ test('isPidAlive reads a genuine zombie process as dead, not alive', () => {
24
+ const child = spawn('/bin/sh', ['-c', 'exit 0'], { detached: true, stdio: 'ignore' });
25
+ const pid = child.pid;
26
+ assert.ok(pid != null, 'child must have a pid');
27
+ child.unref(); // no exit listener attached — nothing reaps it while the loop is starved below
28
+ // Busy-wait SYNCHRONOUSLY (no `await`/timer — starving the event loop, not
29
+ // just delaying it) long enough for the child to exit and the kernel to
30
+ // mark it a zombie, before this process's SIGCHLD handling ever gets a turn.
31
+ const deadline = Date.now() + 400;
32
+ while (Date.now() < deadline) { /* starve the loop */ }
33
+ assert.equal(isPidAlive(pid), false, 'a zombie pid must read as dead, not alive');
34
+ // Best-effort cleanup: signaling an already-zombied pid is a harmless no-op
35
+ // (it's already dead) — the zombie self-clears once this test process exits
36
+ // and the orphan is reparented to init, same as every other fixture in this
37
+ // suite; nothing here relies on that timing for the assertion above.
38
+ try {
39
+ process.kill(pid, 'SIGKILL');
40
+ }
41
+ catch { /* already gone */ }
42
+ });
@@ -1,6 +1,17 @@
1
- /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
2
- * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
3
- * ours — still alive. A null/undefined pid (legacy / never-booted) reads dead. */
1
+ /** True if a process with `pid` is currently alive (signal-0 probe) AND not a
2
+ * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
3
+ * it exists but isn't ours — still alive (a foreign process is never OUR
4
+ * zombie, so the `isZombie` check is skipped for that branch — see below). A
5
+ * null/undefined pid (legacy / never-booted) reads dead.
6
+ *
7
+ * The zombie check matters because EVERY broker this module supervises is
8
+ * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
9
+ * dead broker whose spawning process hasn't reaped it yet is a zombie, and
10
+ * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
11
+ * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
12
+ * already-live-looking row, so a refreshed/crashed node whose broker zombied
13
+ * out under a long-lived spawning process (see `isZombie`'s doc) never comes
14
+ * back on its own. */
4
15
  export declare function isPidAlive(pid: number | null | undefined): boolean;
5
16
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
6
17
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
@@ -9,19 +9,56 @@
9
9
  // placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { readFileSync } from 'node:fs';
12
- /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
13
- * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
14
- * ours still alive. A null/undefined pid (legacy / never-booted) reads dead. */
12
+ /** Is `pid` a ZOMBIE (exited, awaiting reap by its parent)? `kill(pid, 0)`
13
+ * reports a zombie as a perfectly normal alive process its PID is still
14
+ * occupied in the process table so callers relying on that signal-0 probe
15
+ * alone (see `isPidAlive`) misread a dead broker as alive forever whenever
16
+ * its spawning process never reaps it (crouton-labs zombie-broker bug: the
17
+ * front door's foreground attach session parks its OWN event loop for the
18
+ * whole session — see `bootRoot` in runtime/spawn.ts — so it can't process
19
+ * the SIGCHLD that would otherwise reap its detached broker child in real
20
+ * time). `ps -o stat=` reports a leading `Z` for a zombie on BOTH BSD ps
21
+ * (macOS) and procps-ng (Linux), so one portable probe covers both platforms
22
+ * without a Linux-only `/proc` special case. Best-effort: any probe failure
23
+ * (spawn error, no matching row, non-zero exit) reads as NOT a zombie —
24
+ * fail-open, matching this module's other guards; a probe failure must never
25
+ * be misread as proof of death (a live pid IS alive; only a positive `Z`
26
+ * read says otherwise). */
27
+ function isZombie(pid) {
28
+ try {
29
+ const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
30
+ if (r.status !== 0 || typeof r.stdout !== 'string')
31
+ return false;
32
+ return r.stdout.trim().charAt(0) === 'Z';
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** True if a process with `pid` is currently alive (signal-0 probe) AND not a
39
+ * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
40
+ * it exists but isn't ours — still alive (a foreign process is never OUR
41
+ * zombie, so the `isZombie` check is skipped for that branch — see below). A
42
+ * null/undefined pid (legacy / never-booted) reads dead.
43
+ *
44
+ * The zombie check matters because EVERY broker this module supervises is
45
+ * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
46
+ * dead broker whose spawning process hasn't reaped it yet is a zombie, and
47
+ * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
48
+ * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
49
+ * already-live-looking row, so a refreshed/crashed node whose broker zombied
50
+ * out under a long-lived spawning process (see `isZombie`'s doc) never comes
51
+ * back on its own. */
15
52
  export function isPidAlive(pid) {
16
53
  if (pid == null)
17
54
  return false;
18
55
  try {
19
56
  process.kill(pid, 0);
20
- return true;
21
57
  }
22
58
  catch (e) {
23
59
  return e.code === 'EPERM';
24
60
  }
61
+ return !isZombie(pid);
25
62
  }
26
63
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
27
64
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
@@ -9,7 +9,7 @@
9
9
  // terminal background worker by default; with `root`, an
10
10
  // INDEPENDENT resident root (no subscription back to the spawner,
11
11
  // provenance via spawned_by) brought forefront for direct driving.
12
- import { spawnSync } from 'node:child_process';
12
+ import { spawn } from 'node:child_process';
13
13
  import { readdirSync, existsSync } from 'node:fs';
14
14
  import { isAbsolute, resolve, join } from 'node:path';
15
15
  import { homedir } from 'node:os';
@@ -151,8 +151,23 @@ export async function bootRoot(opts) {
151
151
  throw new Error(`the front-door root broker ${meta.node_id} never bound its view socket — cannot attach.`);
152
152
  }
153
153
  const attachEnv = { ...process.env, ...viewerSplitEnv() };
154
- const r = spawnSync('crtr', ['surface', 'attach', 'to', meta.node_id], { stdio: 'inherit', env: attachEnv });
155
- process.exit(r.status ?? 0);
154
+ // Async, NOT spawnSync: a synchronous spawn would park THIS process's own
155
+ // event loop for the whole attach session (which legitimately runs for
156
+ // hours) — starving it of the SIGCHLD delivery it needs to reap its OWN
157
+ // detached broker child (recorded above via `recordPid`). A refresh/crash
158
+ // mid-session would then leave that broker a zombie for as long as the user
159
+ // stays attached, which `isPidAlive` misread as "alive" and blocked the
160
+ // daemon's relaunch (the exact bug this fixes). An async spawn keeps this
161
+ // process's loop ticking throughout, so the broker's already-registered
162
+ // `exit` listener (`headlessBrokerHost.launch`) actually gets to run and
163
+ // reap it in real time; `stdio: 'inherit'` still hands the child the TTY
164
+ // directly, so this is a behavior-neutral swap for the foreground session.
165
+ const attachChild = spawn('crtr', ['surface', 'attach', 'to', meta.node_id], { stdio: 'inherit', env: attachEnv });
166
+ const code = await new Promise((resolveCode) => {
167
+ attachChild.once('exit', (exitCode) => resolveCode(exitCode ?? 0));
168
+ attachChild.once('error', () => resolveCode(1));
169
+ });
170
+ process.exit(code);
156
171
  }
157
172
  /** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
158
173
  * sessions`). pi's package `exports` map is `.`-only, so config.js can't be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.42",
3
+ "version": "0.3.43",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",