@henols/vice-mcp 0.1.4
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/README.md +66 -0
- package/build.ts +268 -0
- package/container-guard.mts +226 -0
- package/containerpath.ts +296 -0
- package/hostpath.ts +318 -0
- package/incident-record.ts +442 -0
- package/install-resources.ts +532 -0
- package/package.json +71 -0
- package/refresh-manifest.ts +103 -0
- package/repo-root.ts +198 -0
- package/resources/broker-control.mjs +343 -0
- package/resources/broker-epoch.mjs +126 -0
- package/resources/broker-kill.mjs +491 -0
- package/resources/broker-launch.mjs +659 -0
- package/resources/broker-state.mjs +173 -0
- package/resources/container-guard.mjs +211 -0
- package/resources/vice-broker.mjs +855 -0
- package/resources/vice-launcher.sh +169 -0
- package/tools-manifest.json +1231 -0
- package/vice-broker-client.ts +899 -0
- package/vice-probe.ts +278 -0
- package/vice-proxy.ts +3093 -0
- package/vice-sync.ts +336 -0
- package/vice.ts +772 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// GENERATED FILE -- DO NOT EDIT.
|
|
2
|
+
// Compiled by `tsc` from broker-epoch.mts. Edit the TypeScript source and rebuild;
|
|
3
|
+
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
|
+
// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
|
|
5
|
+
// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
|
|
6
|
+
// rebuild.
|
|
7
|
+
// broker-epoch.mts
|
|
8
|
+
//
|
|
9
|
+
// B / D-04: the per-instance epoch.json writer, held to the frozen
|
|
10
|
+
// eight-field contract captured in fixtures/ (task 1, before the bash
|
|
11
|
+
// writer that produced them is deleted later in this phase). Ports
|
|
12
|
+
// write_epoch()'s exact field shape and its atomic tmp-sibling-then-rename
|
|
13
|
+
// discipline -- the tmp file is created empty, mode tightened to
|
|
14
|
+
// owner-read-write BEFORE any content reaches it, content written, then
|
|
15
|
+
// renamed -- matching writeBrokerRecord()'s own choke point in
|
|
16
|
+
// vice-broker.mts exactly.
|
|
17
|
+
//
|
|
18
|
+
// Plan 03, Task 1 completes this module: the path derivations
|
|
19
|
+
// (epochPathFor/instanceLogDirFor, both built on the SAME private
|
|
20
|
+
// per-instance-directory helper so the epoch record's `log` field and the
|
|
21
|
+
// file actually written under that directory can never disagree) and the
|
|
22
|
+
// epoch-increment derivation (nextEpochFor) the per-child supervisor
|
|
23
|
+
// (broker-launch.mts, Task 2) needs to bump an instance's epoch on every
|
|
24
|
+
// respawn.
|
|
25
|
+
//
|
|
26
|
+
// T-01.6.2-17 (tampering, epoch record contents): the `pid` field is the
|
|
27
|
+
// emulator CHILD's own pid -- NEVER this broker's own pid. A recycle
|
|
28
|
+
// request resolves its target through this exact field (the grant -> its
|
|
29
|
+
// recorded epoch_file -> that file's pid chain, per
|
|
30
|
+
// handle_recycle_request()'s bash original), so a wrong value here would
|
|
31
|
+
// signal the wrong process. This module never fills that field itself (the
|
|
32
|
+
// caller -- broker-launch.mts's spawn+record path -- supplies it from the
|
|
33
|
+
// actually-spawned child's own pid); this comment exists so no future
|
|
34
|
+
// change quietly starts passing this broker's own process.pid instead.
|
|
35
|
+
import { writeFileSync, chmodSync, renameSync, mkdirSync, readFileSync } from "node:fs";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
/** The one place that knows the epoch file's name relative to its
|
|
38
|
+
* per-instance directory -- writeEpochRecord() and nextEpochFor() both call
|
|
39
|
+
* THIS, so the writer and the reader-for-increment can never name two
|
|
40
|
+
* different files. */
|
|
41
|
+
function epochFileIn(supervisorDir) {
|
|
42
|
+
return join(supervisorDir, "epoch.json");
|
|
43
|
+
}
|
|
44
|
+
/** The one shared per-instance directory derivation -- stateDir + port,
|
|
45
|
+
* exactly matching vice-broker.mts's own `join(stateDir, String(port))`
|
|
46
|
+
* (Plan 01/02). epochPathFor() and instanceLogDirFor() below both build on
|
|
47
|
+
* THIS, so a caller deriving one from the other (as broker-launch.mts's
|
|
48
|
+
* per-child supervisor does for the epoch record's own `log` field) can
|
|
49
|
+
* never observe the two disagree. Not exported: callers that already have a
|
|
50
|
+
* `stateDir`/`port` pair use epochPathFor()/instanceLogDirFor() directly;
|
|
51
|
+
* a caller that already has a resolved `supervisorDir` (e.g.
|
|
52
|
+
* writeEpochRecord()'s own caller) never needs to re-derive it. */
|
|
53
|
+
function instanceDirFor(stateDir, port) {
|
|
54
|
+
return join(stateDir, String(port));
|
|
55
|
+
}
|
|
56
|
+
/** Resolves the epoch file's path for a given instance directly from the
|
|
57
|
+
* state directory and port -- the exact per-instance directory shape
|
|
58
|
+
* vice-broker.mts's launch paths already build inline
|
|
59
|
+
* (`join(stateDir, String(port))`), named here as one function so a second,
|
|
60
|
+
* independently-drifted derivation never needs to exist. */
|
|
61
|
+
export function epochPathFor(stateDir, port) {
|
|
62
|
+
return epochFileIn(instanceDirFor(stateDir, port));
|
|
63
|
+
}
|
|
64
|
+
/** Resolves the per-instance logs directory -- the `logs/` subdirectory
|
|
65
|
+
* under the SAME per-instance directory epochPathFor() derives from, so a
|
|
66
|
+
* caller building the epoch record's `log` field (a path relative to the
|
|
67
|
+
* instance directory, e.g. `logs/x64sc-<ts>.log`) and the caller that
|
|
68
|
+
* actually opens that log file are guaranteed to agree on where `logs/`
|
|
69
|
+
* lives. */
|
|
70
|
+
export function instanceLogDirFor(stateDir, port) {
|
|
71
|
+
return join(instanceDirFor(stateDir, port), "logs");
|
|
72
|
+
}
|
|
73
|
+
/** Writes supervisorDir/epoch.json. Per RESEARCH assumption A4,
|
|
74
|
+
* supervisor_pid has no consumer that reads it for behaviour -- the field
|
|
75
|
+
* is kept and pointed at THIS broker's own pid, so a human reading the file
|
|
76
|
+
* by hand still finds a supervising process to look up, even though this
|
|
77
|
+
* broker spawns the emulator directly and there is no separate per-instance
|
|
78
|
+
* supervisor process any more. */
|
|
79
|
+
export function writeEpochRecord({ supervisorDir, record }) {
|
|
80
|
+
mkdirSync(supervisorDir, { recursive: true });
|
|
81
|
+
const finalPath = epochFileIn(supervisorDir);
|
|
82
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
83
|
+
writeFileSync(tmpPath, "");
|
|
84
|
+
chmodSync(tmpPath, 0o600);
|
|
85
|
+
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
|
|
86
|
+
renameSync(tmpPath, finalPath);
|
|
87
|
+
return finalPath;
|
|
88
|
+
}
|
|
89
|
+
/** The epoch-increment derivation the per-child supervisor calls on every
|
|
90
|
+
* respawn (and on an instance's very first launch): reads supervisorDir's
|
|
91
|
+
* current epoch.json if one is present, using the SAME never-throw posture
|
|
92
|
+
* already established for untrusted reads elsewhere in this codebase
|
|
93
|
+
* (readBrokerRecordMaybe() in vice-broker.mts, readEpoch() in vice.ts) --
|
|
94
|
+
* absence, an unreadable file, malformed JSON, a non-object shape, or a
|
|
95
|
+
* non-integer `epoch` field are ALL treated as "no usable prior record"
|
|
96
|
+
* rather than an error. A fresh instance must be able to start over an
|
|
97
|
+
* unreadable file; refusing to write because the OLD record looks wrong
|
|
98
|
+
* would strand the instance permanently. Returns one more than the epoch
|
|
99
|
+
* found, or 1 when there is no usable prior record -- matching
|
|
100
|
+
* read_prev_epoch()'s own "start at 0 so the first write becomes 1"
|
|
101
|
+
* behaviour exactly. */
|
|
102
|
+
export function nextEpochFor(supervisorDir) {
|
|
103
|
+
const path = epochFileIn(supervisorDir);
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = readFileSync(path, "utf8");
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
const epoch = parsed.epoch;
|
|
122
|
+
if (!Number.isInteger(epoch)) {
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
return epoch + 1;
|
|
126
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// GENERATED FILE -- DO NOT EDIT.
|
|
2
|
+
// Compiled by `tsc` from broker-kill.mts. Edit the TypeScript source and rebuild;
|
|
3
|
+
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
|
+
// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
|
|
5
|
+
// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
|
|
6
|
+
// rebuild.
|
|
7
|
+
// broker-kill.mts
|
|
8
|
+
//
|
|
9
|
+
// D (complete, this plan -- 01.6.2-04): the identity-verified kill discipline,
|
|
10
|
+
// ported from resources/vice-broker.sh's signal_recorded_pid()/
|
|
11
|
+
// signal_vice_child_pid(): zero-signal liveness check, identity check against
|
|
12
|
+
// the process's own argument string, SIGTERM, poll-then-SIGKILL. The
|
|
13
|
+
// expected-identity string always comes from the instance record (the
|
|
14
|
+
// resolved binary path recorded at spawn time by broker-launch.mts), never a
|
|
15
|
+
// module constant -- this broker spawns the emulator directly and there is
|
|
16
|
+
// no intermediate supervising script for an identity check to match against.
|
|
17
|
+
// See this module's own history: the bash original's PARAMETERISED sibling
|
|
18
|
+
// (signal_vice_child_pid, matched against a caller-supplied binary) is the
|
|
19
|
+
// model this ported; its hardcoded sibling (signal_recorded_pid, matched
|
|
20
|
+
// against $SUPERVISOR_SCRIPT) is NOT -- there is no supervisor script in
|
|
21
|
+
// this topology, so carrying that constant forward would make every kill
|
|
22
|
+
// silently refuse while logging a plausible-looking pid-reuse warning.
|
|
23
|
+
//
|
|
24
|
+
// This task also completes the module with two further concerns, both
|
|
25
|
+
// depending on the kill discipline above rather than replacing it:
|
|
26
|
+
// - shutdown()/registerShutdownHandlers(): every catchable teardown path
|
|
27
|
+
// (SIGTERM, SIGINT, SIGHUP, an uncaught exception, an unhandled
|
|
28
|
+
// rejection, normal exit) converges on one re-entrant-safe teardown that
|
|
29
|
+
// identity-verified-kills every instance and clears the map
|
|
30
|
+
// unconditionally (kill-never-recycle). The uncatchable signals (SIGKILL,
|
|
31
|
+
// SIGSTOP) are deliberately unhandled -- see registerShutdownHandlers()'s
|
|
32
|
+
// own comment.
|
|
33
|
+
// - reapOrphanedInstances()/discoverBandProcesses(): the unconditional
|
|
34
|
+
// startup reap (criterion I, D-15) that reaches instances this broker
|
|
35
|
+
// process has no in-memory record of, derived from the emulator port
|
|
36
|
+
// band plus process identity rather than from a registry a restart just
|
|
37
|
+
// lost.
|
|
38
|
+
import { execFileSync } from "node:child_process";
|
|
39
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
const defaultIsAlive = (pid) => {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const defaultReadProcessArgs = (pid) => {
|
|
51
|
+
try {
|
|
52
|
+
return execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8" });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const defaultKill = (pid, signal) => {
|
|
59
|
+
try {
|
|
60
|
+
process.kill(pid, signal);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// already gone -- idempotent by design, matching the bash version's `|| true`
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const defaultSleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
function resolveKillWaitS(override) {
|
|
68
|
+
if (typeof override === "number")
|
|
69
|
+
return override;
|
|
70
|
+
const raw = process.env.VICE_BROKER_KILL_WAIT_S;
|
|
71
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
72
|
+
return Number.isFinite(n) ? n : 5;
|
|
73
|
+
}
|
|
74
|
+
function defaultLog(line) {
|
|
75
|
+
process.stderr.write(`${line}\n`);
|
|
76
|
+
}
|
|
77
|
+
/** Implements the discipline exactly as signal_recorded_pid()/
|
|
78
|
+
* signal_vice_child_pid() do. An empty/null/non-positive pid, or a pid
|
|
79
|
+
* already gone, returns "already_exited" without ever signalling -- "the
|
|
80
|
+
* machine being gone is the goal", per the bash version's own comment. A
|
|
81
|
+
* live pid whose OWN argument string does not contain expectedIdentity is
|
|
82
|
+
* REFUSED -- never signalled -- and returns "identity_refused", the one
|
|
83
|
+
* outcome a caller must be able to tell apart from every other stage
|
|
84
|
+
* (possible pid reuse). Only a genuine identity match proceeds: SIGTERM,
|
|
85
|
+
* poll every 200ms up to killWaitS (default VICE_BROKER_KILL_WAIT_S / 5),
|
|
86
|
+
* SIGKILL on a survivor. */
|
|
87
|
+
export async function verifiedKill({ pid, expectedIdentity, deps = {} }) {
|
|
88
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
89
|
+
const readProcessArgs = deps.readProcessArgs ?? defaultReadProcessArgs;
|
|
90
|
+
const kill = deps.kill ?? defaultKill;
|
|
91
|
+
const sleepMs = deps.sleepMs ?? defaultSleepMs;
|
|
92
|
+
const killWaitS = resolveKillWaitS(deps.killWaitS);
|
|
93
|
+
if (pid === null || !Number.isFinite(pid) || pid <= 0) {
|
|
94
|
+
return "already_exited";
|
|
95
|
+
}
|
|
96
|
+
if (!isAlive(pid)) {
|
|
97
|
+
return "already_exited";
|
|
98
|
+
}
|
|
99
|
+
const args = readProcessArgs(pid);
|
|
100
|
+
if (!args.includes(expectedIdentity)) {
|
|
101
|
+
process.stderr.write(`vice-broker: refusing to signal pid ${pid} -- ps reports "${args.trim()}", which does not match expected identity "${expectedIdentity}" (possible pid reuse)\n`);
|
|
102
|
+
return "identity_refused";
|
|
103
|
+
}
|
|
104
|
+
kill(pid, "SIGTERM");
|
|
105
|
+
const limitMs = killWaitS * 1000;
|
|
106
|
+
let waitedMs = 0;
|
|
107
|
+
while (isAlive(pid)) {
|
|
108
|
+
if (waitedMs >= limitMs) {
|
|
109
|
+
process.stderr.write(`vice-broker: pid ${pid} did not exit within ${killWaitS}s of SIGTERM -- sending SIGKILL\n`);
|
|
110
|
+
kill(pid, "SIGKILL");
|
|
111
|
+
return "sigkill";
|
|
112
|
+
}
|
|
113
|
+
await sleepMs(200);
|
|
114
|
+
waitedMs += 200;
|
|
115
|
+
}
|
|
116
|
+
return "sigterm";
|
|
117
|
+
}
|
|
118
|
+
/** The single shutdown sequence every catchable entry point converges on
|
|
119
|
+
* (mirrors resources/vice-broker.sh's own broker_shutdown() ->
|
|
120
|
+
* reap_all_instances()). For every instance currently recorded: set the
|
|
121
|
+
* deliberate-kill marker BEFORE any signal reaches it (T-01.6.2-21) -- done
|
|
122
|
+
* as its own pass over every instance FIRST, before any kill is attempted,
|
|
123
|
+
* so a slow kill on instance A can never race a later-arriving signal that
|
|
124
|
+
* finds instance B's marker still unset -- so a supervisor's exit handler
|
|
125
|
+
* (broker-launch.mts's superviseChild()) treats the death as a deliberate
|
|
126
|
+
* teardown, never a crash to respawn. Then kill it identity-verified through
|
|
127
|
+
* verifiedKill() (or the injected stand-in). Then remove it from the map
|
|
128
|
+
* UNCONDITIONALLY, whatever stage word came back -- that unconditional
|
|
129
|
+
* removal IS the kill-never-recycle structural guarantee: the only way an
|
|
130
|
+
* instance becomes grantable again is a fresh launch, never a reset of this
|
|
131
|
+
* entry (mirrors teardown()'s own header comment in the bash original).
|
|
132
|
+
* Never throws past an individual kill failure -- one instance's kill
|
|
133
|
+
* rejecting must not stop every other instance from being torn down. */
|
|
134
|
+
export async function shutdown(deps) {
|
|
135
|
+
const kill = deps.kill ?? verifiedKill;
|
|
136
|
+
const log = deps.log ?? defaultLog;
|
|
137
|
+
const instances = Array.from(deps.state.instances.values());
|
|
138
|
+
for (const instance of instances) {
|
|
139
|
+
instance.deliberateKill = true;
|
|
140
|
+
}
|
|
141
|
+
let killed = 0;
|
|
142
|
+
for (const instance of instances) {
|
|
143
|
+
try {
|
|
144
|
+
const stage = await kill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity });
|
|
145
|
+
if (stage === "sigterm" || stage === "sigkill")
|
|
146
|
+
killed++;
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
log(`vice-broker: shutdown -- kill of port ${instance.port} threw: ${e.message}`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
deps.state.instances.delete(instance.port);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
log(`vice-broker: shutdown complete -- ${instances.length} instance(s) processed, ${killed} signalled`);
|
|
156
|
+
}
|
|
157
|
+
/** The six catchable entry points every real broker process registers
|
|
158
|
+
* shutdown() against. Not exported: registerShutdownHandlers() below is the
|
|
159
|
+
* only caller, and no other module needs to enumerate these by name -- a
|
|
160
|
+
* structural test reads this array directly via a test-only re-export
|
|
161
|
+
* rather than duplicating the literal list. */
|
|
162
|
+
const HANDLED_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
163
|
+
/** Exported ONLY for the structural test asserting no handler is registered
|
|
164
|
+
* for the uncatchable kill/stop signals -- reading this array is how that
|
|
165
|
+
* test enumerates "the registered signal names from the module's own
|
|
166
|
+
* registration function" without parsing source text. */
|
|
167
|
+
export const _HANDLED_SIGNALS = HANDLED_SIGNALS;
|
|
168
|
+
/** Registers the single shutdown sequence against every CATCHABLE entry
|
|
169
|
+
* point: SIGTERM, SIGINT, SIGHUP, an uncaught exception, an unhandled
|
|
170
|
+
* rejection, and normal exit -- six listeners, one shutdown function,
|
|
171
|
+
* mirroring the bash original's single `trap broker_shutdown EXIT HUP INT
|
|
172
|
+
* TERM` (extended here with the two JS-only failure modes bash has no
|
|
173
|
+
* equivalent of). Disarms re-entry FIRST, before any child is signalled --
|
|
174
|
+
* the bash version disarms its own trap (`trap - EXIT HUP INT TERM`) as its
|
|
175
|
+
* first statement for exactly this reason, and an interrupt followed by a
|
|
176
|
+
* terminate a hundred milliseconds later is a shape this project has
|
|
177
|
+
* already seen (2026-08-02).
|
|
178
|
+
*
|
|
179
|
+
* Registers NOTHING for SIGKILL or SIGSTOP, and builds no mechanism to
|
|
180
|
+
* prevent orphans after one -- both are UNCATCHABLE at the OS level; a
|
|
181
|
+
* process receiving either executes no handler, no exit hook, no cleanup
|
|
182
|
+
* block, and in a one-process design there is no supervisor left standing
|
|
183
|
+
* to be told anything happened. Orphaned emulators after such a kill are
|
|
184
|
+
* accepted and cleaned up by hand (T-01.6.2-29) -- the NEXT broker start's
|
|
185
|
+
* unconditional reap (reapOrphanedInstances() below) is the actual recovery
|
|
186
|
+
* mechanism, not anything registered here. Two kernel-level alternatives (a
|
|
187
|
+
* watchdog process, a cgroup-wide kill) were considered during this phase's
|
|
188
|
+
* own design discussion and dropped as over-engineering, not deferred.
|
|
189
|
+
*
|
|
190
|
+
* The 'exit' listener is registered identically to the other five, but
|
|
191
|
+
* carries an honest limitation worth stating rather than hiding: Node's
|
|
192
|
+
* 'exit' event fires synchronously and cannot keep the event loop alive for
|
|
193
|
+
* pending async work, so on a REAL process exit only shutdown()'s
|
|
194
|
+
* synchronous prefix (marking every instance deliberately-killed, issuing
|
|
195
|
+
* the initial SIGTERM to each) is guaranteed to run before the process is
|
|
196
|
+
* actually gone -- the SIGTERM-wait-then-SIGKILL escalation's own polling
|
|
197
|
+
* cannot complete there. This is a real Node platform limitation, not a gap
|
|
198
|
+
* in this module; it is why the 'exit' path is exercised in this module's
|
|
199
|
+
* own tests via the injectable `proc` seam (a plain EventEmitter, which CAN
|
|
200
|
+
* await async work in its own listeners) rather than a real process exit.
|
|
201
|
+
*
|
|
202
|
+
* Returns a cleanup function that removes every listener this call
|
|
203
|
+
* registered -- used by this module's own tests so successive test cases
|
|
204
|
+
* never accumulate listeners on the same `proc` object; a real broker
|
|
205
|
+
* process never calls it (registered once, for the process's whole life). */
|
|
206
|
+
export function registerShutdownHandlers(deps) {
|
|
207
|
+
// Whether this call is wired to the REAL Node process global (true, the
|
|
208
|
+
// real-broker wiring site in vice-broker.mts) or a test's injected
|
|
209
|
+
// process-like stand-in (false). This is what the default `exit` below
|
|
210
|
+
// uses to decide whether it may actually call process.exit() -- see that
|
|
211
|
+
// default's own comment for why the answer differs by caller.
|
|
212
|
+
const usingRealProcess = deps.proc === undefined;
|
|
213
|
+
const proc = deps.proc ?? process;
|
|
214
|
+
// Default exit: always records the intended exit code on `proc.exitCode`
|
|
215
|
+
// first (matching this codebase's own "never process.exit(), always
|
|
216
|
+
// process.exitCode" convention used elsewhere, e.g. main()'s
|
|
217
|
+
// argument-parsing error paths, so any pending stdout/stderr write has a
|
|
218
|
+
// chance to flush) -- but ONLY when wired to the real Node process does it
|
|
219
|
+
// ALSO call the real process.exit(code) explicitly. This is deliberate,
|
|
220
|
+
// not an inconsistency: a long-lived broker keeps its heartbeat/poll
|
|
221
|
+
// setInterval timers alive for as long as the process lives, so merely
|
|
222
|
+
// setting `process.exitCode` after a signal would never actually end the
|
|
223
|
+
// process -- the event loop has nothing left that would let it drain on
|
|
224
|
+
// its own. A deliberate, fully-sequenced shutdown (every instance killed,
|
|
225
|
+
// every marker set, the log line written) is exactly the point at which an
|
|
226
|
+
// explicit process.exit() is the right primitive, not a shortcut around
|
|
227
|
+
// it. Tests that inject their own `proc` (a fake, never the real Node
|
|
228
|
+
// process) never take this branch, so emitting 'exit'/'uncaughtException'
|
|
229
|
+
// on a fake stand-in never terminates the actual test-runner process.
|
|
230
|
+
const exit = deps.exit ?? ((code) => {
|
|
231
|
+
proc.exitCode = code;
|
|
232
|
+
if (usingRealProcess) {
|
|
233
|
+
process.exit(code);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const log = deps.log ?? defaultLog;
|
|
237
|
+
let running = false;
|
|
238
|
+
const run = (reasonForLog, exitCode) => {
|
|
239
|
+
if (running)
|
|
240
|
+
return;
|
|
241
|
+
running = true;
|
|
242
|
+
shutdown(deps)
|
|
243
|
+
.catch((e) => {
|
|
244
|
+
log(`vice-broker: shutdown error during ${reasonForLog}: ${e.message}`);
|
|
245
|
+
})
|
|
246
|
+
.finally(() => {
|
|
247
|
+
exit(exitCode);
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches
|
|
251
|
+
// ProcessLike#once's own listener shape (node:events' EventEmitter#once).
|
|
252
|
+
const registrations = [];
|
|
253
|
+
const register = (event, listener) => {
|
|
254
|
+
proc.once(event, listener);
|
|
255
|
+
registrations.push([event, listener]);
|
|
256
|
+
};
|
|
257
|
+
for (const sig of HANDLED_SIGNALS) {
|
|
258
|
+
register(sig, () => run(`signal ${sig}`, 0));
|
|
259
|
+
}
|
|
260
|
+
register("uncaughtException", (err) => {
|
|
261
|
+
log(`vice-broker: uncaught exception: ${err && err.message ? err.message : String(err)}`);
|
|
262
|
+
run("uncaughtException", 1);
|
|
263
|
+
});
|
|
264
|
+
register("unhandledRejection", (reason) => {
|
|
265
|
+
log(`vice-broker: unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
266
|
+
run("unhandledRejection", 1);
|
|
267
|
+
});
|
|
268
|
+
register("exit", () => run("exit", 0));
|
|
269
|
+
return () => {
|
|
270
|
+
const p = proc;
|
|
271
|
+
if (typeof p.removeListener === "function") {
|
|
272
|
+
for (const [event, listener] of registrations) {
|
|
273
|
+
p.removeListener(event, listener);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** D-25's mandatory start-time banner: printed unconditionally, before the
|
|
279
|
+
* control listener begins accepting, naming exactly what a keyboard
|
|
280
|
+
* interrupt or a closed terminal destroys. On 2026-08-02 a `^C` produced
|
|
281
|
+
* "reap saw 4 recorded instance(s), terminated 4" and killed a live
|
|
282
|
+
* session -- the incident was not caused by missing machinery, it was
|
|
283
|
+
* caused by nobody being told. Detaching stays the operator's own
|
|
284
|
+
* nohup/setsid/systemd choice (D-25) -- this banner names that choice
|
|
285
|
+
* rather than offering a flag; the launcher stays thin.
|
|
286
|
+
*
|
|
287
|
+
* D-25/P-13 (01.6.2.1-05-PLAN.md): the one place naming the retired
|
|
288
|
+
* warm-floor environment variable does not weaken D-10/D-11's clean break --
|
|
289
|
+
* the line added below reports the variable's mere PRESENCE, never its
|
|
290
|
+
* value, and no reader anywhere in this broker still consults it (the
|
|
291
|
+
* structural gate in broker-kill.test.ts proves that). Without it, an
|
|
292
|
+
* operator with the retired variable set in a shell profile would silently
|
|
293
|
+
* get the default instead of their configured value, with nothing saying
|
|
294
|
+
* so -- the exact failure mode the developer was shown when choosing the
|
|
295
|
+
* clean break over a fallback read. */
|
|
296
|
+
export function startupBanner() {
|
|
297
|
+
const lines = [
|
|
298
|
+
"vice-broker: WARNING -- this broker runs in the FOREGROUND of this process.",
|
|
299
|
+
"vice-broker: a keyboard interrupt (Ctrl-C), a closed terminal, or an ending SSH/VS Code",
|
|
300
|
+
"vice-broker: session will TERMINATE EVERY EMULATOR this broker launched -- including",
|
|
301
|
+
"vice-broker: instances leased to OTHER AGENTS' LIVE SESSIONS, which then lose their",
|
|
302
|
+
"vice-broker: accumulated context.",
|
|
303
|
+
"vice-broker: a broker that dies voids every session it was serving -- there is no",
|
|
304
|
+
"vice-broker: reconnect. A session whose broker dies must be restarted, not resumed.",
|
|
305
|
+
"vice-broker: to run this broker outside the current terminal session, use your own",
|
|
306
|
+
"vice-broker: nohup/setsid/systemd -- this launcher does not offer a --detach flag.",
|
|
307
|
+
];
|
|
308
|
+
if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check (D-25/P-13) -- never reads the value
|
|
309
|
+
lines.push("vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. Use VICE_BROKER_WARM_FLOOR instead.");
|
|
310
|
+
}
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
/** Real default: `ps -eo pid=,args=` -- every process on the host, pid plus
|
|
314
|
+
* its full argument string. Never throws: an unreadable `ps` (e.g. no
|
|
315
|
+
* processes visible under this container's pid namespace) yields an empty
|
|
316
|
+
* list rather than aborting the reap. */
|
|
317
|
+
function defaultListProcesses() {
|
|
318
|
+
let raw;
|
|
319
|
+
try {
|
|
320
|
+
raw = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf8" });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const line of raw.split("\n")) {
|
|
327
|
+
const trimmed = line.trimStart();
|
|
328
|
+
if (trimmed === "")
|
|
329
|
+
continue;
|
|
330
|
+
const m = /^(\d+)\s+(.*)$/.exec(trimmed);
|
|
331
|
+
if (!m)
|
|
332
|
+
continue;
|
|
333
|
+
const pid = Number(m[1]);
|
|
334
|
+
if (!Number.isFinite(pid))
|
|
335
|
+
continue;
|
|
336
|
+
out.push({ pid, args: m[2] });
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
/** True iff `args` contains a bare numeric token whose value is >= basePort.
|
|
341
|
+
* This is a substring/token scan over the process's own argument string --
|
|
342
|
+
* the same class of untrusted-but-locally-observed check this module's
|
|
343
|
+
* identity check already performs -- not a parse of any particular VICE
|
|
344
|
+
* flag shape, so it holds regardless of whether the port arrived via
|
|
345
|
+
* `-mcpserverport N` or a raw VICE_ARGS override naming the port some other
|
|
346
|
+
* way. */
|
|
347
|
+
function argsNamePortAtOrAbove(args, basePort) {
|
|
348
|
+
const matches = args.match(/\d+/g);
|
|
349
|
+
if (!matches)
|
|
350
|
+
return false;
|
|
351
|
+
return matches.some((token) => {
|
|
352
|
+
const n = Number(token);
|
|
353
|
+
return Number.isFinite(n) && n >= basePort;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function resolveBasePortForReap(override) {
|
|
357
|
+
if (typeof override === "number")
|
|
358
|
+
return override;
|
|
359
|
+
const raw = process.env.VICE_BROKER_BASE_PORT;
|
|
360
|
+
if (raw === undefined || raw === "")
|
|
361
|
+
return 6600;
|
|
362
|
+
const n = Number(raw);
|
|
363
|
+
return Number.isFinite(n) ? n : 6600;
|
|
364
|
+
}
|
|
365
|
+
function resolveViceBinForReap(override) {
|
|
366
|
+
return override ?? process.env.VICE_BIN ?? "x64sc";
|
|
367
|
+
}
|
|
368
|
+
/** Two-condition selection (T-01.6.2-25/-26): a process qualifies ONLY when
|
|
369
|
+
* its own argument string BOTH names the configured emulator binary AND
|
|
370
|
+
* names a port at or above the allocation band's base. A process matching
|
|
371
|
+
* only one condition is left alone -- this is the whole point: the
|
|
372
|
+
* 6510-6599 band below the base is reserved by convention for an emulator a
|
|
373
|
+
* human launched for their own work (D-18), and reaping one of those would
|
|
374
|
+
* be exactly the squatting problem that band separation exists to prevent;
|
|
375
|
+
* conversely an unrelated process that merely happens to mention a
|
|
376
|
+
* matching-looking port is never a target either. */
|
|
377
|
+
export async function discoverBandProcesses(options = {}) {
|
|
378
|
+
const listProcesses = options.listProcesses ?? defaultListProcesses;
|
|
379
|
+
const viceBin = resolveViceBinForReap(options.viceBin);
|
|
380
|
+
const basePort = resolveBasePortForReap(options.basePort);
|
|
381
|
+
const entries = await listProcesses();
|
|
382
|
+
return entries.filter((entry) => entry.args.includes(viceBin) && argsNamePortAtOrAbove(entry.args, basePort));
|
|
383
|
+
}
|
|
384
|
+
function defaultListInstanceDirs(stateDir) {
|
|
385
|
+
let names;
|
|
386
|
+
try {
|
|
387
|
+
names = readdirSync(stateDir, { withFileTypes: true })
|
|
388
|
+
.filter((entry) => entry.isDirectory())
|
|
389
|
+
.map((entry) => entry.name);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
return names.filter((name) => /^\d+$/.test(name)).map(Number);
|
|
395
|
+
}
|
|
396
|
+
function readExistingEpochFieldsMaybe(path) {
|
|
397
|
+
let raw;
|
|
398
|
+
try {
|
|
399
|
+
raw = readFileSync(path, "utf8");
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
const parsed = JSON.parse(raw);
|
|
406
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
407
|
+
return parsed;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
/* malformed -- treated as "nothing to preserve", matching nextEpochFor()'s
|
|
412
|
+
* own never-throw posture */
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
/** Bumps a single in-band instance directory's epoch by exactly one
|
|
417
|
+
* (via the injected nextEpochFor(), the SAME derivation superviseChild()
|
|
418
|
+
* uses on every respawn), preserving every OTHER field already on disk when
|
|
419
|
+
* readable so a human inspecting the file afterwards still finds the
|
|
420
|
+
* instance's real spawned_at/pid/vice_bin/log -- and degrading to
|
|
421
|
+
* reasonable placeholders when the directory carries no prior epoch.json at
|
|
422
|
+
* all (the exact case this reap exists to still cover: a directory the
|
|
423
|
+
* broker has no in-memory record of). This bump is what carries the void
|
|
424
|
+
* into the existing MachineRestartedError path (vice.ts's
|
|
425
|
+
* assertSameMachine()) -- no second notion of "recoverable" is invented
|
|
426
|
+
* here. */
|
|
427
|
+
function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
428
|
+
const supervisorDir = join(stateDir, String(port));
|
|
429
|
+
const path = deps.epochPathFor(stateDir, port);
|
|
430
|
+
const existing = readExistingEpochFieldsMaybe(path);
|
|
431
|
+
const nextEpoch = deps.nextEpochFor(supervisorDir);
|
|
432
|
+
const record = {
|
|
433
|
+
epoch: nextEpoch,
|
|
434
|
+
spawned_at: typeof existing?.spawned_at === "string" ? existing.spawned_at : new Date().toISOString(),
|
|
435
|
+
pid: typeof existing?.pid === "number" ? existing.pid : 0,
|
|
436
|
+
supervisor_pid: typeof existing?.supervisor_pid === "number" ? existing.supervisor_pid : process.pid,
|
|
437
|
+
vice_bin: typeof existing?.vice_bin === "string" ? existing.vice_bin : "",
|
|
438
|
+
vice_args: Array.isArray(existing?.vice_args) ? existing.vice_args : [],
|
|
439
|
+
log: typeof existing?.log === "string" ? existing.log : "",
|
|
440
|
+
dry_run: typeof existing?.dry_run === "boolean" ? existing.dry_run : false,
|
|
441
|
+
};
|
|
442
|
+
deps.writeEpochRecord({ supervisorDir, record });
|
|
443
|
+
}
|
|
444
|
+
/** The unconditional startup reap (criterion I, D-15). Runs on every broker
|
|
445
|
+
* start, before the control listener accepts and before anything is
|
|
446
|
+
* launched -- unconditional because a broker killed with SIGKILL never runs
|
|
447
|
+
* a shutdown path, so "was the last shutdown clean" is unanswerable, and a
|
|
448
|
+
* marker file recording that answer would itself be the class of file-based
|
|
449
|
+
* liveness claim this phase retires (consults NO such file; the seam this
|
|
450
|
+
* module offers is the process listing and the on-disk instance
|
|
451
|
+
* directories, nothing else).
|
|
452
|
+
*
|
|
453
|
+
* Enumerates host processes via the injected/real process-listing
|
|
454
|
+
* dependency, selects the two-condition matches (discoverBandProcesses()
|
|
455
|
+
* above), and kills each one identity-verified against the configured
|
|
456
|
+
* emulator binary. Then bumps the epoch of EVERY instance directory under
|
|
457
|
+
* `stateDir` whose port falls in the band -- including directories this
|
|
458
|
+
* broker process has no in-memory record of, which is the exact case this
|
|
459
|
+
* seed (.planning/seeds/broker-restart-reaps-and-voids.md) flags: the void
|
|
460
|
+
* has to reach instances a registry-free restart never heard of.
|
|
461
|
+
*
|
|
462
|
+
* Logs one line naming the count found and the count killed, including the
|
|
463
|
+
* zero case -- both the 2026-08-01 and 2026-08-02 incidents were diagnosed
|
|
464
|
+
* from broker log lines, and a silent reap would be exactly the kind of
|
|
465
|
+
* thing impossible to reconstruct afterwards. */
|
|
466
|
+
export async function reapOrphanedInstances(options) {
|
|
467
|
+
const kill = options.kill ?? verifiedKill;
|
|
468
|
+
const log = options.log ?? defaultLog;
|
|
469
|
+
const viceBin = resolveViceBinForReap(options.viceBin);
|
|
470
|
+
const basePort = resolveBasePortForReap(options.basePort);
|
|
471
|
+
const listInstanceDirs = options.listInstanceDirs ?? defaultListInstanceDirs;
|
|
472
|
+
const matched = await discoverBandProcesses({
|
|
473
|
+
listProcesses: options.listProcesses,
|
|
474
|
+
viceBin,
|
|
475
|
+
basePort,
|
|
476
|
+
});
|
|
477
|
+
let killed = 0;
|
|
478
|
+
for (const entry of matched) {
|
|
479
|
+
const stage = await kill({ pid: entry.pid, expectedIdentity: viceBin });
|
|
480
|
+
if (stage === "sigterm" || stage === "sigkill")
|
|
481
|
+
killed++;
|
|
482
|
+
}
|
|
483
|
+
const ports = listInstanceDirs(options.stateDir);
|
|
484
|
+
for (const port of ports) {
|
|
485
|
+
if (port >= basePort) {
|
|
486
|
+
bumpEpochForInstanceDir(options, options.stateDir, port);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
log(`vice-broker: startup reap found ${matched.length} process(es) in the emulator port band, terminated ${killed}`);
|
|
490
|
+
return { found: matched.length, killed };
|
|
491
|
+
}
|