@henols/vice-mcp 0.2.3 → 0.2.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/anno-cli.ts +156 -158
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-export-asm.ts +100 -101
- package/anno-graphics.ts +16 -16
- package/anno-hazard-report.ts +2 -2
- package/anno-import.ts +15 -15
- package/anno-index.ts +8 -8
- package/anno-join.ts +35 -35
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +4 -4
- package/anno-regbits-gen.ts +13 -13
- package/anno-store-export.ts +11 -11
- package/anno-store.ts +139 -144
- package/anno-symbols.ts +7 -7
- package/anno-types.ts +55 -55
- package/package.json +1 -1
- package/resources/broker-control.mjs +85 -92
- package/resources/broker-epoch.mjs +6 -7
- package/resources/broker-kill.mjs +29 -30
- package/resources/broker-launch.mjs +352 -370
- package/resources/broker-state.mjs +9 -10
- package/resources/host-tool.mjs +636 -664
- package/resources/vice-broker.mjs +189 -191
- package/vice-broker-client.ts +98 -100
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// rebuild.
|
|
7
7
|
// vice-broker.mts
|
|
8
8
|
//
|
|
9
|
-
// The long-lived host broker entry point
|
|
10
|
-
//
|
|
9
|
+
// The long-lived host broker entry point. Extends an earlier write-once
|
|
10
|
+
// tracer script in place rather than replacing it: parseArgs(),
|
|
11
11
|
// readBrokerRecordMaybe() and the atomic tmp-sibling-then-rename write
|
|
12
12
|
// discipline all survive; main() grows a real control listener, a
|
|
13
13
|
// heartbeat and a real acquire/release path spawning a real child.
|
|
@@ -29,23 +29,26 @@ import { join, basename, resolve as resolvePath } from "node:path";
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
31
31
|
import { containerGuardReport, containerGuardEnforce } from "./container-guard.mjs";
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
32
|
+
// countReady/countTotal/countLaunching are DROPPED from this import -- they
|
|
33
|
+
// were used only as maintainWarmFloorForRealBroker()'s own deps for the
|
|
34
|
+
// now-retired maintainWarmFloor() (the warm floor itself was retired once
|
|
35
|
+
// the connection became the lease, with no separate expiry left to
|
|
36
|
+
// maintain), passed through by shorthand property (`countReady,` etc.),
|
|
37
|
+
// never called directly in this file. atCapacity() is the one survivor
|
|
38
|
+
// actually called here (its own cold-launch-arm gate, below).
|
|
38
39
|
import { createBrokerState, nextFreePort, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
|
|
39
40
|
import { acquirePortAndLaunch, deleteInstanceRecord,
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// Replaces maintainWarmFloor -- the warm floor itself is retired (the
|
|
42
|
+
// connection is the lease now, so there is no separate floor left to
|
|
43
|
+
// keep warm); this is ONLY the launching -> ready promotion sweep the
|
|
44
|
+
// floor used to carry as its own step 1.
|
|
43
45
|
promoteLaunchingInstances, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// (type-only) re-import of it and this file's VALUE
|
|
48
|
-
// same one home. A real value import is safe here
|
|
46
|
+
// resolvedBackend() resolves the emulator binary's identity -- ViceBackend's
|
|
47
|
+
// own definition lives in backend-detect.mts too (narrowed to a single
|
|
48
|
+
// literal now that the fork backend has been removed entirely), so
|
|
49
|
+
// broker-launch.mjs's own (type-only) re-import of it and this file's VALUE
|
|
50
|
+
// import both name the same one home. A real value import is safe here
|
|
51
|
+
// (unlike inside
|
|
49
52
|
// broker-launch.mts) because vice-broker.mts is ALWAYS run from its own
|
|
50
53
|
// compiled resources/ form -- both modules are compiled together in the
|
|
51
54
|
// same build.ts pass, so "./backend-detect.mjs" always exists as a real
|
|
@@ -53,19 +56,18 @@ promoteLaunchingInstances, probeReady, runBrokerPass, withCrashSupervision, } fr
|
|
|
53
56
|
import { resolvedBackend } from "./backend-detect.mjs";
|
|
54
57
|
import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
|
|
55
58
|
import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
|
|
56
|
-
//
|
|
57
|
-
//
|
|
59
|
+
// A VALUE import of the host-tool executor -- safe here for the SAME reason
|
|
60
|
+
// every other sibling value import above is:
|
|
58
61
|
// this file is ALWAYS run from its own compiled resources/ form, and
|
|
59
62
|
// "./host-tool.mjs" is compiled into that same directory by the same build.ts
|
|
60
63
|
// pass (host-tool.mts is added to HOST_BOUND_ARTIFACTS/tsconfig.build.json's
|
|
61
64
|
// include[] in this same commit).
|
|
62
65
|
import { runHostTool } from "./host-tool.mjs";
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// listed in HOST_BOUND_ARTIFACTS, since plan 40-08 landed the module).
|
|
66
|
+
// A VALUE import of the same handle-minting function for the SAME reason as
|
|
67
|
+
// the host-tool.mjs import immediately above -- this file is always run
|
|
68
|
+
// from its own compiled resources/ form, and "./ghidra-project.mjs" is
|
|
69
|
+
// compiled into that same directory by the same build.ts pass (both source
|
|
70
|
+
// and target are already listed in HOST_BOUND_ARTIFACTS).
|
|
69
71
|
import { ensureGhidraRunsHandle } from "./ghidra-project.mjs";
|
|
70
72
|
import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
|
|
71
73
|
const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
|
|
@@ -73,8 +75,8 @@ const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [-
|
|
|
73
75
|
* container guard needs no paths at all, matching the bash launcher's own
|
|
74
76
|
* `--check-container` handling (answered before any path resolution).
|
|
75
77
|
* `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
|
|
76
|
-
* otherwise `.c64-re-tools/supervisor` under the repo root (moved 2026-09-08
|
|
77
|
-
*
|
|
78
|
+
* otherwise `.c64-re-tools/supervisor` under the repo root (moved 2026-09-08
|
|
79
|
+
* from `.vice-supervisor`; the three-tier chain itself -- explicit
|
|
78
80
|
* `--state-dir`, then `VICE_POOL_DIR`, then this default -- is unchanged,
|
|
79
81
|
* only the default's location moved). This module is host-bound and compiled
|
|
80
82
|
* by `build.ts`, so it must not import the container-side `repo-root.ts`;
|
|
@@ -108,10 +110,9 @@ export function parseArgs(argv) {
|
|
|
108
110
|
(repoRoot ? join(repoRoot, ".c64-re-tools", "supervisor") : join(".c64-re-tools", "supervisor"));
|
|
109
111
|
return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
|
|
110
112
|
}
|
|
111
|
-
/** The deployed JavaScript broker artifact's own name
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* It now names itself. */
|
|
113
|
+
/** The deployed JavaScript broker artifact's own name. This field used to
|
|
114
|
+
* read "vice-broker.sh" (the retiring bash daemon), which was false the
|
|
115
|
+
* moment a real TypeScript broker existed. It now names itself. */
|
|
115
116
|
export const WRITTEN_BY = "vice-broker.mjs";
|
|
116
117
|
// ---------------------------------------------------------------------------
|
|
117
118
|
// Small, locally-duplicated env-var reader (plan 05) -- the SAME pattern
|
|
@@ -123,11 +124,11 @@ export const WRITTEN_BY = "vice-broker.mjs";
|
|
|
123
124
|
// env-var read this file can duplicate exactly as cheaply). Mirrors
|
|
124
125
|
// broker-launch.mts's own default precisely (VICE_BROKER_MAX/16) so
|
|
125
126
|
// broker.json's config echo and host_state's own answer can never disagree
|
|
126
|
-
// with what atCapacity() itself actually enforces.
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
127
|
+
// with what atCapacity() itself actually enforces. This used to be a PAIR
|
|
128
|
+
// with resolveWarmFloorForRecord() (VICE_BROKER_WARM_FLOOR/1), kept in
|
|
129
|
+
// lockstep with broker-launch.mts's own matching pair so the two numbers
|
|
130
|
+
// could never disagree. The warm-floor half of that pair is RETIRED along
|
|
131
|
+
// with the floor itself -- the
|
|
131
132
|
// ceiling's own default (16) is untouched, since it is a separate concern
|
|
132
133
|
// (VICE_BROKER_MAX / atCapacity()) this plan does not touch.
|
|
133
134
|
// ---------------------------------------------------------------------------
|
|
@@ -203,21 +204,21 @@ function writeBrokerRecordFile(stateDir, record) {
|
|
|
203
204
|
return finalPath;
|
|
204
205
|
}
|
|
205
206
|
/** Builds a spawn function that redirects the child's stdout/stderr into a
|
|
206
|
-
* FRESH per-launch log file under logDir (
|
|
207
|
-
*
|
|
207
|
+
* FRESH per-launch log file under logDir (so per-instance boot/crash logs
|
|
208
|
+
* survive under .c64-re-tools/supervisor/<port>/logs/, same paths, same
|
|
208
209
|
* format as the retiring bash supervisor), returning both the spawn
|
|
209
210
|
* closure and the log's path relative to supervisorDir (the epoch
|
|
210
211
|
* record's own `log` field). Shared by both launch paths -- a cold
|
|
211
212
|
* acquire and warm-floor maintenance -- so there is exactly one place that
|
|
212
213
|
* opens a launch log fd.
|
|
213
214
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
215
|
+
* The returned `spawn` now also forwards a caller options object, MERGING
|
|
216
|
+
* it into the object handed to nodeSpawn() -- caller options spread FIRST,
|
|
217
|
+
* `stdio` set LAST, so the launch log fd always wins over any
|
|
218
|
+
* caller-supplied `stdio`. Merging in the other order would silently
|
|
219
|
+
* redirect a launch's output away from the per-instance log file the
|
|
220
|
+
* epoch record names, breaking the per-instance forensic logs while
|
|
221
|
+
* appearing to work. */
|
|
221
222
|
function makeLoggingSpawn(logDir) {
|
|
222
223
|
mkdirSync(logDir, { recursive: true });
|
|
223
224
|
const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
|
|
@@ -229,8 +230,9 @@ function makeLoggingSpawn(logDir) {
|
|
|
229
230
|
};
|
|
230
231
|
}
|
|
231
232
|
/** Writes the epoch record for a just-launched instance -- shared by both
|
|
232
|
-
* launch paths so
|
|
233
|
-
* discipline, all unchanged -- only the writer moves) is
|
|
233
|
+
* launch paths so the epoch record's own contract (format, location,
|
|
234
|
+
* atomic-write discipline, all unchanged -- only the writer moves) is
|
|
235
|
+
* discharged from
|
|
234
236
|
* exactly one place regardless of WHY the instance was launched. A
|
|
235
237
|
* granted instance and a still-warm instance are equally real processes; both
|
|
236
238
|
* need a real epoch.json the moment they exist, or plan 04's grant-time
|
|
@@ -259,19 +261,20 @@ function writeEpochForLaunch(record, logRelPath) {
|
|
|
259
261
|
}
|
|
260
262
|
/** Builds the supervision dependency object for withCrashSupervision(),
|
|
261
263
|
* once per launch, so the real launch path (handleAcquire's own cold arm,
|
|
262
|
-
* here --
|
|
263
|
-
*
|
|
264
|
-
* object into the shared wrapper.
|
|
265
|
-
* NOT set spawnFactory: on a respawn, launchSupervised()
|
|
266
|
-
* derives its own per-instance log path from
|
|
267
|
-
* that same path in the epoch record it writes
|
|
268
|
-
* spawn factory here would produce two log files
|
|
269
|
-
* epoch record naming the wrong one. Leaving it unset
|
|
270
|
-
* output lands in the supervision module's own log file
|
|
271
|
-
* per-instance logs directory
|
|
272
|
-
* the file that actually received the
|
|
264
|
+
* here -- the second real launch path this comment used to name, the warm
|
|
265
|
+
* floor, was retired once the connection became the lease) passes a
|
|
266
|
+
* structurally identical SuperviseChildDeps object into the shared wrapper.
|
|
267
|
+
* Deliberately does NOT set spawnFactory: on a respawn, launchSupervised()
|
|
268
|
+
* (broker-launch.mts) derives its own per-instance log path from
|
|
269
|
+
* instanceLogDirFor and names that same path in the epoch record it writes
|
|
270
|
+
* -- supplying a competing spawn factory here would produce two log files
|
|
271
|
+
* per respawn with the epoch record naming the wrong one. Leaving it unset
|
|
272
|
+
* means a respawn's output lands in the supervision module's own log file
|
|
273
|
+
* under the same per-instance logs directory the epoch record already
|
|
274
|
+
* requires, and the epoch record names the file that actually received the
|
|
275
|
+
* output.
|
|
273
276
|
*
|
|
274
|
-
*
|
|
277
|
+
* `backend` is a REQUIRED positional parameter, not an
|
|
275
278
|
* optional field a call site may quietly omit. Before this, both real call
|
|
276
279
|
* sites built their deps here WITHOUT it, so `spawnAndRecordInstance()`'s own
|
|
277
280
|
* unset-parameter default silently took over the moment crash supervision
|
|
@@ -301,8 +304,8 @@ function superviseDepsFor(stateDir, state, backend, binmonHost) {
|
|
|
301
304
|
/** Exported ONLY so a test can install withCrashSupervision() through the
|
|
302
305
|
* REAL deps object this module actually uses in production, rather than a
|
|
303
306
|
* hand-built SuperviseChildDeps that can (and did) diverge from it -- the
|
|
304
|
-
* exact blind spot
|
|
305
|
-
* respawn/recycle tests each construct their deps inline and therefore pass
|
|
307
|
+
* exact blind spot the backend-argv bug above lived in: broker-launch.test.ts's
|
|
308
|
+
* own respawn/recycle tests each construct their deps inline and therefore pass
|
|
306
309
|
* `backend: "stock"` directly, so the production builder's missing field was
|
|
307
310
|
* invisible to the whole suite. Same discipline as broker-kill.mts's
|
|
308
311
|
* `_HANDLED_SIGNALS`: an underscore-prefixed alias, never called by any
|
|
@@ -321,8 +324,7 @@ function markDeliberateDeath(instance, respawnAfterKill) {
|
|
|
321
324
|
instance.respawnAfterKill = respawnAfterKill;
|
|
322
325
|
}
|
|
323
326
|
// ---------------------------------------------------------------------------
|
|
324
|
-
//
|
|
325
|
-
// PROFILE-ELIGIBILITY rule.
|
|
327
|
+
// THE WARM-INSTANCE PROFILE-ELIGIBILITY RULE.
|
|
326
328
|
//
|
|
327
329
|
// THE DECISION, stated out loud because two of the three available answers
|
|
328
330
|
// are wrong in ways the CALLER CANNOT DETECT:
|
|
@@ -332,7 +334,7 @@ function markDeliberateDeath(instance, respawnAfterKill) {
|
|
|
332
334
|
// - Serve the request with the mismatched instance -> the caller asked for
|
|
333
335
|
// warp, got an unwarped machine, and received a confident grant. The knob
|
|
334
336
|
// is a lie and nothing in the response says so.
|
|
335
|
-
// -
|
|
337
|
+
// - What this implements: the mismatched instance is INELIGIBLE. The
|
|
336
338
|
// walk skips it and the acquire falls through to the cold arm, which
|
|
337
339
|
// launches a DEDICATED instance for that grant.
|
|
338
340
|
//
|
|
@@ -364,34 +366,32 @@ export function profileEligible(record, requested) {
|
|
|
364
366
|
/** Walks `state.instances` for probe-live `ready` candidates, in iteration
|
|
365
367
|
* order, and returns the first that answers a grant-time re-probe (P-02) --
|
|
366
368
|
* or `null` once every candidate has been tried and none answered, letting
|
|
367
|
-
* the caller fall through to a cold launch
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
369
|
+
* the caller fall through to a cold launch. Regardless of `record.reason`:
|
|
370
|
+
* a waiting request takes an instance whichever reason booted it, so a
|
|
371
|
+
* warm-floor instance and a not-yet-granted instance are equally eligible.
|
|
372
|
+
* Kill-never-recycle needs no separate guard here --
|
|
371
373
|
* handleRelease() below already deletes a released instance's record
|
|
372
374
|
* outright, so a released instance is structurally absent from
|
|
373
375
|
* `state.instances` and can never be a candidate.
|
|
374
376
|
*
|
|
375
377
|
* A candidate whose grant-time probe FAILS is dropped -- de-registered from
|
|
376
378
|
* `state.instances` -- and identity-verified-killed BEFORE the walk
|
|
377
|
-
* continues to the next candidate, but
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* the
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* request's hot path once the warm floor is configured above its default
|
|
387
|
-
* of 1 (WR-02's own bounding condition). The drop -- `markDeliberateDeath()`
|
|
379
|
+
* continues to the next candidate, but the kill itself is deliberately
|
|
380
|
+
* fire-and-forget, matching handleRelease()'s own posture a few hundred
|
|
381
|
+
* lines below (`verifiedKill(...).catch(...)`, never awaited by that call
|
|
382
|
+
* site either): the acquiring request must not wait up to
|
|
383
|
+
* `VICE_BROKER_KILL_WAIT_S` (default 5s) of SIGTERM-then-poll-then-SIGKILL
|
|
384
|
+
* PER DEAD CANDIDATE before the walk can move on -- that wait is exactly
|
|
385
|
+
* what turns a warm floor's fast, in-memory grant into a multi-second
|
|
386
|
+
* serial teardown on a single request's hot path once the warm floor is
|
|
387
|
+
* configured above its default of 1. The drop -- `markDeliberateDeath()`
|
|
388
388
|
* plus `state.instances.delete()` -- still happens SYNCHRONOUSLY, in the
|
|
389
389
|
* same tick as the probe failure, before `deps.kill(...)` is even invoked;
|
|
390
|
-
* only the kill's own SETTLEMENT is decoupled from this walk. This
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
390
|
+
* only the kill's own SETTLEMENT is decoupled from this walk. This matches
|
|
391
|
+
* an idiom the file already uses elsewhere rather than inventing a new
|
|
392
|
+
* bound, and removes the wait entirely rather than merely capping it, by
|
|
393
|
+
* design: capping how many failed candidates a single acquire will wait
|
|
394
|
+
* through was the alternative considered and rejected. The grant-time-probe-failure
|
|
395
395
|
* log line's own ordering is decoupled accordingly (see below) -- it can no
|
|
396
396
|
* longer name the kill's resolved stage synchronously, since nothing here
|
|
397
397
|
* waits for it to resolve. The marker is set BEFORE any signal reaches the
|
|
@@ -404,24 +404,23 @@ export function profileEligible(record, requested) {
|
|
|
404
404
|
* immediately after every `await` (the probe call itself) and BEFORE ever
|
|
405
405
|
* treating a probe-live candidate as the winner -- this is what makes the
|
|
406
406
|
* caller's own "no await between selection and the grant-recording step"
|
|
407
|
-
* property
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* state.instances at all, orphaning the grant. Rechecking
|
|
407
|
+
* property actually hold under two concurrent acquires. A candidate's own
|
|
408
|
+
* probe response cannot change because a sibling acquire granted it first,
|
|
409
|
+
* but its RECORDED state does, the instant that sibling's synchronous grant
|
|
410
|
+
* step runs -- recorded state alone catches that case. It does NOT catch a
|
|
411
|
+
* sibling that has already DROPPED this exact candidate (a failed
|
|
412
|
+
* grant-time probe: markDeliberateDeath() + state.instances.delete(), which
|
|
413
|
+
* never touches record.state -- the drop path a few lines below) -- a
|
|
414
|
+
* state-only recheck is blind to a concurrent drop, letting a second
|
|
415
|
+
* caller's stale object reference win a grant for a record that is no
|
|
416
|
+
* longer in state.instances at all, orphaning the grant. Rechecking
|
|
418
417
|
* `state.instances.get(record.port) === record` (identity, not merely a
|
|
419
418
|
* port-number lookup) closes that case too. */
|
|
420
419
|
async function selectWarmInstance(state, deps) {
|
|
421
420
|
for (const record of Array.from(state.instances.values())) {
|
|
422
421
|
if (record.state !== "ready")
|
|
423
422
|
continue;
|
|
424
|
-
//
|
|
423
|
+
// A SYNCHRONOUS `continue`, sitting
|
|
425
424
|
// immediately beside the `record.state !== "ready"` filter directly
|
|
426
425
|
// above and BEFORE the readiness probe below. That placement is
|
|
427
426
|
// load-bearing twice over, and neither reason is stylistic:
|
|
@@ -448,7 +447,7 @@ async function selectWarmInstance(state, deps) {
|
|
|
448
447
|
// while this probe was in flight. "Granted" changes record.state;
|
|
449
448
|
// "dropped" removes the record from state.instances outright and never
|
|
450
449
|
// touches record.state -- so map membership must be rechecked too, not
|
|
451
|
-
// merely the state field
|
|
450
|
+
// merely the state field.
|
|
452
451
|
if (record.state !== "ready" || state.instances.get(record.port) !== record) {
|
|
453
452
|
continue;
|
|
454
453
|
}
|
|
@@ -456,27 +455,27 @@ async function selectWarmInstance(state, deps) {
|
|
|
456
455
|
return record;
|
|
457
456
|
}
|
|
458
457
|
// Drop and de-register FIRST, synchronously, before the kill is even
|
|
459
|
-
// invoked -- this is what
|
|
460
|
-
//
|
|
461
|
-
// concurrent sibling's own probe on this same candidate resolves.
|
|
462
|
-
//
|
|
463
|
-
// this ordering.
|
|
458
|
+
// invoked -- this is what the identity recheck above depends on: the
|
|
459
|
+
// record must already be gone from state.instances by the time a
|
|
460
|
+
// concurrent sibling's own probe on this same candidate resolves. The
|
|
461
|
+
// fire-and-forget kill below only changes what happens to the kill's
|
|
462
|
+
// own PROMISE next, never this ordering.
|
|
464
463
|
markDeliberateDeath(record, false);
|
|
465
|
-
//
|
|
464
|
+
// Dropping a record is also where its second
|
|
466
465
|
// (`-remotemonitor`) port stops being spoken for -- deleteInstanceRecord()
|
|
467
466
|
// is the ONE place both mutations happen together, so a drop can never
|
|
468
467
|
// leak a port out of the fixed allocation band.
|
|
469
468
|
deleteInstanceRecord(state, record.port);
|
|
470
469
|
// Distinct wording from shutdown()'s own "shutdown complete" line
|
|
471
470
|
// (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
|
|
472
|
-
// path --
|
|
471
|
+
// path -- the standing constraint that a lifecycle decision must be
|
|
473
472
|
// reconstructable from the log after an incident (both 2026-08-01 and
|
|
474
473
|
// 2026-08-02 were diagnosed from broker log lines). Logged BEFORE the
|
|
475
|
-
// kill settles
|
|
474
|
+
// kill settles: the walk does not wait for deps.kill(...) to
|
|
476
475
|
// resolve, so this line can no longer name the kill's resolved stage --
|
|
477
476
|
// that gets its own, separately-logged line once the kill settles,
|
|
478
477
|
// below.
|
|
479
|
-
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
|
|
478
|
+
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)`);
|
|
480
479
|
// Fire-and-forget, matching handleRelease()'s own posture
|
|
481
480
|
// (`verifiedKill(...).catch(...)`, a few hundred lines below in this
|
|
482
481
|
// same file) -- the acquire walk moves on to the next candidate (or
|
|
@@ -503,8 +502,8 @@ async function selectWarmInstance(state, deps) {
|
|
|
503
502
|
* The warm-instance selection arm (selectWarmInstance(), P-01) runs BEFORE
|
|
504
503
|
* the cold-launch arm; `atCapacity()` gates ONLY the cold-launch arm --
|
|
505
504
|
* checked only once selectWarmInstance() has already answered `null` (no
|
|
506
|
-
* probe-live candidate available) -- NOT before either arm
|
|
507
|
-
*
|
|
505
|
+
* probe-live candidate available) -- NOT before either arm. A full host
|
|
506
|
+
* still refuses a fresh cold launch before
|
|
508
507
|
* ever touching the port allocator, but a ready, probe-live warm candidate
|
|
509
508
|
* is grantable even when the ceiling is already reached: granting it
|
|
510
509
|
* creates no NEW instance and does not raise `countTotal()`, so refusing to
|
|
@@ -523,14 +522,14 @@ async function selectWarmInstance(state, deps) {
|
|
|
523
522
|
* control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
|
|
524
523
|
* retry it later rather than refusing it. */
|
|
525
524
|
export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
526
|
-
//
|
|
525
|
+
// The readiness probe is backend-aware, from the SAME threaded-down
|
|
527
526
|
// verdict handleAcquire already uses for buildViceArgs() -- on stock the port
|
|
528
527
|
// speaks the binary monitor, so an HTTP POST there can never succeed.
|
|
529
528
|
const backend = deps.backend ?? "stock";
|
|
530
529
|
const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
|
|
531
530
|
// Textually a verifiedKill( call site, not merely a reference -- reused
|
|
532
|
-
// UNCHANGED from broker-kill.mts
|
|
533
|
-
//
|
|
531
|
+
// UNCHANGED from broker-kill.mts, never re-derived, and never replaced by
|
|
532
|
+
// a bare process.kill().
|
|
534
533
|
const kill = deps.kill ?? ((opts) => verifiedKill(opts));
|
|
535
534
|
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
536
535
|
const winner = await selectWarmInstance(state, { probe, kill, log, requestedProfile: deps.profile });
|
|
@@ -556,13 +555,13 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
556
555
|
state,
|
|
557
556
|
stateDir,
|
|
558
557
|
allocatePort: nextFreePort,
|
|
559
|
-
//
|
|
558
|
+
// The SAME local `backend` const resolved at the
|
|
560
559
|
// top of this function feeds BOTH the initial argv (here) and the
|
|
561
560
|
// supervision deps below, so a crash-respawn of this instance can never
|
|
562
561
|
// build a different backend's argv than the launch it replaces.
|
|
563
562
|
backend,
|
|
564
563
|
allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
|
|
565
|
-
//
|
|
564
|
+
// The profile the warm arm just
|
|
566
565
|
// refused to compromise on reaches buildViceArgs() here, and is
|
|
567
566
|
// mirrored onto the fresh InstanceRecord by spawnAndRecordInstance()
|
|
568
567
|
// in the SAME step -- so this instance's recorded profile and its real
|
|
@@ -579,7 +578,7 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
579
578
|
}),
|
|
580
579
|
});
|
|
581
580
|
if (!result.ok) {
|
|
582
|
-
//
|
|
581
|
+
// `result.reason` passes
|
|
583
582
|
// straight through -- `AcquireLaunchResult`'s reason union
|
|
584
583
|
// ("launch_in_flight" | "no_free_port" | "no_free_text_port") is a
|
|
585
584
|
// subset of `AcquireOutcome`'s, so a failed text-port allocation's own
|
|
@@ -589,30 +588,29 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
589
588
|
return { ok: false, reason: result.reason };
|
|
590
589
|
}
|
|
591
590
|
if (result.record.pid === null) {
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
// reached at all).
|
|
591
|
+
// The spawn never forked a real process (e.g. a bad VICE_BIN path),
|
|
592
|
+
// so there is nothing to signal -- the fix is deleting the
|
|
593
|
+
// just-created broken record alone. Without this, a configuration
|
|
594
|
+
// failure would silently occupy a port slot and count toward
|
|
595
|
+
// countTotal()/atCapacity() until crash supervision's own delayed
|
|
596
|
+
// respawn/give-up machinery eventually noticed and freed it, even
|
|
597
|
+
// though the caller was already told "internal" right now.
|
|
598
|
+
// deleteInstanceRecord(), not a bare map delete -- a stock launch that
|
|
599
|
+
// failed this way already had its second port allocated and blocked
|
|
600
|
+
// by acquirePortAndLaunch(), and deleteInstanceRecord() hands that
|
|
601
|
+
// second port back to the allocator (via state.blockedPorts) in the
|
|
602
|
+
// SAME step as it removes the broken record -- this branch is reached
|
|
603
|
+
// only once a record already exists, i.e. only once BOTH allocations
|
|
604
|
+
// already succeeded (a failed second allocation now fails the acquire
|
|
605
|
+
// before any record -- and before this `pid === null` check -- is
|
|
606
|
+
// ever reached at all).
|
|
609
607
|
deleteInstanceRecord(state, result.record.port);
|
|
610
608
|
return { ok: false, reason: "internal" };
|
|
611
609
|
}
|
|
612
610
|
record = result.record;
|
|
613
611
|
// Only the cold-launch arm ever writes a FRESH epoch record here --
|
|
614
|
-
// selectWarmInstance()'s own winner already has one.
|
|
615
|
-
//
|
|
612
|
+
// selectWarmInstance()'s own winner already has one. WHY that is true
|
|
613
|
+
// changed without changing that it IS true: a
|
|
616
614
|
// ready, ungranted candidate no longer comes from a warm-floor pass's
|
|
617
615
|
// own onLaunched hook (retired along with the floor) -- it comes from
|
|
618
616
|
// broker-launch.mts's own crash-supervision respawn path
|
|
@@ -626,7 +624,7 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
626
624
|
// THE single grant-recording step, fed by both arms above -- no `await`
|
|
627
625
|
// between resolving `record` (whichever arm produced it) and this
|
|
628
626
|
// synchronous pair, so two concurrent acquires can never both grant the
|
|
629
|
-
// SAME record (
|
|
627
|
+
// SAME record (see selectWarmInstance()'s own re-check for
|
|
630
628
|
// the other half of that guarantee).
|
|
631
629
|
state.grants.set(requestId, { id: requestId, port: record.port, grantedAt: Date.now(), pid: record.pid });
|
|
632
630
|
record.state = "granted";
|
|
@@ -637,7 +635,7 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
637
635
|
url: record.url,
|
|
638
636
|
epochFile: record.epochFile,
|
|
639
637
|
supervisorDir: record.supervisorDir,
|
|
640
|
-
//
|
|
638
|
+
// Key omitted entirely when the record has none --
|
|
641
639
|
// the fork case, and (until a later plan closes the port-allocation
|
|
642
640
|
// degrade path) a stock instance whose second port allocation itself
|
|
643
641
|
// failed. Same key-omitted-when-undefined idiom
|
|
@@ -649,7 +647,7 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
649
647
|
/** Answers the `status` control-plane request: one entry per instance,
|
|
650
648
|
* computed on demand from the SAME in-memory map every other count reads --
|
|
651
649
|
* strictly better than the dropped broker-instances.json projection, which
|
|
652
|
-
* could go stale between passes
|
|
650
|
+
* could go stale between passes. */
|
|
653
651
|
function handleStatus(state) {
|
|
654
652
|
return Array.from(state.instances.values()).map((r) => ({
|
|
655
653
|
port: r.port,
|
|
@@ -657,9 +655,9 @@ function handleStatus(state) {
|
|
|
657
655
|
state: r.state,
|
|
658
656
|
reason: r.reason,
|
|
659
657
|
epoch: typeof r.epoch === "number" ? r.epoch : null,
|
|
660
|
-
//
|
|
658
|
+
// "at least one channel is claimed" -- promoted from
|
|
661
659
|
// a single-field check, byte-identical wire shape, meaning stated
|
|
662
|
-
// explicitly
|
|
660
|
+
// explicitly.
|
|
663
661
|
hasMonitorClient: Object.keys(r.monitorClients).length > 0,
|
|
664
662
|
}));
|
|
665
663
|
}
|
|
@@ -667,17 +665,16 @@ function handleStatus(state) {
|
|
|
667
665
|
* handleRelease() and handleRecycleForRealBroker() already resolve theirs:
|
|
668
666
|
* `targetId` is a grant id, looked up in state.grants for its port, then
|
|
669
667
|
* the instance at that port. Returns `null` for an unknown target_id/port
|
|
670
|
-
* so callers answer `bad_request`, never `internal
|
|
671
|
-
* acceptance criterion). */
|
|
668
|
+
* so callers answer `bad_request`, never `internal`. */
|
|
672
669
|
function resolveInstanceForMonitorTarget(targetId, state) {
|
|
673
670
|
const grant = state.grants.get(targetId);
|
|
674
671
|
if (!grant)
|
|
675
672
|
return null;
|
|
676
673
|
return state.instances.get(grant.port) ?? null;
|
|
677
674
|
}
|
|
678
|
-
/** Answers `monitor_claim` (
|
|
679
|
-
*
|
|
680
|
-
*
|
|
675
|
+
/** Answers `monitor_claim` (per-channel): exclusive monitor-socket
|
|
676
|
+
* ownership enforced HERE, broker-side, PER CHANNEL, so a conflicting
|
|
677
|
+
* claim is refused by name
|
|
681
678
|
* before any second `connect()` is ever attempted -- the one state stock
|
|
682
679
|
* VICE cannot report and no client-side heuristic can diagnose. `targetId`
|
|
683
680
|
* doubles as both "which instance" (resolved via the SAME grant lookup
|
|
@@ -705,11 +702,11 @@ export function handleMonitorClaim(requestId, targetId, channel, state) {
|
|
|
705
702
|
}
|
|
706
703
|
return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid, channel } };
|
|
707
704
|
}
|
|
708
|
-
/** Answers `monitor_release` (
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
705
|
+
/** Answers `monitor_release` (per-channel): clears ONLY the named
|
|
706
|
+
* channel's entry, ONLY when `targetId` names that channel's CURRENT
|
|
707
|
+
* holder -- a non-holder is refused, not silently accepted (spoofing a
|
|
708
|
+
* release is a deliberately refused case). A channel with no current
|
|
709
|
+
* holder at all tolerates the
|
|
713
710
|
* release as a success, matching the container-side client's own documented
|
|
714
711
|
* tolerance for releasing a socket the broker already cleared. */
|
|
715
712
|
export function handleMonitorRelease(requestId, targetId, channel, state) {
|
|
@@ -801,10 +798,11 @@ async function handleRecycleForRealBroker(targetId, state) {
|
|
|
801
798
|
const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
|
|
802
799
|
return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
|
|
803
800
|
}
|
|
804
|
-
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
801
|
+
/** The second concern of the fixed-order evaluation pass, RENAMED from the
|
|
802
|
+
* retired warm-floor maintenance function this replaces (the projection
|
|
803
|
+
* write is dropped, and the grant sweep does not appear -- the connection
|
|
804
|
+
* is the lease now, so there is nothing left to sweep for expiry). Unlike
|
|
805
|
+
* the function it
|
|
808
806
|
* replaces, this one never launches anything -- it wires only
|
|
809
807
|
* broker-launch.mjs's real promoteLaunchingInstances() against this
|
|
810
808
|
* broker's own state and the backend-aware readiness probe, so a
|
|
@@ -815,7 +813,7 @@ function promoteLaunchingForRealBroker(state, backend) {
|
|
|
815
813
|
return promoteLaunchingInstances({
|
|
816
814
|
state,
|
|
817
815
|
backend,
|
|
818
|
-
//
|
|
816
|
+
// Same backend-aware probe route as handleAcquire's, from the
|
|
819
817
|
// SAME resolved verdict this function already receives.
|
|
820
818
|
probe: (port) => probeReady(port, { backend }),
|
|
821
819
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
@@ -825,9 +823,9 @@ function promoteLaunchingForRealBroker(state, backend) {
|
|
|
825
823
|
* when the port's CURRENT occupant is proven to be the SAME process this
|
|
826
824
|
* grant was actually issued for (its own recorded `pid`, set at grant time
|
|
827
825
|
* by handleAcquire()'s single state.grants.set() call site), not merely
|
|
828
|
-
* "whatever now holds this port number." This
|
|
829
|
-
*
|
|
830
|
-
*
|
|
826
|
+
* "whatever now holds this port number." This closes a cross-session-kill
|
|
827
|
+
* blast radius: even after the specific concurrent-acquire race above is
|
|
828
|
+
* closed, this lookup was ALREADY
|
|
831
829
|
* unsafe against any OTHER event that swaps a port's occupant without also
|
|
832
830
|
* clearing the grant -- the clearest independent example being an ordinary
|
|
833
831
|
* (non-deliberate) crash of a GRANTED instance that hits the give-up
|
|
@@ -851,7 +849,7 @@ function promoteLaunchingForRealBroker(state, backend) {
|
|
|
851
849
|
* grant's own recorded pid, and the current occupant's pid (or "none" when
|
|
852
850
|
* the port is empty), worded distinctly from both the shutdown-complete
|
|
853
851
|
* line (broker-kill.mts) and the grant-time-probe-failure line this same
|
|
854
|
-
* file already emits (
|
|
852
|
+
* file already emits (the standing constraint that a lifecycle decision
|
|
855
853
|
* must be reconstructable from the log after an incident).
|
|
856
854
|
*
|
|
857
855
|
* A legitimate recycle (broker-launch.mts's handleExit() recycle branch)
|
|
@@ -874,7 +872,7 @@ export function handleRelease(requestId, state) {
|
|
|
874
872
|
// depend on silently continuing to hold.
|
|
875
873
|
clearMonitorClient(instance);
|
|
876
874
|
state.grants.delete(requestId);
|
|
877
|
-
//
|
|
875
|
+
// Kill-never-recycle means this instance is gone for good, so its
|
|
878
876
|
// second (`-remotemonitor`) port must go back to the allocator with it.
|
|
879
877
|
deleteInstanceRecord(state, grant.port);
|
|
880
878
|
verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
|
|
@@ -892,7 +890,7 @@ export function handleRelease(requestId, state) {
|
|
|
892
890
|
}
|
|
893
891
|
async function run(args) {
|
|
894
892
|
const finalPath = join(args.stateDir, "broker.json");
|
|
895
|
-
//
|
|
893
|
+
// An early tracer-era "refuse to overwrite
|
|
896
894
|
// a record naming a currently-live pid" pre-check is GONE -- REPLACED by
|
|
897
895
|
// the bind-before-write singleton guard below, not merely extended
|
|
898
896
|
// alongside it (this phase's own plan-time note is explicit: the
|
|
@@ -905,7 +903,7 @@ async function run(args) {
|
|
|
905
903
|
// port itself already held" -- and broker.json becomes a pure ARBITER of
|
|
906
904
|
// that question's two possible causes, never a gate in its own right.
|
|
907
905
|
//
|
|
908
|
-
//
|
|
906
|
+
// The mandatory start-time banner, printed unconditionally and
|
|
909
907
|
// BEFORE anything else in this function runs -- an operator must be told
|
|
910
908
|
// what a Ctrl-C costs before there is anything running for them to Ctrl-C.
|
|
911
909
|
process.stderr.write(`${startupBanner()}\n`);
|
|
@@ -915,17 +913,17 @@ async function run(args) {
|
|
|
915
913
|
const startedAt = new Date().toISOString(); // FIXED across every heartbeat refresh -- see writeBrokerRecordFile()'s callers below
|
|
916
914
|
const pollMs = Number(process.env.VICE_BROKER_POLL_MS) || 500;
|
|
917
915
|
const controlPort = resolveControlPort();
|
|
918
|
-
//
|
|
916
|
+
// The unconditional startup reap runs BEFORE the
|
|
919
917
|
// control listener accepts and before anything is launched. A SIGKILLed
|
|
920
918
|
// prior broker never ran a shutdown path, so this is the only place the
|
|
921
919
|
// "every emulator this project's port band could be squatting is either
|
|
922
920
|
// ours or a human's own work" guarantee can be enforced -- no marker file
|
|
923
921
|
// is consulted, per this reap's own header comment in broker-kill.mts.
|
|
924
922
|
//
|
|
925
|
-
// NOTE
|
|
923
|
+
// NOTE: this reap runs UNCONDITIONALLY, before the bind attempt
|
|
926
924
|
// below -- including for a process that goes on to LOSE the singleton
|
|
927
925
|
// race a moment later (see the EADDRINUSE handling below). That ordering
|
|
928
|
-
// is
|
|
926
|
+
// is deliberate and already established and tested
|
|
929
927
|
// (broker-kill.test.ts's own structural source-order check); this task
|
|
930
928
|
// does not change it. A losing second broker's own reap pass is an
|
|
931
929
|
// accepted, pre-existing consequence of "the reap is unconditional" --
|
|
@@ -936,7 +934,7 @@ async function run(args) {
|
|
|
936
934
|
nextEpochFor,
|
|
937
935
|
writeEpochRecord,
|
|
938
936
|
});
|
|
939
|
-
//
|
|
937
|
+
// Resolved ONCE here, after the unconditional
|
|
940
938
|
// startup reap and BEFORE the control listener binds -- never re-read per
|
|
941
939
|
// launch, and never called from inside broker-launch.mts's `inFlight`
|
|
942
940
|
// single-owner guard (this call sits entirely outside it; no launch is
|
|
@@ -946,14 +944,14 @@ async function run(args) {
|
|
|
946
944
|
// directory repo-root.ts's supervisorDir() would resolve to, without this
|
|
947
945
|
// host-bound module ever importing that container-side resolver directly
|
|
948
946
|
// (backend-detect.mts's own header comment explains why it cannot).
|
|
949
|
-
//
|
|
947
|
+
// There is nothing left to detect -- the resolved
|
|
950
948
|
// `backend` is always `"stock"`; what this call still does is resolve the
|
|
951
949
|
// binary's own identity for the log line below and initialise the
|
|
952
|
-
// capability cache backend-detect.mts's own
|
|
950
|
+
// capability cache backend-detect.mts's own record depends on.
|
|
953
951
|
const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
|
|
954
952
|
const backend = backendResult.backend;
|
|
955
953
|
process.stderr.write(`vice-broker: backend "${backend}" (binary: ${backendResult.binPath})\n`);
|
|
956
|
-
//
|
|
954
|
+
// THE BROKER mints/verifies the
|
|
957
955
|
// Ghidra runs-root handle here -- after the unconditional startup reap
|
|
958
956
|
// above, and BEFORE the control listener below accepts a single
|
|
959
957
|
// connection -- so a container-side MCP server with no host tooling of
|
|
@@ -982,7 +980,7 @@ async function run(args) {
|
|
|
982
980
|
else {
|
|
983
981
|
process.stderr.write(`vice-broker: ghidra runs handle refused: ${ghidraHandleResult.message} -- ghidra.analyze will refuse by name until this is fixed by hand; every other tool id is unaffected\n`);
|
|
984
982
|
}
|
|
985
|
-
//
|
|
983
|
+
// 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.
|
|
986
984
|
let listener;
|
|
987
985
|
try {
|
|
988
986
|
listener = await startControlListener({
|
|
@@ -991,11 +989,11 @@ async function run(args) {
|
|
|
991
989
|
token,
|
|
992
990
|
onAcquire: (requestId, profile) => handleAcquire(requestId, args.stateDir, state, {
|
|
993
991
|
backend,
|
|
994
|
-
//
|
|
992
|
+
// Threaded down to
|
|
995
993
|
// acquirePortAndLaunch()'s own gate (backend === "stock"); this
|
|
996
994
|
// callback does not re-read any environment variable itself.
|
|
997
995
|
allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
|
|
998
|
-
//
|
|
996
|
+
// The ALREADY-NARROWED
|
|
999
997
|
// profile broker-control.mts handed this callback. Nothing here
|
|
1000
998
|
// re-validates it and nothing here reads a raw wire field --
|
|
1001
999
|
// normaliseLaunchProfile() is the single narrowing site, and it ran
|
|
@@ -1005,11 +1003,11 @@ async function run(args) {
|
|
|
1005
1003
|
onRelease: (requestId) => handleRelease(requestId, state),
|
|
1006
1004
|
onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
|
|
1007
1005
|
onStatus: () => handleStatus(state),
|
|
1008
|
-
//
|
|
1006
|
+
// Its OWN callback, wired alongside
|
|
1009
1007
|
// (never derived from) the other six above -- handed only
|
|
1010
1008
|
// `args.repoRoot` and a stderr logger, never this broker's `state` map,
|
|
1011
1009
|
// so it structurally cannot reach lease state through this closure.
|
|
1012
|
-
//
|
|
1010
|
+
// Deliberately supplies no timeout, and that is
|
|
1013
1011
|
// authoritative here, not an omission -- the per-tool budget table
|
|
1014
1012
|
// inside runHostTool()/hostToolTimeoutMs() (host-tool.mts) is the ONE
|
|
1015
1013
|
// place a budget is decided, and no wire field carries one across the
|
|
@@ -1031,7 +1029,7 @@ async function run(args) {
|
|
|
1031
1029
|
viceBin: resolveViceBinForHostState(),
|
|
1032
1030
|
maxInstances: resolveCeilingForRecord(),
|
|
1033
1031
|
basePort: resolveBasePort(),
|
|
1034
|
-
//
|
|
1032
|
+
// The verdict THIS process resolved once, at
|
|
1035
1033
|
// startup, above -- kept on the wire because text-tools.ts's own
|
|
1036
1034
|
// broker-identity cross-check (out of this plan's scope) still reads
|
|
1037
1035
|
// it. Never a second resolvedBackend() call.
|
|
@@ -1040,7 +1038,7 @@ async function run(args) {
|
|
|
1040
1038
|
});
|
|
1041
1039
|
}
|
|
1042
1040
|
catch (e) {
|
|
1043
|
-
//
|
|
1041
|
+
// The singleton race closes here. A well-known TCP port
|
|
1044
1042
|
// cannot be bound twice, so EADDRINUSE is the kernel enforcing the
|
|
1045
1043
|
// singleton -- but the guarantee holds only while the control port
|
|
1046
1044
|
// keeps its default (two brokers deliberately configured onto
|
|
@@ -1075,7 +1073,7 @@ async function run(args) {
|
|
|
1075
1073
|
process.exitCode = 1;
|
|
1076
1074
|
return;
|
|
1077
1075
|
}
|
|
1078
|
-
//
|
|
1076
|
+
// Every catchable shutdown path (SIGTERM/SIGINT/SIGHUP, an uncaught
|
|
1079
1077
|
// exception, an unhandled rejection, normal exit) converges on ONE
|
|
1080
1078
|
// re-entrant-safe teardown that identity-verified-kills every instance
|
|
1081
1079
|
// this broker launched and clears the map unconditionally
|
|
@@ -1083,13 +1081,13 @@ async function run(args) {
|
|
|
1083
1081
|
// nothing to tear down before that point.
|
|
1084
1082
|
registerShutdownHandlers({ state });
|
|
1085
1083
|
// A successful bind writes the record UNCONDITIONALLY, overwriting
|
|
1086
|
-
// whatever was there -- the bind itself is the proof of singleton status
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
// likewise gone (
|
|
1092
|
-
// configured value for) -- and every other config-echo field survives
|
|
1084
|
+
// whatever was there -- the bind itself is the proof of singleton status.
|
|
1085
|
+
// The fourteen-field set (narrowed to thirteen once the warm floor was
|
|
1086
|
+
// retired, then widened back to fourteen to add node_exec_path): the
|
|
1087
|
+
// lease time-to-live field the bash original carried is gone -- the
|
|
1088
|
+
// connection is the lease now, so there is no separate expiry left to
|
|
1089
|
+
// track -- `warm_floor` is likewise gone (there is no warm floor left to
|
|
1090
|
+
// echo a configured value for) -- and every other config-echo field survives
|
|
1093
1091
|
// even though no consumer parses it beyond a status message, because a
|
|
1094
1092
|
// human reading this file by hand benefits from the full echo.
|
|
1095
1093
|
//
|
|
@@ -1128,15 +1126,15 @@ async function run(args) {
|
|
|
1128
1126
|
}, heartbeatMs);
|
|
1129
1127
|
// The fixed-order evaluation pass (runBrokerPass, broker-launch.mts):
|
|
1130
1128
|
// serve pending acquires, then promote launching -> ready -- mirroring
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1129
|
+
// the retiring bash daemon's own broker_once() ordering (the warm floor
|
|
1130
|
+
// this pass used to maintain as its second concern is RETIRED; see
|
|
1131
|
+
// runBrokerPass()'s own comment in broker-launch.mts for what the fixed
|
|
1132
|
+
// order still buys now that only serveAcquires() ever launches anything).
|
|
1133
|
+
// Ticks on VICE_BROKER_POLL_MS (default 500, the SAME env var name and
|
|
1134
|
+
// semantics the bash daemon used). serveAcquires now drains the
|
|
1137
1135
|
// arrival-ordered pending-acquire structure this listener instance owns
|
|
1138
|
-
// (
|
|
1139
|
-
//
|
|
1136
|
+
// (an early stubbed `serveAcquires: () => {}` comment reserved exactly
|
|
1137
|
+
// this room) -- an acquire queued because a launch was
|
|
1140
1138
|
// already in flight is retried here, on the SAME pass that also promotes
|
|
1141
1139
|
// any newly-ready instance, so a stalled pass shows up as a stale record
|
|
1142
1140
|
// rather than a silently wrong one. Re-entrancy guarded: a pass that is
|
|
@@ -1160,8 +1158,8 @@ async function run(args) {
|
|
|
1160
1158
|
}, pollMs);
|
|
1161
1159
|
}
|
|
1162
1160
|
/** Parses argv, evaluates the container guard FIRST -- before any state
|
|
1163
|
-
* directory is read or written and before anything is spawned
|
|
1164
|
-
*
|
|
1161
|
+
* directory is read or written and before anything is spawned -- then
|
|
1162
|
+
* runs the long-lived broker. Never calls process.exit(); always sets
|
|
1165
1163
|
* process.exitCode so pending I/O flushes first. */
|
|
1166
1164
|
export function main(argv = process.argv.slice(2)) {
|
|
1167
1165
|
let args;
|