@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.
- package/bin/crouter +12 -1
- package/bin/crtr +12 -1
- package/bin/crtrd +12 -1
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +34 -0
- package/dist/clients/attach/attach-cmd.js +277 -277
- package/dist/core/__tests__/pid-zombie.test.d.ts +1 -0
- package/dist/core/__tests__/pid-zombie.test.js +42 -0
- package/dist/core/canvas/pid.d.ts +14 -3
- package/dist/core/canvas/pid.js +41 -4
- package/dist/core/runtime/spawn.js +18 -3
- package/package.json +1 -1
|
@@ -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)
|
|
2
|
-
* 0)` throws ESRCH when the process is gone; EPERM means
|
|
3
|
-
* ours — still alive
|
|
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
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
|
|
155
|
-
|
|
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
|