@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.
@@ -0,0 +1,855 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from vice-broker.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
+ // vice-broker.mts
8
+ //
9
+ // The long-lived host broker entry point (Phase 01.6.2). Extends the Phase
10
+ // 01.6 tracer in place rather than replacing it: parseArgs(),
11
+ // readBrokerRecordMaybe() and the atomic tmp-sibling-then-rename write
12
+ // discipline all survive; main() grows a real control listener, a
13
+ // heartbeat and a real acquire/release path spawning a real child.
14
+ //
15
+ // heartbeat_at is now MANDATORY, refreshed on a recurring timer for as long
16
+ // as this process lives. The tracer's own header comment used to forbid it
17
+ // ("DELIBERATELY OMITS heartbeat_at") because a heartbeat-less record from a
18
+ // write-once tracer that immediately exits would strand every later
19
+ // session's readBrokerLiveness() classification at never_started forever.
20
+ // That reasoning does not apply here: this broker is genuinely long-lived,
21
+ // so omitting heartbeat_at would instead make a REAL, RUNNING broker read
22
+ // as never_started -- exactly the failure this field exists to prevent.
23
+ //
24
+ // Imports node: builtins ONLY plus this phase's own sibling modules --
25
+ // mcp__vice__* stays the only route to the emulator; nothing here opens a
26
+ // connection to it.
27
+ import { readFileSync, mkdirSync, openSync, writeFileSync, chmodSync, renameSync } from "node:fs";
28
+ import { join, basename, resolve as resolvePath } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { spawn as nodeSpawn } from "node:child_process";
31
+ import { containerGuardReport, containerGuardEnforce } from "./container-guard.mjs";
32
+ import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, } from "./broker-state.mjs";
33
+ import { acquirePortAndLaunch, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
34
+ import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
35
+ import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
36
+ import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
37
+ const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
38
+ /** `--repo-root` is required UNLESS `--check-container` is given -- the
39
+ * container guard needs no paths at all, matching the bash launcher's own
40
+ * `--check-container` handling (answered before any path resolution).
41
+ * `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
42
+ * otherwise `.vice-supervisor` under the repo root. */
43
+ export function parseArgs(argv) {
44
+ let repoRoot = null;
45
+ let stateDir = null;
46
+ let checkContainer = false;
47
+ let dryRun = false;
48
+ for (let i = 0; i < argv.length; i++) {
49
+ if (argv[i] === "--repo-root") {
50
+ repoRoot = argv[i + 1] ?? null;
51
+ i++;
52
+ }
53
+ else if (argv[i] === "--state-dir") {
54
+ stateDir = argv[i + 1] ?? null;
55
+ i++;
56
+ }
57
+ else if (argv[i] === "--check-container") {
58
+ checkContainer = true;
59
+ }
60
+ else if (argv[i] === "--dry-run") {
61
+ dryRun = true;
62
+ }
63
+ }
64
+ if (!checkContainer && !repoRoot) {
65
+ throw new Error(USAGE);
66
+ }
67
+ const resolvedStateDir = stateDir ?? process.env.VICE_POOL_DIR ?? (repoRoot ? join(repoRoot, ".vice-supervisor") : ".vice-supervisor");
68
+ return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
69
+ }
70
+ /** The deployed JavaScript broker artifact's own name -- D-26's entire
71
+ * point: this field used to read "vice-broker.sh" (the retiring bash
72
+ * daemon), which was false the moment a real TypeScript broker existed.
73
+ * It now names itself. */
74
+ export const WRITTEN_BY = "vice-broker.mjs";
75
+ // ---------------------------------------------------------------------------
76
+ // Small, locally-duplicated env-var readers (plan 05) -- the SAME pattern
77
+ // broker-kill.mts's own resolveBasePortForReap()/resolveViceBinForReap()
78
+ // already established: this module cannot import broker-launch.mts's
79
+ // PRIVATE resolveWarmFloor()/resolveCeiling() (they are not exported, and
80
+ // this file is already the top-level wiring module value-importing every
81
+ // sibling .mjs directly -- exporting them would widen broker-launch.mts's
82
+ // own surface for a one-line env-var read this file can duplicate exactly
83
+ // as cheaply). Both mirror broker-launch.mts's defaults precisely
84
+ // (VICE_BROKER_WARM_FLOOR/1, VICE_BROKER_MAX/16) so broker.json's config echo
85
+ // and host_state's own answer can never disagree with what maintainWarmFloor
86
+ // itself actually enforces. The floor default dropped from 3 to 1 in
87
+ // 01.6.2.1-03-PLAN.md (D-06) -- BOTH readers changed together in that same
88
+ // commit, deliberately, because this invariant (the two numbers never
89
+ // disagree) breaks silently the moment only one of them moves. The
90
+ // ceiling's own default (16) is untouched by D-06 -- it is the unrun
91
+ // concurrency-ceiling spike's territory, not this phase's.
92
+ // ---------------------------------------------------------------------------
93
+ function resolveWarmFloorForRecord() {
94
+ const raw = process.env.VICE_BROKER_WARM_FLOOR;
95
+ if (raw === undefined || raw === "")
96
+ return 1;
97
+ const n = Number(raw);
98
+ return Number.isFinite(n) ? n : 1;
99
+ }
100
+ function resolveCeilingForRecord() {
101
+ const raw = process.env.VICE_BROKER_MAX;
102
+ if (raw === undefined || raw === "")
103
+ return 16;
104
+ const n = Number(raw);
105
+ return Number.isFinite(n) ? n : 16;
106
+ }
107
+ function resolveViceBinForHostState() {
108
+ return process.env.VICE_BIN ?? "x64sc";
109
+ }
110
+ /** Duplicates vice-broker-client.ts's readBrokerLiveness() classification
111
+ * logic (never_started / stale / alive against BROKER_STALE_MS) rather than
112
+ * importing it -- confirmed empirically (plan 02's own SUMMARY) that
113
+ * importing vice-broker-client.ts into a HOST-BOUND module pulls its
114
+ * transitive dependents (repo-root.ts, install-resources.ts, hostpath.ts)
115
+ * into the SAME tsc build program, which either fails to compile under
116
+ * tsconfig.build.json's allowImportingTsExtensions:false or forces those
117
+ * container-side files to be committed under resources/ as if host-bound.
118
+ * This is the SAME classification a test can drive the REAL
119
+ * readBrokerLiveness() over (broker-control.test.ts does exactly that,
120
+ * against records this function's own caller writes), proving the two never
121
+ * diverge -- this module only needs the classification NAME (never_started
122
+ * / stale / alive), never the pid/heartbeatAt fields readBrokerLiveness()
123
+ * also returns. */
124
+ const BROKER_STALE_MS = Number(process.env.VICE_BROKER_STALE_MS || 180000);
125
+ function classifyBrokerLivenessLocal(path) {
126
+ const parsed = readBrokerRecordMaybe(path);
127
+ if (parsed === null)
128
+ return "never_started";
129
+ const heartbeatAt = typeof parsed.heartbeat_at === "string" ? parsed.heartbeat_at : null;
130
+ const heartbeatMs = heartbeatAt ? Date.parse(heartbeatAt) : NaN;
131
+ if (!Number.isFinite(heartbeatMs))
132
+ return "never_started";
133
+ return Date.now() - heartbeatMs > BROKER_STALE_MS ? "stale" : "alive";
134
+ }
135
+ function isPlainObject(value) {
136
+ return typeof value === "object" && value !== null && !Array.isArray(value);
137
+ }
138
+ /** Read and parse a broker record, treating anything short of a
139
+ * well-formed object as "not there yet" -- missing file, unreadable file,
140
+ * partial write, malformed JSON, non-object shape. Never throws. */
141
+ export function readBrokerRecordMaybe(path) {
142
+ let raw;
143
+ try {
144
+ raw = readFileSync(path, "utf8");
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ try {
150
+ const parsed = JSON.parse(raw);
151
+ return isPlainObject(parsed) ? parsed : null;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ /** Atomic tmp-sibling -> mode-tighten -> content -> rename, the same
158
+ * choke-point discipline the tracer's own writeBrokerRecord() used, now
159
+ * shared by both the initial write and every heartbeat refresh -- mode
160
+ * stays owner-read-write on EVERY write, refresh included. */
161
+ function writeBrokerRecordFile(stateDir, record) {
162
+ mkdirSync(stateDir, { recursive: true });
163
+ const finalPath = join(stateDir, "broker.json");
164
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
165
+ writeFileSync(tmpPath, "");
166
+ chmodSync(tmpPath, 0o600);
167
+ writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
168
+ renameSync(tmpPath, finalPath);
169
+ return finalPath;
170
+ }
171
+ /** Builds a spawn function that redirects the child's stdout/stderr into a
172
+ * FRESH per-launch log file under logDir (D-23: per-instance boot/crash
173
+ * logs survive under .vice-supervisor/<port>/logs/, same paths, same
174
+ * format as the retiring bash supervisor), returning both the spawn
175
+ * closure and the log's path relative to supervisorDir (the epoch
176
+ * record's own `log` field). Shared by both launch paths -- a cold
177
+ * acquire and warm-floor maintenance -- so there is exactly one place that
178
+ * opens a launch log fd. */
179
+ function makeLoggingSpawn(logDir) {
180
+ mkdirSync(logDir, { recursive: true });
181
+ const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
182
+ const logName = `${viceBinForLog}-${Date.now()}.log`;
183
+ const logFd = openSync(join(logDir, logName), "a");
184
+ return {
185
+ spawn: (cmd, cmdArgs) => nodeSpawn(cmd, cmdArgs, { stdio: ["ignore", logFd, logFd] }),
186
+ logRelPath: `logs/${logName}`,
187
+ };
188
+ }
189
+ /** Writes the epoch record for a just-launched instance -- shared by both
190
+ * launch paths so D-04's contract (format, location, atomic-write
191
+ * discipline, all unchanged -- only the writer moves) is discharged from
192
+ * exactly one place regardless of WHY the instance was launched. A
193
+ * granted instance and a still-warm instance are equally real processes; both
194
+ * need a real epoch.json the moment they exist, or plan 04's grant-time
195
+ * re-probe (which reads a warm instance's recorded epoch_file, per
196
+ * grant_from_spare()'s bash original) would carry forward a path to
197
+ * nothing. */
198
+ function writeEpochForLaunch(record, logRelPath) {
199
+ const epochRecord = {
200
+ epoch: 1,
201
+ spawned_at: new Date(record.launchedAt).toISOString(),
202
+ pid: record.pid,
203
+ supervisor_pid: process.pid,
204
+ vice_bin: record.viceBin,
205
+ vice_args: record.viceArgs,
206
+ log: logRelPath,
207
+ dry_run: false,
208
+ };
209
+ writeEpochRecord({ supervisorDir: record.supervisorDir, record: epochRecord });
210
+ // The in-memory record's own epoch field must carry the SAME value the
211
+ // epoch record was just written with -- without this, every
212
+ // first-generation instance reports an absent epoch to the status
213
+ // response and an absent epoch-before in a recycle acknowledgement,
214
+ // making a later respawn's advance unobservable at the one place a
215
+ // caller reads it (handleStatus(), handleRecycleForRealBroker()).
216
+ record.epoch = epochRecord.epoch;
217
+ }
218
+ /** Builds the supervision dependency object for withCrashSupervision(),
219
+ * once per launch, so both real launch paths (handleAcquire here; Task 2's
220
+ * maintainWarmFloorForRealBroker) pass a structurally identical
221
+ * SuperviseChildDeps object into the SAME shared wrapper. Deliberately does
222
+ * NOT set spawnFactory: on a respawn, launchSupervised() (broker-launch.mts)
223
+ * derives its own per-instance log path from instanceLogDirFor and names
224
+ * that same path in the epoch record it writes -- supplying a competing
225
+ * spawn factory here would produce two log files per respawn with the
226
+ * epoch record naming the wrong one. Leaving it unset means a respawn's
227
+ * output lands in the supervision module's own log file under the same
228
+ * per-instance logs directory D-23 requires, and the epoch record names
229
+ * the file that actually received the output. */
230
+ function superviseDepsFor(stateDir, state) {
231
+ return {
232
+ state,
233
+ stateDir,
234
+ epoch: { epochPathFor, instanceLogDirFor, nextEpochFor, writeEpochRecord },
235
+ log: (line) => process.stderr.write(`${line}\n`),
236
+ };
237
+ }
238
+ /** Sets the deliberate-death marker and its respawn-after-kill answer
239
+ * TOGETHER -- the single place in this module that ever writes either
240
+ * field, so a call site can never set one and forget the other, which is
241
+ * the exact shape of the defect this closes (T-01.6.2-80). Called BEFORE
242
+ * any signal reaches the target child in both handlers below, never after:
243
+ * the exit handler (broker-launch.mts) runs on the child's OWN exit event,
244
+ * so a marker set after the signal arrives too late to be read
245
+ * (T-01.6.2-84). */
246
+ function markDeliberateDeath(instance, respawnAfterKill) {
247
+ instance.deliberateKill = true;
248
+ instance.respawnAfterKill = respawnAfterKill;
249
+ }
250
+ /** Walks `state.instances` for probe-live `ready` candidates, in iteration
251
+ * order, and returns the first that answers a grant-time re-probe (P-02) --
252
+ * or `null` once every candidate has been tried and none answered, letting
253
+ * the caller fall through to a cold launch (P-03). Regardless of
254
+ * `record.reason`: per D-07, a waiting request takes an instance whichever
255
+ * reason booted it, so a warm-floor instance and a not-yet-granted instance are
256
+ * equally eligible. Kill-never-recycle needs no separate guard here --
257
+ * handleRelease() below already deletes a released instance's record
258
+ * outright, so a released instance is structurally absent from
259
+ * `state.instances` and can never be a candidate.
260
+ *
261
+ * A candidate whose grant-time probe FAILS is dropped -- de-registered from
262
+ * `state.instances` -- and identity-verified-killed BEFORE the walk
263
+ * continues to the next candidate, but per WR-02
264
+ * (`.planning/todos/pending/2026-08-05-wr-02-*`, decision: fix now rather
265
+ * than defer further) the kill itself is fire-and-forget, matching
266
+ * handleRelease()'s own posture a few hundred lines below
267
+ * (`verifiedKill(...).catch(...)`, never awaited by that call site either):
268
+ * the acquiring request must not wait up to `VICE_BROKER_KILL_WAIT_S`
269
+ * (default 5s) of SIGTERM-then-poll-then-SIGKILL PER DEAD CANDIDATE before
270
+ * the walk can move on -- that wait is exactly what turns a warm floor's
271
+ * fast, in-memory grant into a multi-second serial teardown on a single
272
+ * request's hot path once the warm floor is configured above its default
273
+ * of 1 (WR-02's own bounding condition). The drop -- `markDeliberateDeath()`
274
+ * plus `state.instances.delete()` -- still happens SYNCHRONOUSLY, in the
275
+ * same tick as the probe failure, before `deps.kill(...)` is even invoked;
276
+ * only the kill's own SETTLEMENT is decoupled from this walk. This is
277
+ * WR-02's fix option 1, not option 2 (capping how many failed candidates a
278
+ * single acquire will wait through): option 1 matches an idiom the file
279
+ * already uses elsewhere rather than inventing a new bound, and removes the
280
+ * wait entirely rather than merely capping it. The grant-time-probe-failure
281
+ * log line's own ordering is decoupled accordingly (see below) -- it can no
282
+ * longer name the kill's resolved stage synchronously, since nothing here
283
+ * waits for it to resolve. The marker is set BEFORE any signal reaches the
284
+ * child (markDeliberateDeath()'s own contract), with a FALSE
285
+ * respawn-after-kill answer -- this arm never wants a replacement on the
286
+ * SAME port; a replacement, if any, comes from either the next candidate in
287
+ * this same walk or the caller's own cold-launch fall-through.
288
+ *
289
+ * Re-checks `record.state === "ready"` AND map membership by identity
290
+ * immediately after every `await` (the probe call itself) and BEFORE ever
291
+ * treating a probe-live candidate as the winner -- this is what makes the
292
+ * caller's own "no await between selection and the grant-recording step"
293
+ * property (T-01.6.2.1-03) actually hold under two concurrent acquires. A
294
+ * candidate's own probe response cannot change because a sibling acquire
295
+ * granted it first, but its RECORDED state does, the instant that sibling's
296
+ * synchronous grant step runs -- recorded state alone catches that case.
297
+ * It does NOT catch a sibling that has already DROPPED this exact candidate
298
+ * (a failed grant-time probe: markDeliberateDeath() + state.instances.delete(),
299
+ * which never touches record.state -- the drop path a few lines below) --
300
+ * 01.6.2.1-VERIFICATION.md's CR-01 finding, re-confirmed here: a state-only
301
+ * recheck is blind to a concurrent drop, letting a second caller's stale
302
+ * object reference win a grant for a record that is no longer in
303
+ * state.instances at all, orphaning the grant. Rechecking
304
+ * `state.instances.get(record.port) === record` (identity, not merely a
305
+ * port-number lookup) closes that case too. */
306
+ async function selectWarmInstance(state, deps) {
307
+ for (const record of Array.from(state.instances.values())) {
308
+ if (record.state !== "ready")
309
+ continue;
310
+ const isReady = await deps.probe(record.port);
311
+ // A sibling acquire may have granted OR dropped this exact candidate
312
+ // while this probe was in flight. "Granted" changes record.state;
313
+ // "dropped" removes the record from state.instances outright and never
314
+ // touches record.state -- so map membership must be rechecked too, not
315
+ // merely the state field (CR-01, 01.6.2.1-REVIEW.md/01.6.2.1-VERIFICATION.md).
316
+ if (record.state !== "ready" || state.instances.get(record.port) !== record) {
317
+ continue;
318
+ }
319
+ if (isReady) {
320
+ return record;
321
+ }
322
+ // Drop and de-register FIRST, synchronously, before the kill is even
323
+ // invoked -- this is what CR-01's identity recheck above depends on:
324
+ // the record must already be gone from state.instances by the time a
325
+ // concurrent sibling's own probe on this same candidate resolves.
326
+ // WR-02 only changes what happens to the kill's own PROMISE next, never
327
+ // this ordering.
328
+ markDeliberateDeath(record, false);
329
+ state.instances.delete(record.port);
330
+ // Distinct wording from shutdown()'s own "shutdown complete" line
331
+ // (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
332
+ // path -- D-07's standing constraint that a lifecycle decision must be
333
+ // reconstructable from the log after an incident (both 2026-08-01 and
334
+ // 2026-08-02 were diagnosed from broker log lines). Logged BEFORE the
335
+ // kill settles (WR-02): the walk does not wait for deps.kill(...) to
336
+ // resolve, so this line can no longer name the kill's resolved stage --
337
+ // that gets its own, separately-logged line once the kill settles,
338
+ // below.
339
+ deps.log(`vice-broker: grant-time probe failed for port ${record.port} (pid ${record.pid ?? "null"}) -- dropped the record and kicked off an identity-verified kill of the pid (not awaited by the acquire walk, WR-02)`);
340
+ // Fire-and-forget, matching handleRelease()'s own posture
341
+ // (`verifiedKill(...).catch(...)`, a few hundred lines below in this
342
+ // same file) -- the acquire walk moves on to the next candidate (or
343
+ // returns null to the cold-launch fall-through) without waiting up to
344
+ // VICE_BROKER_KILL_WAIT_S per dead candidate. Still identity-verified:
345
+ // this is the SAME deps.kill, never replaced by a bare, unverified
346
+ // signal. The settlement is only OBSERVED asynchronously, via its own
347
+ // log line, never awaited.
348
+ void deps
349
+ .kill({ pid: record.pid, expectedIdentity: record.expectedIdentity })
350
+ .then((killStage) => {
351
+ deps.log(`vice-broker: grant-time-probe-failure kill for port ${record.port} (pid ${record.pid ?? "null"}) settled (kill stage: ${killStage})`);
352
+ })
353
+ .catch(() => {
354
+ // best-effort; nothing further to report on this path, matching
355
+ // handleRelease()'s own posture at its own verifiedKill(...).catch(...) call site.
356
+ });
357
+ }
358
+ return null;
359
+ }
360
+ /** Resolves a GRANTABLE instance -- a cold launch is one of two ways of
361
+ * obtaining one, not the only one (this task's own assumption-delta
362
+ * decision: "resolve a grantable instance" is now the primary operation).
363
+ * The warm-instance selection arm (selectWarmInstance(), P-01) runs BEFORE
364
+ * the cold-launch arm; `atCapacity()` gates ONLY the cold-launch arm --
365
+ * checked only once selectWarmInstance() has already answered `null` (no
366
+ * probe-live candidate available) -- NOT before either arm (WR-01,
367
+ * 01.6.2.1-REVIEW.md). A full host still refuses a fresh cold launch before
368
+ * ever touching the port allocator, but a ready, probe-live warm candidate
369
+ * is grantable even when the ceiling is already reached: granting it
370
+ * creates no NEW instance and does not raise `countTotal()`, so refusing to
371
+ * hand out an already-existing idle one was a real availability bug, not a
372
+ * correct interpretation of the ceiling's own purpose (bounding concurrent
373
+ * emulator *processes*, not bounding how many of those processes may be
374
+ * *handed out*). Both arms converge on exactly ONE `state.grants.set()`
375
+ * call -- load-bearing for task 2's structural anti-regression gate, which
376
+ * counts it -- fed by whichever arm produced a record. Answers the full
377
+ * discriminated AcquireOutcome (plan 05): `at_capacity` when the ceiling is
378
+ * already reached AND no warm candidate could be served,
379
+ * `no_free_port`/`launch_in_flight` passed straight through from
380
+ * acquirePortAndLaunch()'s own typed failure (the cold arm only), and
381
+ * `internal` only for a genuine, otherwise-unclassified fault. A
382
+ * `launch_in_flight` outcome is NOT a control-plane error -- broker-
383
+ * control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
384
+ * retry it later rather than refusing it. */
385
+ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
386
+ const probe = deps.probe ?? ((port) => probeReady(port));
387
+ // Textually a verifiedKill( call site, not merely a reference -- reused
388
+ // UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
389
+ // re-derived, and never replaced by a bare process.kill().
390
+ const kill = deps.kill ?? ((opts) => verifiedKill(opts));
391
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
392
+ const winner = await selectWarmInstance(state, { probe, kill, log });
393
+ let record;
394
+ if (winner) {
395
+ record = winner;
396
+ }
397
+ else if (atCapacity(state)) {
398
+ return { ok: false, reason: "at_capacity" };
399
+ }
400
+ else {
401
+ // acquirePortAndLaunch() holds the single in_flight owner across its own
402
+ // async port allocation (not merely tryLaunchOne()'s synchronous spawn
403
+ // instant) -- see that function's own header comment for the race this
404
+ // closes between a cold acquire and a concurrent warm-floor pass. This
405
+ // is also what restores vice-broker.sh's own process_requests() throttle:
406
+ // a cold acquire that arrives while ANY launch (cold or warm) is already
407
+ // under way is queued here (plan 05), matching the bash original's
408
+ // declined-to-change behaviour of never racing a second instance into
409
+ // existence, but answered LATER instead of refused outright.
410
+ let lastLogRelPath = "";
411
+ const result = await acquirePortAndLaunch("acquire", {
412
+ state,
413
+ stateDir,
414
+ allocatePort: nextFreePort,
415
+ spawnFactory: deps.buildColdSpawnFactory ??
416
+ ((port) => {
417
+ const supervisorDir = join(stateDir, String(port));
418
+ const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
419
+ lastLogRelPath = logRelPath;
420
+ return withCrashSupervision("acquire", port, spawn, superviseDepsFor(stateDir, state));
421
+ }),
422
+ });
423
+ if (!result.ok) {
424
+ return { ok: false, reason: result.reason };
425
+ }
426
+ if (result.record.pid === null) {
427
+ // WR-03 (01.6.2.1-REVIEW.md): the spawn never forked a real process
428
+ // (e.g. a bad VICE_BIN path), so there is nothing to signal -- the
429
+ // fix is deleting the just-created broken record alone. Without this,
430
+ // a configuration failure would silently occupy a port slot and count
431
+ // toward countTotal()/atCapacity() until crash supervision's own
432
+ // delayed respawn/give-up machinery eventually noticed and freed it,
433
+ // even though the caller was already told "internal" right now.
434
+ state.instances.delete(result.record.port);
435
+ return { ok: false, reason: "internal" };
436
+ }
437
+ record = result.record;
438
+ // Only the cold-launch arm ever writes a FRESH epoch record -- the warm
439
+ // arm's winner already has one, written when it was warmed
440
+ // (maintainWarmFloorForRealBroker()'s own onLaunched hook), and
441
+ // rewriting it here would advance an epoch no restart caused, which the
442
+ // container-side assertSameMachine() would read as a machine change.
443
+ writeEpochForLaunch(record, lastLogRelPath);
444
+ }
445
+ // THE single grant-recording step, fed by both arms above -- no `await`
446
+ // between resolving `record` (whichever arm produced it) and this
447
+ // synchronous pair, so two concurrent acquires can never both grant the
448
+ // SAME record (T-01.6.2.1-03; see selectWarmInstance()'s own re-check for
449
+ // the other half of that guarantee).
450
+ state.grants.set(requestId, { id: requestId, port: record.port, grantedAt: Date.now(), pid: record.pid });
451
+ record.state = "granted";
452
+ return {
453
+ ok: true,
454
+ grant: { port: record.port, url: record.url, epochFile: record.epochFile, supervisorDir: record.supervisorDir },
455
+ };
456
+ }
457
+ /** Answers the `status` control-plane request: one entry per instance,
458
+ * computed on demand from the SAME in-memory map every other count reads --
459
+ * strictly better than the dropped broker-instances.json projection, which
460
+ * could go stale between passes (D-24). */
461
+ function handleStatus(state) {
462
+ return Array.from(state.instances.values()).map((r) => ({
463
+ port: r.port,
464
+ url: r.url,
465
+ state: r.state,
466
+ reason: r.reason,
467
+ epoch: typeof r.epoch === "number" ? r.epoch : null,
468
+ }));
469
+ }
470
+ /** Resolves a recycle target's emulator child pid from THIS broker's own
471
+ * in-memory instance record -- record.pid is, by construction, exactly the
472
+ * same value broker-epoch.mts's writer puts in epoch.json's own `pid` field
473
+ * (both are set from the same spawned child's own pid at launch time, and
474
+ * both are updated together on every respawn) -- so reading it here is
475
+ * reading "the epoch record's pid", never the supervising broker's own
476
+ * process.pid (T-01.6.2-17; there is no intermediate supervisor process in
477
+ * this topology at all, per broker-kill.mts's own header comment). A
478
+ * recycle's OWNERSHIP check (does this connection hold this grant) already
479
+ * happened in broker-control.mts before this function is ever called -- this
480
+ * function only resolves, marks and kills.
481
+ *
482
+ * Marks the death as broker-ordered AND to be replaced, with a TRUE
483
+ * respawn-after-kill answer, BEFORE the kill -- the actual replacement is
484
+ * then carried out by the per-child supervision exit handler
485
+ * (broker-launch.mts's handleExit(), wired in by plan 12) on the SAME port,
486
+ * asynchronously, after this function has already returned its own
487
+ * acknowledgement. This is exactly what the tool description's own "via the
488
+ * host supervisor's existing respawn loop" wording describes: this function
489
+ * marks and kills; the respawn loop is the exit handler, not this function.
490
+ * Neither the grant nor the instance entry is deleted here -- the grant is
491
+ * what keeps the recycled port belonging to this same session, and the
492
+ * instance entry is what the exit handler reads to decide the relaunch;
493
+ * both must still exist once this function returns for the exit handler to
494
+ * have anything to act on. */
495
+ async function handleRecycleForRealBroker(targetId, state) {
496
+ const grant = state.grants.get(targetId);
497
+ if (!grant) {
498
+ return {
499
+ port: null,
500
+ pid: null,
501
+ viceBin: null,
502
+ killStage: "no_signal",
503
+ epochBefore: null,
504
+ outcome: "grant_lookup_failed",
505
+ reason: `no grant record found for target ${targetId}`,
506
+ };
507
+ }
508
+ const instance = state.instances.get(grant.port);
509
+ if (!instance) {
510
+ return {
511
+ port: grant.port,
512
+ pid: null,
513
+ viceBin: null,
514
+ killStage: "no_signal",
515
+ epochBefore: null,
516
+ outcome: "epoch_lookup_failed",
517
+ reason: `no resolvable epoch record for target ${targetId} (port ${grant.port})`,
518
+ };
519
+ }
520
+ if (instance.pid === null) {
521
+ return {
522
+ port: instance.port,
523
+ pid: null,
524
+ viceBin: instance.viceBin,
525
+ killStage: "no_signal",
526
+ epochBefore: typeof instance.epoch === "number" ? instance.epoch : null,
527
+ outcome: "pid_lookup_failed",
528
+ reason: `epoch record carries no pid for target ${targetId}`,
529
+ };
530
+ }
531
+ const epochBefore = typeof instance.epoch === "number" ? instance.epoch : null;
532
+ markDeliberateDeath(instance, true);
533
+ const killStage = await verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity });
534
+ const outcome = killStage === "identity_refused" ? "identity_refused" : "ok";
535
+ const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
536
+ return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
537
+ }
538
+ /** The warm-floor concern of the fixed-order evaluation pass (D-24 drops
539
+ * the projection write; the grant sweep does not appear -- D-12's
540
+ * connection-is-the-lease). Builds a fresh MaintainWarmFloorDeps per call
541
+ * (never reused across passes) wiring broker-state.mjs's real
542
+ * allocatePort/counts and broker-launch.mjs's real probeReady, and hooks
543
+ * onLaunched to write the SAME epoch record a cold acquire writes -- a
544
+ * warm instance is a real process the moment it exists, per D-04.
545
+ *
546
+ * WR-04 (01.6.2.1-REVIEW.md): the log-path stash below is a LOCAL variable,
547
+ * declared fresh once per call to THIS function -- exactly mirroring how
548
+ * handleAcquire()'s own equivalent cold-launch log-path variable
549
+ * (`lastLogRelPath`) is already scoped locally rather than to the module.
550
+ * Both the write site (the spawn-wrapping closure) and the read site (the
551
+ * `onLaunched` callback) live inside this SAME function body, so this is a
552
+ * pure relocation with no behavioural change -- it removes the
553
+ * cross-call-sharing risk a module-level `let` carried (correct only
554
+ * because of invariants -- at most one launch per call, never invoked
555
+ * concurrently with itself -- enforced elsewhere and never checked at the
556
+ * point the variable used to be declared). */
557
+ function maintainWarmFloorForRealBroker(stateDir, state) {
558
+ let lastWarmLaunchLogRelPath = "";
559
+ return maintainWarmFloor({
560
+ state,
561
+ stateDir,
562
+ spawnFactory: (port) => {
563
+ const supervisorDir = join(stateDir, String(port));
564
+ const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
565
+ const stashingSpawn = (cmd, args) => {
566
+ const child = spawn(cmd, args);
567
+ // Stash the log path where onLaunched (fired synchronously right
568
+ // after this returns, still within the SAME maintainWarmFloor()
569
+ // call -- at most one launch per call, per the serialised-warming
570
+ // invariant) can find it. withCrashSupervision() below composes
571
+ // AROUND this function, so the stash still runs (and still
572
+ // completes before onLaunched reads it) before the exit listener
573
+ // is ever attached.
574
+ lastWarmLaunchLogRelPath = logRelPath;
575
+ return child;
576
+ };
577
+ return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state));
578
+ },
579
+ probe: (port) => probeReady(port),
580
+ allocatePort: nextFreePort,
581
+ countReady,
582
+ countTotal,
583
+ countLaunching,
584
+ onLaunched: (record) => {
585
+ writeEpochForLaunch(record, lastWarmLaunchLogRelPath);
586
+ },
587
+ log: (line) => process.stderr.write(`${line}\n`),
588
+ });
589
+ }
590
+ /** Releases a grant and identity-verified-kills its instance -- but ONLY
591
+ * when the port's CURRENT occupant is proven to be the SAME process this
592
+ * grant was actually issued for (its own recorded `pid`, set at grant time
593
+ * by handleAcquire()'s single state.grants.set() call site), not merely
594
+ * "whatever now holds this port number." This is Task 2's own closure of
595
+ * CR-01's cross-session-kill blast radius (T-01.6.2.1-28): even after Task
596
+ * 1 closes the specific concurrent-acquire race, this lookup was ALREADY
597
+ * unsafe against any OTHER event that swaps a port's occupant without also
598
+ * clearing the grant -- the clearest independent example being an ordinary
599
+ * (non-deliberate) crash of a GRANTED instance that hits the give-up
600
+ * threshold: broker-launch.mts's handleExit() deletes the record from
601
+ * state.instances regardless of record.state, freeing the port for
602
+ * nextFreePort() to hand to a brand-new, unrelated cold launch, while the
603
+ * original grant sits untouched in state.grants.
604
+ *
605
+ * On a pid MATCH: unchanged from before this task -- marks the death as
606
+ * broker-ordered with a FALSE respawn-after-kill answer BEFORE the kill
607
+ * (the opposite answer from the recycle handler above, since a release
608
+ * wants no replacement), deletes the instance entry (harmless double-delete
609
+ * if the exit handler's own final-death branch also runs), and
610
+ * fire-and-forget identity-verified-kills it.
611
+ *
612
+ * On a pid MISMATCH -- including when there is no instance at all at that
613
+ * port: the grant's own bookkeeping is still removed (a release always
614
+ * retires its OWN request's bookkeeping), but the mismatched CURRENT
615
+ * occupant is left running, untouched -- neither deleted nor signalled in
616
+ * any way -- and a distinct log line names the request id, the port, the
617
+ * grant's own recorded pid, and the current occupant's pid (or "none" when
618
+ * the port is empty), worded distinctly from both the shutdown-complete
619
+ * line (broker-kill.mts) and the grant-time-probe-failure line this same
620
+ * file already emits (D-07's standing constraint that a lifecycle decision
621
+ * must be reconstructable from the log after an incident).
622
+ *
623
+ * A legitimate recycle (broker-launch.mts's handleExit() recycle branch)
624
+ * keeps this grant's `pid` in sync with the respawned record's own pid, so
625
+ * this check never misfires against a recycled instance the grant still
626
+ * legitimately owns. */
627
+ export function handleRelease(requestId, state) {
628
+ const grant = state.grants.get(requestId);
629
+ if (!grant)
630
+ return;
631
+ const instance = state.instances.get(grant.port);
632
+ if (instance && instance.pid === grant.pid) {
633
+ markDeliberateDeath(instance, false);
634
+ state.grants.delete(requestId);
635
+ state.instances.delete(grant.port);
636
+ verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
637
+ // best-effort; nothing further to report on this path this task
638
+ });
639
+ return;
640
+ }
641
+ // Stale/orphaned grant: the port's current occupant (if any) is NOT the
642
+ // same process this grant was issued for. Retire the grant's own
643
+ // bookkeeping only -- the mismatched occupant, if any, is left running.
644
+ state.grants.delete(requestId);
645
+ process.stderr.write(`vice-broker: release for request ${requestId} found a different instance at port ${grant.port} than the one this grant was issued for ` +
646
+ `(grant pid ${grant.pid ?? "null"}, current occupant pid ${instance ? instance.pid ?? "null" : "none"}) -- the grant's own bookkeeping was retired, ` +
647
+ `and the current occupant was left untouched\n`);
648
+ }
649
+ async function run(args) {
650
+ const finalPath = join(args.stateDir, "broker.json");
651
+ // Plan 05 (criterion K, D-17): the tracer/plan-04-era "refuse to overwrite
652
+ // a record naming a currently-live pid" pre-check is GONE -- REPLACED by
653
+ // the bind-before-write singleton guard below, not merely extended
654
+ // alongside it (this phase's own plan-time note is explicit: the
655
+ // refuse-to-clobber heuristic is replaced, not extended). That old check
656
+ // read broker.json's OWN recorded pid and asked "is that process alive" --
657
+ // a heuristic that can never tell "a live broker legitimately holds this
658
+ // port" apart from "a live but unrelated process happens to share a pid
659
+ // number with a stale record" (pids get reused). The kernel-enforced bind
660
+ // below asks the ONLY question that actually matters -- "is the control
661
+ // port itself already held" -- and broker.json becomes a pure ARBITER of
662
+ // that question's two possible causes, never a gate in its own right.
663
+ //
664
+ // D-25: the mandatory start-time banner, printed unconditionally and
665
+ // BEFORE anything else in this function runs -- an operator must be told
666
+ // what a Ctrl-C costs before there is anything running for them to Ctrl-C.
667
+ process.stderr.write(`${startupBanner()}\n`);
668
+ const state = createBrokerState();
669
+ const token = newControlToken();
670
+ const controlHost = process.env.VICE_BROKER_CONTROL_HOST ?? "0.0.0.0";
671
+ const startedAt = new Date().toISOString(); // FIXED across every heartbeat refresh -- see writeBrokerRecordFile()'s callers below
672
+ const pollMs = Number(process.env.VICE_BROKER_POLL_MS) || 500;
673
+ const controlPort = resolveControlPort();
674
+ // Criterion I / D-15: the unconditional startup reap runs BEFORE the
675
+ // control listener accepts and before anything is launched. A SIGKILLed
676
+ // prior broker never ran a shutdown path, so this is the only place the
677
+ // "every emulator this project's port band could be squatting is either
678
+ // ours or a human's own work" guarantee can be enforced -- no marker file
679
+ // is consulted, per this reap's own header comment in broker-kill.mts.
680
+ //
681
+ // NOTE (plan 05): this reap runs UNCONDITIONALLY, before the bind attempt
682
+ // below -- including for a process that goes on to LOSE the singleton
683
+ // race a moment later (see the EADDRINUSE handling below). That ordering
684
+ // is D-15's own, already established and tested by plan 04
685
+ // (broker-kill.test.ts's own structural source-order check); this task
686
+ // does not change it. A losing second broker's own reap pass is an
687
+ // accepted, pre-existing consequence of "the reap is unconditional" --
688
+ // not something the singleton guard below is required to prevent.
689
+ await reapOrphanedInstances({
690
+ stateDir: args.stateDir,
691
+ epochPathFor,
692
+ nextEpochFor,
693
+ writeEpochRecord,
694
+ });
695
+ // D-18: the singleton guarantee holds only while the control port keeps its default -- two brokers deliberately configured onto different ports are two brokers, and no code prevents that.
696
+ let listener;
697
+ try {
698
+ listener = await startControlListener({
699
+ host: controlHost,
700
+ port: controlPort,
701
+ token,
702
+ onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state),
703
+ onRelease: (requestId) => handleRelease(requestId, state),
704
+ onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
705
+ onStatus: () => handleStatus(state),
706
+ onHostState: () => ({
707
+ pid: process.pid,
708
+ startedAt,
709
+ nodeVersion: process.version,
710
+ viceBin: resolveViceBinForHostState(),
711
+ warmFloor: resolveWarmFloorForRecord(),
712
+ maxInstances: resolveCeilingForRecord(),
713
+ basePort: resolveBasePort(),
714
+ }),
715
+ });
716
+ }
717
+ catch (e) {
718
+ // Criterion K / D-17 / D-18: CR-01 closes here. A well-known TCP port
719
+ // cannot be bound twice, so EADDRINUSE is the kernel enforcing the
720
+ // singleton -- but the guarantee holds only while the control port
721
+ // keeps its default (two brokers deliberately configured onto
722
+ // DIFFERENT ports are two brokers, and no code here or anywhere else
723
+ // prevents that). On EADDRINUSE, broker.json arbitrates via the SAME
724
+ // never_started/stale/alive classification vice-broker-client.ts's
725
+ // readBrokerLiveness() uses (duplicated locally above -- see
726
+ // classifyBrokerLivenessLocal()'s own header comment for why this
727
+ // cannot be a value import), and takes exactly one of two DISTINCT
728
+ // paths: a record classified alive means this process lost a genuine
729
+ // race against a live broker -- exit quietly, status 0, as designed.
730
+ // A record classified stale or never_started means the port is held by
731
+ // something that does not answer as a broker at all -- fail loudly,
732
+ // naming the port and what to check. Conflating these two would let a
733
+ // squatted port masquerade as a healthy singleton, permanently and
734
+ // silently (T-01.6.2-34). Neither path writes the discovery record,
735
+ // launches an instance, or reaps again -- both simply exit.
736
+ const err = e;
737
+ if (err.code === "EADDRINUSE") {
738
+ const liveness = classifyBrokerLivenessLocal(finalPath);
739
+ if (liveness === "alive") {
740
+ process.stderr.write(`vice-broker: another broker is already running and holds control port ${controlPort} -- exiting quietly as a second instance (record: ${finalPath})\n`);
741
+ process.exitCode = 0;
742
+ return;
743
+ }
744
+ process.stderr.write(`vice-broker: FATAL -- control port ${controlPort} is held by something that does not answer as a broker (discovery record classified "${liveness}"). ` +
745
+ `Check what is bound to port ${controlPort} on the host (e.g. \`lsof -i :${controlPort}\` or \`ss -ltnp\`) before restarting. Record: ${finalPath}\n`);
746
+ process.exitCode = 1;
747
+ return;
748
+ }
749
+ process.stderr.write(`vice-broker: failed to start control listener: ${err.message}\n`);
750
+ process.exitCode = 1;
751
+ return;
752
+ }
753
+ // C5: every catchable shutdown path (SIGTERM/SIGINT/SIGHUP, an uncaught
754
+ // exception, an unhandled rejection, normal exit) converges on ONE
755
+ // re-entrant-safe teardown that identity-verified-kills every instance
756
+ // this broker launched and clears the map unconditionally
757
+ // (kill-never-recycle). Registered once the listener is up, since there is
758
+ // nothing to tear down before that point.
759
+ registerShutdownHandlers({ state });
760
+ // A successful bind writes the record UNCONDITIONALLY, overwriting
761
+ // whatever was there -- the bind itself is the proof of singleton status
762
+ // (D-17). The fourteen-field set (D-27, criterion G): the lease
763
+ // time-to-live field the bash original carried is gone -- the connection
764
+ // is the lease now (D-12) -- and every other config-echo field survives
765
+ // even though no consumer parses it beyond a status message, because a
766
+ // human reading this file by hand benefits from the full echo.
767
+ let record = {
768
+ version: 1,
769
+ written_by: WRITTEN_BY,
770
+ pid: process.pid,
771
+ started_at: startedAt,
772
+ heartbeat_at: new Date().toISOString(),
773
+ node_version: process.version,
774
+ control_host: listener.host,
775
+ control_port: listener.port,
776
+ control_token: token, // never logged -- T-01.6.2-02
777
+ warm_floor: resolveWarmFloorForRecord(),
778
+ max_instances: resolveCeilingForRecord(),
779
+ base_port: resolveBasePort(),
780
+ poll_ms: pollMs,
781
+ dry_run: args.dryRun,
782
+ };
783
+ writeBrokerRecordFile(args.stateDir, record);
784
+ process.stderr.write(`vice-broker: wrote ${finalPath} (node ${record.node_version}); control listener bound on ${listener.host}:${listener.port}\n`);
785
+ const heartbeatMs = Number(process.env.VICE_BROKER_HEARTBEAT_MS) || 30000;
786
+ setInterval(() => {
787
+ // The refresh path goes through the SAME atomic tmp-then-rename choke
788
+ // point as the initial write (writeBrokerRecordFile() itself), and the
789
+ // mode is tightened to owner-read-write on EVERY write, refresh
790
+ // included -- never only on the first.
791
+ record = { ...record, heartbeat_at: new Date().toISOString() };
792
+ writeBrokerRecordFile(args.stateDir, record);
793
+ }, heartbeatMs);
794
+ // The fixed-order evaluation pass (runBrokerPass, broker-launch.mts):
795
+ // serve pending acquires, then maintain the warm floor -- mirroring
796
+ // vice-broker.sh's own broker_once() ordering. Ticks on
797
+ // VICE_BROKER_POLL_MS (default 500, the SAME env var name and semantics
798
+ // the bash daemon used). serveAcquires now drains the arrival-ordered
799
+ // pending-acquire structure this listener instance owns (D-08's
800
+ // mechanism; plan 02's own `serveAcquires: () => {}` comment reserved
801
+ // exactly this room) -- an acquire queued because a launch was already in
802
+ // flight is retried here, on the SAME pass that also maintains the warm
803
+ // floor, so a stalled pass shows up as a stale record rather than a
804
+ // silently wrong one. Re-entrancy guarded: a pass that is still running
805
+ // (e.g. a slow readiness probe against a genuinely slow host) is never
806
+ // overlapped by the next tick.
807
+ let passInFlight = false;
808
+ setInterval(() => {
809
+ if (passInFlight)
810
+ return;
811
+ passInFlight = true;
812
+ runBrokerPass({
813
+ serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
814
+ maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state),
815
+ })
816
+ .catch((e) => {
817
+ process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
818
+ })
819
+ .finally(() => {
820
+ passInFlight = false;
821
+ });
822
+ }, pollMs);
823
+ }
824
+ /** Parses argv, evaluates the container guard FIRST -- before any state
825
+ * directory is read or written and before anything is spawned (PD-03) --
826
+ * then runs the long-lived broker. Never calls process.exit(); always sets
827
+ * process.exitCode so pending I/O flushes first. */
828
+ export function main(argv = process.argv.slice(2)) {
829
+ let args;
830
+ try {
831
+ args = parseArgs(argv);
832
+ }
833
+ catch (e) {
834
+ process.stderr.write(`${e.message}\n`);
835
+ process.exitCode = 1;
836
+ return;
837
+ }
838
+ if (args.checkContainer) {
839
+ process.exitCode = containerGuardReport();
840
+ return;
841
+ }
842
+ const guardRc = containerGuardEnforce();
843
+ if (guardRc !== 0) {
844
+ process.exitCode = guardRc;
845
+ return;
846
+ }
847
+ run(args).catch((e) => {
848
+ process.stderr.write(`vice-broker: ${e.message}\n`);
849
+ process.exitCode = 1;
850
+ });
851
+ }
852
+ // -------------------------------------------------------------------- CLI
853
+ if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url)) {
854
+ main();
855
+ }