@henols/vice-mcp 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Compiled by `tsc` from vice-broker.mts. Edit the TypeScript source and rebuild;
|
|
3
3
|
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
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
|
|
5
|
+
// verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
|
|
6
6
|
// rebuild.
|
|
7
7
|
// vice-broker.mts
|
|
8
8
|
//
|
|
@@ -29,26 +29,56 @@ 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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
32
|
+
// Plan 41-05 (folded todo): countReady/countTotal/countLaunching are DROPPED
|
|
33
|
+
// from this import -- they were used only as maintainWarmFloorForRealBroker()'s
|
|
34
|
+
// own deps for the now-retired maintainWarmFloor(), passed through by
|
|
35
|
+
// shorthand property (`countReady,` etc.), never called directly in this
|
|
36
|
+
// file. atCapacity() is the one survivor actually called here (its own
|
|
37
|
+
// cold-launch-arm gate, below).
|
|
38
|
+
import { createBrokerState, nextFreePort, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
|
|
39
|
+
import { acquirePortAndLaunch, deleteInstanceRecord,
|
|
40
|
+
// Plan 41-05 (folded todo): replaces maintainWarmFloor -- the warm floor
|
|
41
|
+
// itself is retired; this is ONLY the launching -> ready promotion sweep
|
|
42
|
+
// the floor used to carry as its own step 1.
|
|
43
|
+
promoteLaunchingInstances, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
|
|
44
|
+
// Plan 02-07: resolvedBackend() resolves the emulator binary's identity --
|
|
45
|
+
// ViceBackend's own definition lives in backend-detect.mts too (narrowed to
|
|
46
|
+
// a single literal by FORKRM-01, plan 52-06), so broker-launch.mjs's own
|
|
47
|
+
// (type-only) re-import of it and this file's VALUE import both name the
|
|
48
|
+
// same one home. A real value import is safe here (unlike inside
|
|
49
|
+
// broker-launch.mts) because vice-broker.mts is ALWAYS run from its own
|
|
50
|
+
// compiled resources/ form -- both modules are compiled together in the
|
|
51
|
+
// same build.ts pass, so "./backend-detect.mjs" always exists as a real
|
|
52
|
+
// sibling file by the time this import resolves.
|
|
42
53
|
import { resolvedBackend } from "./backend-detect.mjs";
|
|
43
54
|
import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
|
|
44
55
|
import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
|
|
56
|
+
// Phase 34, plan 34-01 (SEAM-01): a VALUE import of the host-tool executor --
|
|
57
|
+
// safe here for the SAME reason every other sibling value import above is:
|
|
58
|
+
// this file is ALWAYS run from its own compiled resources/ form, and
|
|
59
|
+
// "./host-tool.mjs" is compiled into that same directory by the same build.ts
|
|
60
|
+
// pass (host-tool.mts is added to HOST_BOUND_ARTIFACTS/tsconfig.build.json's
|
|
61
|
+
// include[] in this same commit).
|
|
62
|
+
import { runHostTool } from "./host-tool.mjs";
|
|
63
|
+
// Gap G-40-1, requirement R2 (plan 40-09): a VALUE import of the same
|
|
64
|
+
// handle-minting function for the SAME reason as the host-tool.mjs import
|
|
65
|
+
// immediately above -- this file is always run from its own compiled
|
|
66
|
+
// resources/ form, and "./ghidra-project.mjs" is compiled into that same
|
|
67
|
+
// directory by the same build.ts pass (both source and target are already
|
|
68
|
+
// listed in HOST_BOUND_ARTIFACTS, since plan 40-08 landed the module).
|
|
69
|
+
import { ensureGhidraRunsHandle } from "./ghidra-project.mjs";
|
|
45
70
|
import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
|
|
46
71
|
const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
|
|
47
72
|
/** `--repo-root` is required UNLESS `--check-container` is given -- the
|
|
48
73
|
* container guard needs no paths at all, matching the bash launcher's own
|
|
49
74
|
* `--check-container` handling (answered before any path resolution).
|
|
50
75
|
* `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
|
|
51
|
-
* otherwise `.
|
|
76
|
+
* otherwise `.c64-re-tools/supervisor` under the repo root (moved 2026-09-08,
|
|
77
|
+
* D-33 -- was `.vice-supervisor`; the three-tier chain itself -- explicit
|
|
78
|
+
* `--state-dir`, then `VICE_POOL_DIR`, then this default -- is unchanged,
|
|
79
|
+
* only the default's location moved). This module is host-bound and compiled
|
|
80
|
+
* by `build.ts`, so it must not import the container-side `repo-root.ts`;
|
|
81
|
+
* the two segments are joined directly, matching that file's `toolsDir()`. */
|
|
52
82
|
export function parseArgs(argv) {
|
|
53
83
|
let repoRoot = null;
|
|
54
84
|
let stateDir = null;
|
|
@@ -73,7 +103,9 @@ export function parseArgs(argv) {
|
|
|
73
103
|
if (!checkContainer && !repoRoot) {
|
|
74
104
|
throw new Error(USAGE);
|
|
75
105
|
}
|
|
76
|
-
const resolvedStateDir = stateDir ??
|
|
106
|
+
const resolvedStateDir = stateDir ??
|
|
107
|
+
process.env.VICE_POOL_DIR ??
|
|
108
|
+
(repoRoot ? join(repoRoot, ".c64-re-tools", "supervisor") : join(".c64-re-tools", "supervisor"));
|
|
77
109
|
return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
|
|
78
110
|
}
|
|
79
111
|
/** The deployed JavaScript broker artifact's own name -- D-26's entire
|
|
@@ -82,30 +114,23 @@ export function parseArgs(argv) {
|
|
|
82
114
|
* It now names itself. */
|
|
83
115
|
export const WRITTEN_BY = "vice-broker.mjs";
|
|
84
116
|
// ---------------------------------------------------------------------------
|
|
85
|
-
// Small, locally-duplicated env-var
|
|
117
|
+
// Small, locally-duplicated env-var reader (plan 05) -- the SAME pattern
|
|
86
118
|
// broker-kill.mts's own resolveBasePortForReap()/resolveViceBinForReap()
|
|
87
119
|
// already established: this module cannot import broker-launch.mts's
|
|
88
|
-
// PRIVATE
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// ceiling's own default (16) is untouched
|
|
100
|
-
//
|
|
120
|
+
// PRIVATE resolveCeiling() (it is not exported, and this file is already
|
|
121
|
+
// the top-level wiring module value-importing every sibling .mjs directly --
|
|
122
|
+
// exporting it would widen broker-launch.mts's own surface for a one-line
|
|
123
|
+
// env-var read this file can duplicate exactly as cheaply). Mirrors
|
|
124
|
+
// broker-launch.mts's own default precisely (VICE_BROKER_MAX/16) so
|
|
125
|
+
// broker.json's config echo and host_state's own answer can never disagree
|
|
126
|
+
// with what atCapacity() itself actually enforces. Plan 41-05 (folded todo):
|
|
127
|
+
// this used to be a PAIR with resolveWarmFloorForRecord() (VICE_BROKER_WARM_
|
|
128
|
+
// FLOOR/1), kept in lockstep with broker-launch.mts's own matching pair
|
|
129
|
+
// (01.6.2.1-03-PLAN.md, D-06) so the two numbers could never disagree. The
|
|
130
|
+
// warm-floor half of that pair is RETIRED along with the floor itself -- the
|
|
131
|
+
// ceiling's own default (16) is untouched, since it is a separate concern
|
|
132
|
+
// (VICE_BROKER_MAX / atCapacity()) this plan does not touch.
|
|
101
133
|
// ---------------------------------------------------------------------------
|
|
102
|
-
function resolveWarmFloorForRecord() {
|
|
103
|
-
const raw = process.env.VICE_BROKER_WARM_FLOOR;
|
|
104
|
-
if (raw === undefined || raw === "")
|
|
105
|
-
return 1;
|
|
106
|
-
const n = Number(raw);
|
|
107
|
-
return Number.isFinite(n) ? n : 1;
|
|
108
|
-
}
|
|
109
134
|
function resolveCeilingForRecord() {
|
|
110
135
|
const raw = process.env.VICE_BROKER_MAX;
|
|
111
136
|
if (raw === undefined || raw === "")
|
|
@@ -179,7 +204,7 @@ function writeBrokerRecordFile(stateDir, record) {
|
|
|
179
204
|
}
|
|
180
205
|
/** Builds a spawn function that redirects the child's stdout/stderr into a
|
|
181
206
|
* FRESH per-launch log file under logDir (D-23: per-instance boot/crash
|
|
182
|
-
* logs survive under .
|
|
207
|
+
* logs survive under .c64-re-tools/supervisor/<port>/logs/, same paths, same
|
|
183
208
|
* format as the retiring bash supervisor), returning both the spawn
|
|
184
209
|
* closure and the log's path relative to supervisorDir (the epoch
|
|
185
210
|
* record's own `log` field). Shared by both launch paths -- a cold
|
|
@@ -233,9 +258,10 @@ function writeEpochForLaunch(record, logRelPath) {
|
|
|
233
258
|
record.epoch = epochRecord.epoch;
|
|
234
259
|
}
|
|
235
260
|
/** Builds the supervision dependency object for withCrashSupervision(),
|
|
236
|
-
* once per launch, so
|
|
237
|
-
*
|
|
238
|
-
*
|
|
261
|
+
* once per launch, so the real launch path (handleAcquire's own cold arm,
|
|
262
|
+
* here -- plan 41-05 retires the second real launch path this comment used
|
|
263
|
+
* to name, the warm floor) passes a structurally identical SuperviseChildDeps
|
|
264
|
+
* object into the shared wrapper. Deliberately does
|
|
239
265
|
* NOT set spawnFactory: on a respawn, launchSupervised() (broker-launch.mts)
|
|
240
266
|
* derives its own per-instance log path from instanceLogDirFor and names
|
|
241
267
|
* that same path in the epoch record it writes -- supplying a competing
|
|
@@ -248,11 +274,11 @@ function writeEpochForLaunch(record, logRelPath) {
|
|
|
248
274
|
* CR-01 (03-REVIEW.md): `backend` is a REQUIRED positional parameter, not an
|
|
249
275
|
* optional field a call site may quietly omit. Before this, both real call
|
|
250
276
|
* sites built their deps here WITHOUT it, so `spawnAndRecordInstance()`'s own
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
277
|
+
* unset-parameter default silently took over the moment crash supervision
|
|
278
|
+
* replaced an instance -- a stock instance's crash-respawn or `vice_recycle`
|
|
279
|
+
* could relaunch it with a different backend's argv shape than the one it
|
|
280
|
+
* was actually launched with, leaving a pool member that can never be
|
|
281
|
+
* reached over the binary monitor again while still counting toward
|
|
256
282
|
* countReady()/countTotal(). Making it positional and required is what makes
|
|
257
283
|
* that omission a compile error rather than a silent backend swap: the FIRST
|
|
258
284
|
* launch and every REPLACEMENT of it now build their argv from the SAME
|
|
@@ -294,6 +320,47 @@ function markDeliberateDeath(instance, respawnAfterKill) {
|
|
|
294
320
|
instance.deliberateKill = true;
|
|
295
321
|
instance.respawnAfterKill = respawnAfterKill;
|
|
296
322
|
}
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Phase 33, plan 33-06 (REPRO-05, D-16, T-33-23/T-33-24): the warm-instance
|
|
325
|
+
// PROFILE-ELIGIBILITY rule.
|
|
326
|
+
//
|
|
327
|
+
// THE DECISION, stated out loud because two of the three available answers
|
|
328
|
+
// are wrong in ways the CALLER CANNOT DETECT:
|
|
329
|
+
// - Refuse the acquire outright when a mismatched warm instance exists ->
|
|
330
|
+
// warp becomes unusable whenever a warm floor exists (the default is 1,
|
|
331
|
+
// so: essentially always).
|
|
332
|
+
// - Serve the request with the mismatched instance -> the caller asked for
|
|
333
|
+
// warp, got an unwarped machine, and received a confident grant. The knob
|
|
334
|
+
// is a lie and nothing in the response says so.
|
|
335
|
+
// - D-16, what this implements: the mismatched instance is INELIGIBLE. The
|
|
336
|
+
// walk skips it and the acquire falls through to the cold arm, which
|
|
337
|
+
// launches a DEDICATED instance for that grant.
|
|
338
|
+
//
|
|
339
|
+
// WHAT MUST NEVER BE ADDED HERE: a retro-warp, and a kill-then-relaunch of a
|
|
340
|
+
// mismatched warm instance. There is no runtime `WarpMode` resource on stock
|
|
341
|
+
// at all (vsync.c:220-241, deliberately), so an existing instance cannot be
|
|
342
|
+
// adjusted -- it can only be ineligible. And "killing or relaunching
|
|
343
|
+
// preemptively to serve a newer request" is a NAMED anti-pattern in this
|
|
344
|
+
// project (CLAUDE.md): it would make an interactive session's emulator vanish
|
|
345
|
+
// because some capture run asked for warp. A test asserts the kill dependency
|
|
346
|
+
// is not called and the instance stays `ready`.
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
/** True when `record` was launched with the SAME profile `requested` asks
|
|
349
|
+
* for. FULLY SYNCHRONOUS by requirement, not by convenience -- see the call
|
|
350
|
+
* site inside selectWarmInstance() below for why.
|
|
351
|
+
*
|
|
352
|
+
* Absent is `{}`: a record with no `profile` field (a pre-33-06 record, a
|
|
353
|
+
* fork launch, a warm-floor spare, or a record a broker restarted mid-phase
|
|
354
|
+
* read from a state directory written before the field existed) is compared
|
|
355
|
+
* as though it carried `{}`, and so is an absent request. Each knob is
|
|
356
|
+
* compared `=== true` on BOTH sides, so `undefined` and `false` are the same
|
|
357
|
+
* request -- which is what makes an absent profile, an explicit `{}` and
|
|
358
|
+
* `{warp:false, headless:false}` one single behaviour rather than three. */
|
|
359
|
+
export function profileEligible(record, requested) {
|
|
360
|
+
const have = record.profile ?? {};
|
|
361
|
+
const want = requested ?? {};
|
|
362
|
+
return (have.warp === true) === (want.warp === true) && (have.headless === true) === (want.headless === true);
|
|
363
|
+
}
|
|
297
364
|
/** Walks `state.instances` for probe-live `ready` candidates, in iteration
|
|
298
365
|
* order, and returns the first that answers a grant-time re-probe (P-02) --
|
|
299
366
|
* or `null` once every candidate has been tried and none answered, letting
|
|
@@ -354,6 +421,28 @@ async function selectWarmInstance(state, deps) {
|
|
|
354
421
|
for (const record of Array.from(state.instances.values())) {
|
|
355
422
|
if (record.state !== "ready")
|
|
356
423
|
continue;
|
|
424
|
+
// Phase 33, plan 33-06 (D-16, T-33-23): a SYNCHRONOUS `continue`, sitting
|
|
425
|
+
// immediately beside the `record.state !== "ready"` filter directly
|
|
426
|
+
// above and BEFORE the readiness probe below. That placement is
|
|
427
|
+
// load-bearing twice over, and neither reason is stylistic:
|
|
428
|
+
//
|
|
429
|
+
// 1. It introduces NO new `await` into the region the single-owner
|
|
430
|
+
// `inFlight` launch guard protects. That guard exists because of the
|
|
431
|
+
// 2026-08-01 triple-launch outage and must stay a synchronous
|
|
432
|
+
// check-and-set with no `await` between (CLAUDE.md, regression-
|
|
433
|
+
// tested). A filter placed after the probe would put a fresh
|
|
434
|
+
// suspension point inside that region -- which is why this plan
|
|
435
|
+
// verifies the placement by line-number comparison, not by comment.
|
|
436
|
+
// 2. An ineligible candidate costs no probe at all -- no socket, no
|
|
437
|
+
// round trip, no wait.
|
|
438
|
+
//
|
|
439
|
+
// An ineligible miss falls through EXACTLY as a "no warm instance" miss
|
|
440
|
+
// does: to the caller's own cold arm, which records the one and only
|
|
441
|
+
// grant. It opens no second `state.grants.set()` call, and it never
|
|
442
|
+
// kills, recycles or re-warps the mismatched instance (see
|
|
443
|
+
// profileEligible()'s own banner for why those are excluded by design).
|
|
444
|
+
if (!profileEligible(record, deps.requestedProfile))
|
|
445
|
+
continue;
|
|
357
446
|
const isReady = await deps.probe(record.port);
|
|
358
447
|
// A sibling acquire may have granted OR dropped this exact candidate
|
|
359
448
|
// while this probe was in flight. "Granted" changes record.state;
|
|
@@ -437,14 +526,14 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
437
526
|
// WR-01: the readiness probe is backend-aware, from the SAME threaded-down
|
|
438
527
|
// verdict handleAcquire already uses for buildViceArgs() -- on stock the port
|
|
439
528
|
// speaks the binary monitor, so an HTTP POST there can never succeed.
|
|
440
|
-
const backend = deps.backend ?? "
|
|
529
|
+
const backend = deps.backend ?? "stock";
|
|
441
530
|
const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
|
|
442
531
|
// Textually a verifiedKill( call site, not merely a reference -- reused
|
|
443
532
|
// UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
|
|
444
533
|
// re-derived, and never replaced by a bare process.kill().
|
|
445
534
|
const kill = deps.kill ?? ((opts) => verifiedKill(opts));
|
|
446
535
|
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
447
|
-
const winner = await selectWarmInstance(state, { probe, kill, log });
|
|
536
|
+
const winner = await selectWarmInstance(state, { probe, kill, log, requestedProfile: deps.profile });
|
|
448
537
|
let record;
|
|
449
538
|
if (winner) {
|
|
450
539
|
record = winner;
|
|
@@ -473,6 +562,14 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
473
562
|
// build a different backend's argv than the launch it replaces.
|
|
474
563
|
backend,
|
|
475
564
|
allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
|
|
565
|
+
// Phase 33, plan 33-06 (D-15/D-16): the profile the warm arm just
|
|
566
|
+
// refused to compromise on reaches buildViceArgs() here, and is
|
|
567
|
+
// mirrored onto the fresh InstanceRecord by spawnAndRecordInstance()
|
|
568
|
+
// in the SAME step -- so this instance's recorded profile and its real
|
|
569
|
+
// argv are written together and cannot disagree. This is the arm that
|
|
570
|
+
// makes "a dedicated instance for that grant" true rather than
|
|
571
|
+
// aspirational.
|
|
572
|
+
profile: deps.profile,
|
|
476
573
|
spawnFactory: deps.buildColdSpawnFactory ??
|
|
477
574
|
((port) => {
|
|
478
575
|
const supervisorDir = join(stateDir, String(port));
|
|
@@ -482,6 +579,13 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
482
579
|
}),
|
|
483
580
|
});
|
|
484
581
|
if (!result.ok) {
|
|
582
|
+
// Plan 41-05 (D-16, checkpoint option B): `result.reason` passes
|
|
583
|
+
// straight through -- `AcquireLaunchResult`'s reason union
|
|
584
|
+
// ("launch_in_flight" | "no_free_port" | "no_free_text_port") is a
|
|
585
|
+
// subset of `AcquireOutcome`'s, so a failed text-port allocation's own
|
|
586
|
+
// `no_free_text_port` reaches the control plane as its own distinct
|
|
587
|
+
// code (broker-control.mts's ControlErrorCode) rather than collapsing
|
|
588
|
+
// to `internal` or to the generic `no_free_port`.
|
|
485
589
|
return { ok: false, reason: result.reason };
|
|
486
590
|
}
|
|
487
591
|
if (result.record.pid === null) {
|
|
@@ -494,16 +598,29 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
494
598
|
// even though the caller was already told "internal" right now.
|
|
495
599
|
// CR-02: deleteInstanceRecord(), not a bare map delete -- a stock launch
|
|
496
600
|
// that failed this way already had its second port allocated and
|
|
497
|
-
// blocked by acquirePortAndLaunch()
|
|
601
|
+
// blocked by acquirePortAndLaunch(), and deleteInstanceRecord() hands
|
|
602
|
+
// that second port back to the allocator (via state.blockedPorts) in
|
|
603
|
+
// the SAME step as it removes the broken record -- confirmed still
|
|
604
|
+
// true after plan 41-05 (D-16): this branch is reached only once a
|
|
605
|
+
// record already exists, i.e. only once BOTH allocations already
|
|
606
|
+
// succeeded (a failed second allocation now fails the acquire before
|
|
607
|
+
// any record -- and before this `pid === null` check -- is ever
|
|
608
|
+
// reached at all).
|
|
498
609
|
deleteInstanceRecord(state, result.record.port);
|
|
499
610
|
return { ok: false, reason: "internal" };
|
|
500
611
|
}
|
|
501
612
|
record = result.record;
|
|
502
|
-
// Only the cold-launch arm ever writes a FRESH epoch record --
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
613
|
+
// Only the cold-launch arm ever writes a FRESH epoch record here --
|
|
614
|
+
// selectWarmInstance()'s own winner already has one. Plan 41-05 (folded
|
|
615
|
+
// todo) changes WHY that is true without changing that it IS true: a
|
|
616
|
+
// ready, ungranted candidate no longer comes from a warm-floor pass's
|
|
617
|
+
// own onLaunched hook (retired along with the floor) -- it comes from
|
|
618
|
+
// broker-launch.mts's own crash-supervision respawn path
|
|
619
|
+
// (launchSupervised(), which writes its own epoch record via
|
|
620
|
+
// deps.epoch.writeEpochRecord() on every launch and every respawn).
|
|
621
|
+
// Either way, rewriting the epoch here would advance an epoch no restart
|
|
622
|
+
// caused, which the container-side assertSameMachine() would read as a
|
|
623
|
+
// machine change.
|
|
507
624
|
writeEpochForLaunch(record, lastLogRelPath);
|
|
508
625
|
}
|
|
509
626
|
// THE single grant-recording step, fed by both arms above -- no `await`
|
|
@@ -515,7 +632,18 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
515
632
|
record.state = "granted";
|
|
516
633
|
return {
|
|
517
634
|
ok: true,
|
|
518
|
-
grant: {
|
|
635
|
+
grant: {
|
|
636
|
+
port: record.port,
|
|
637
|
+
url: record.url,
|
|
638
|
+
epochFile: record.epochFile,
|
|
639
|
+
supervisorDir: record.supervisorDir,
|
|
640
|
+
// Plan 41-01 (D-15): key omitted entirely when the record has none --
|
|
641
|
+
// the fork case, and (until a later plan closes the port-allocation
|
|
642
|
+
// degrade path) a stock instance whose second port allocation itself
|
|
643
|
+
// failed. Same key-omitted-when-undefined idiom
|
|
644
|
+
// spawnAndRecordInstance() already uses for this same field.
|
|
645
|
+
...(record.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: record.remoteMonitorPort }),
|
|
646
|
+
},
|
|
519
647
|
};
|
|
520
648
|
}
|
|
521
649
|
/** Answers the `status` control-plane request: one entry per instance,
|
|
@@ -529,7 +657,10 @@ function handleStatus(state) {
|
|
|
529
657
|
state: r.state,
|
|
530
658
|
reason: r.reason,
|
|
531
659
|
epoch: typeof r.epoch === "number" ? r.epoch : null,
|
|
532
|
-
|
|
660
|
+
// Plan 41-03 (D-14): "at least one channel is claimed" -- promoted from
|
|
661
|
+
// a single-field check, byte-identical wire shape, meaning stated
|
|
662
|
+
// explicitly (D-15).
|
|
663
|
+
hasMonitorClient: Object.keys(r.monitorClients).length > 0,
|
|
533
664
|
}));
|
|
534
665
|
}
|
|
535
666
|
/** Resolves a monitor_claim/monitor_release target the SAME way
|
|
@@ -544,49 +675,55 @@ function resolveInstanceForMonitorTarget(targetId, state) {
|
|
|
544
675
|
return null;
|
|
545
676
|
return state.instances.get(grant.port) ?? null;
|
|
546
677
|
}
|
|
547
|
-
/** Answers `monitor_claim` (plan 05, BROK-02/PROTO-08, D-13
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* the SAME grant lookup
|
|
553
|
-
* use) and "the
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
|
|
678
|
+
/** Answers `monitor_claim` (plan 05, BROK-02/PROTO-08, D-13; per-channel
|
|
679
|
+
* since plan 41-03, D-14): exclusive monitor-socket ownership enforced
|
|
680
|
+
* HERE, broker-side, PER CHANNEL, so a conflicting claim is refused by name
|
|
681
|
+
* before any second `connect()` is ever attempted -- the one state stock
|
|
682
|
+
* VICE cannot report and no client-side heuristic can diagnose. `targetId`
|
|
683
|
+
* doubles as both "which instance" (resolved via the SAME grant lookup
|
|
684
|
+
* handleRelease()/handleRecycleForRealBroker() already use) and "the
|
|
685
|
+
* requesting grant's own identity" -- the claim IS the grant, so there is
|
|
686
|
+
* no separate identity to carry. A repeated claim from the SAME grant on
|
|
687
|
+
* the SAME channel is idempotent (`ok: true`, no second holder created); a
|
|
688
|
+
* claim from a DIFFERENT grant while that channel already has a holder is
|
|
689
|
+
* refused, naming the current holder and the channel (T-02-18) -- never the
|
|
690
|
+
* emulator's own fault. A DIFFERENT channel's holder is irrelevant to this
|
|
691
|
+
* decision -- claiming one channel never evicts or is refused by the
|
|
692
|
+
* other's holder. */
|
|
693
|
+
export function handleMonitorClaim(requestId, targetId, channel, state) {
|
|
559
694
|
void requestId; // correlation only -- the claim's own identity is targetId itself
|
|
560
695
|
const instance = resolveInstanceForMonitorTarget(targetId, state);
|
|
561
696
|
if (!instance)
|
|
562
697
|
return { ok: false, code: "bad_request" };
|
|
563
|
-
const existing = instance.
|
|
698
|
+
const existing = instance.monitorClients[channel];
|
|
564
699
|
if (!existing) {
|
|
565
|
-
instance.
|
|
700
|
+
instance.monitorClients[channel] = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
|
|
566
701
|
return { ok: true };
|
|
567
702
|
}
|
|
568
703
|
if (existing.grantId === targetId) {
|
|
569
|
-
return { ok: true }; // idempotent repeat from the SAME grant -- no second holder
|
|
704
|
+
return { ok: true }; // idempotent repeat from the SAME grant on the SAME channel -- no second holder
|
|
570
705
|
}
|
|
571
|
-
return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid } };
|
|
706
|
+
return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid, channel } };
|
|
572
707
|
}
|
|
573
|
-
/** Answers `monitor_release` (plan 05, T-02-01
|
|
574
|
-
*
|
|
708
|
+
/** Answers `monitor_release` (plan 05, T-02-01; per-channel since plan
|
|
709
|
+
* 41-03, D-14): clears ONLY the named channel's entry, ONLY when `targetId`
|
|
710
|
+
* names that channel's CURRENT holder -- a non-holder is refused, not
|
|
575
711
|
* silently accepted (spoofing a release is exactly T-02-01's own
|
|
576
|
-
* disposition).
|
|
712
|
+
* disposition). A channel with no current holder at all tolerates the
|
|
577
713
|
* release as a success, matching the container-side client's own documented
|
|
578
714
|
* tolerance for releasing a socket the broker already cleared. */
|
|
579
|
-
export function handleMonitorRelease(requestId, targetId, state) {
|
|
715
|
+
export function handleMonitorRelease(requestId, targetId, channel, state) {
|
|
580
716
|
void requestId; // correlation only, matching handleMonitorClaim()'s own posture
|
|
581
717
|
const instance = resolveInstanceForMonitorTarget(targetId, state);
|
|
582
718
|
if (!instance)
|
|
583
719
|
return { ok: false, code: "bad_request" };
|
|
584
|
-
|
|
720
|
+
const existing = instance.monitorClients[channel];
|
|
721
|
+
if (!existing)
|
|
585
722
|
return { ok: true }; // already cleared -- tolerated, not an error
|
|
586
|
-
if (
|
|
723
|
+
if (existing.grantId !== targetId) {
|
|
587
724
|
return { ok: false, code: "denied" };
|
|
588
725
|
}
|
|
589
|
-
clearMonitorClient(instance);
|
|
726
|
+
clearMonitorClient(instance, channel);
|
|
590
727
|
return { ok: true };
|
|
591
728
|
}
|
|
592
729
|
/** Resolves a recycle target's emulator child pid from THIS broker's own
|
|
@@ -664,83 +801,26 @@ async function handleRecycleForRealBroker(targetId, state) {
|
|
|
664
801
|
const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
|
|
665
802
|
return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
|
|
666
803
|
}
|
|
667
|
-
/**
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
* Both the write site (the spawn-wrapping closure) and the read site (the
|
|
680
|
-
* `onLaunched` callback) live inside this SAME function body, so this is a
|
|
681
|
-
* pure relocation with no behavioural change -- it removes the
|
|
682
|
-
* cross-call-sharing risk a module-level `let` carried (correct only
|
|
683
|
-
* because of invariants -- at most one launch per call, never invoked
|
|
684
|
-
* concurrently with itself -- enforced elsewhere and never checked at the
|
|
685
|
-
* point the variable used to be declared). */
|
|
686
|
-
function maintainWarmFloorForRealBroker(stateDir, state, backend) {
|
|
687
|
-
let lastWarmLaunchLogRelPath = "";
|
|
688
|
-
return maintainWarmFloor({
|
|
804
|
+
/** Plan 41-05 (folded todo): the second concern of the fixed-order
|
|
805
|
+
* evaluation pass, RENAMED from the retired warm-floor maintenance function
|
|
806
|
+
* this replaces (D-24 drops the projection write; the grant sweep does not
|
|
807
|
+
* appear -- D-12's connection-is-the-lease). Unlike the function it
|
|
808
|
+
* replaces, this one never launches anything -- it wires only
|
|
809
|
+
* broker-launch.mjs's real promoteLaunchingInstances() against this
|
|
810
|
+
* broker's own state and the backend-aware readiness probe, so a
|
|
811
|
+
* `launching` instance (however it got there -- a cold acquire's own
|
|
812
|
+
* instance, or a crash-respawn) is promoted to `ready` the moment it
|
|
813
|
+
* answers. */
|
|
814
|
+
function promoteLaunchingForRealBroker(state, backend) {
|
|
815
|
+
return promoteLaunchingInstances({
|
|
689
816
|
state,
|
|
690
|
-
stateDir,
|
|
691
817
|
backend,
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
|
|
695
|
-
// I-1 rider (08.2-06-PLAN.md, Task 2): forwards a third options
|
|
696
|
-
// argument -- this is a SECOND, independent dropper on the
|
|
697
|
-
// warm-floor arm; fixing only makeLoggingSpawn above would leave
|
|
698
|
-
// this arm's own scratch XDG_CONFIG_HOME dropped right here.
|
|
699
|
-
const stashingSpawn = (cmd, args, options) => {
|
|
700
|
-
const child = spawn(cmd, args, options);
|
|
701
|
-
// Stash the log path where onLaunched (fired synchronously right
|
|
702
|
-
// after this returns, still within the SAME maintainWarmFloor()
|
|
703
|
-
// call -- at most one launch per call, per the serialised-warming
|
|
704
|
-
// invariant) can find it. withCrashSupervision() below composes
|
|
705
|
-
// AROUND this function, so the stash still runs (and still
|
|
706
|
-
// completes before onLaunched reads it) before the exit listener
|
|
707
|
-
// is ever attached.
|
|
708
|
-
lastWarmLaunchLogRelPath = logRelPath;
|
|
709
|
-
return child;
|
|
710
|
-
};
|
|
711
|
-
// CR-01 (03-REVIEW.md): the SAME resolved `backend` this function
|
|
712
|
-
// already receives for the launch argv is threaded into the supervision
|
|
713
|
-
// deps, so a warm instance's own crash-respawn stays on its backend.
|
|
714
|
-
return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state, backend));
|
|
715
|
-
},
|
|
716
|
-
// WR-01: same backend-aware probe route as handleAcquire's, from the SAME
|
|
717
|
-
// resolved verdict this function already receives for the launch argv.
|
|
818
|
+
// WR-01: same backend-aware probe route as handleAcquire's, from the
|
|
819
|
+
// SAME resolved verdict this function already receives.
|
|
718
820
|
probe: (port) => probeReady(port, { backend }),
|
|
719
|
-
allocatePort: nextFreePort,
|
|
720
|
-
// Plan 03-04 (DIRECT-06, D-13): same wiring as handleAcquire()'s own
|
|
721
|
-
// cold-launch arm -- acquirePortAndLaunch() (reached via
|
|
722
|
-
// maintainWarmFloor() below) gates the second allocation on
|
|
723
|
-
// `backend === "stock"` itself, so this function need not check the
|
|
724
|
-
// backend before passing it.
|
|
725
|
-
allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
|
|
726
|
-
countReady,
|
|
727
|
-
countTotal,
|
|
728
|
-
countLaunching,
|
|
729
|
-
onLaunched: (record) => {
|
|
730
|
-
writeEpochForLaunch(record, lastWarmLaunchLogRelPath);
|
|
731
|
-
},
|
|
732
821
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
733
822
|
});
|
|
734
823
|
}
|
|
735
|
-
/** Exported ONLY so a test can drive the warm-floor arm's REAL spawn
|
|
736
|
-
* composition (this function's own makeLoggingSpawn()+stashingSpawn+
|
|
737
|
-
* withCrashSupervision() closure above) through the built artifact, the
|
|
738
|
-
* same escape-hatch pattern `_superviseDepsFor` already establishes for the
|
|
739
|
-
* respawn composition -- see vice-broker-acquire.test.ts's I-1 composition
|
|
740
|
-
* tests (08.2-06-PLAN.md, Task 3), which call this directly with no spawn
|
|
741
|
-
* override so the warm floor's own independent `stashingSpawn` dropper
|
|
742
|
-
* cannot hide behind an injected stub. */
|
|
743
|
-
export const _maintainWarmFloorForRealBroker = maintainWarmFloorForRealBroker;
|
|
744
824
|
/** Releases a grant and identity-verified-kills its instance -- but ONLY
|
|
745
825
|
* when the port's CURRENT occupant is proven to be the SAME process this
|
|
746
826
|
* grant was actually issued for (its own recorded `pid`, set at grant time
|
|
@@ -785,10 +865,11 @@ export function handleRelease(requestId, state) {
|
|
|
785
865
|
const instance = state.instances.get(grant.port);
|
|
786
866
|
if (instance && instance.pid === grant.pid) {
|
|
787
867
|
markDeliberateDeath(instance, false);
|
|
788
|
-
// Plan 05: releasing clears monitor-client ownership
|
|
789
|
-
// -- redundant with the instance-map deletion two lines
|
|
790
|
-
// WHOLE record,
|
|
791
|
-
// for the same reason GrantRecord's own clearing is explicit
|
|
868
|
+
// Plan 05: releasing clears monitor-client ownership (every channel) as
|
|
869
|
+
// a side effect -- redundant with the instance-map deletion two lines
|
|
870
|
+
// below (the WHOLE record, monitorClients included, is going away), but
|
|
871
|
+
// explicit for the same reason GrantRecord's own clearing is explicit
|
|
872
|
+
// here: the
|
|
792
873
|
// instance-map deletion is a Task-2-era invariant this task must not
|
|
793
874
|
// depend on silently continuing to hold.
|
|
794
875
|
clearMonitorClient(instance);
|
|
@@ -860,18 +941,47 @@ async function run(args) {
|
|
|
860
941
|
// launch, and never called from inside broker-launch.mts's `inFlight`
|
|
861
942
|
// single-owner guard (this call sits entirely outside it; no launch is
|
|
862
943
|
// even possible yet at this point in run()). `supervisorDir: args.stateDir`
|
|
863
|
-
// is passed explicitly -- args.stateDir IS `.
|
|
944
|
+
// is passed explicitly -- args.stateDir IS `.c64-re-tools/supervisor` under this
|
|
864
945
|
// broker's own repo root (see parseArgs() above), so this is the SAME
|
|
865
946
|
// directory repo-root.ts's supervisorDir() would resolve to, without this
|
|
866
947
|
// host-bound module ever importing that container-side resolver directly
|
|
867
|
-
// (backend-detect.mts's own header comment explains why it cannot).
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
948
|
+
// (backend-detect.mts's own header comment explains why it cannot).
|
|
949
|
+
// FORKRM-01 (plan 52-06): there is nothing left to detect -- the resolved
|
|
950
|
+
// `backend` is always `"stock"`; what this call still does is resolve the
|
|
951
|
+
// binary's own identity for the log line below and initialise the
|
|
952
|
+
// capability cache backend-detect.mts's own BACK-04 record depends on.
|
|
872
953
|
const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
|
|
873
954
|
const backend = backendResult.backend;
|
|
874
|
-
process.stderr.write(`vice-broker: backend "${backend}" (
|
|
955
|
+
process.stderr.write(`vice-broker: backend "${backend}" (binary: ${backendResult.binPath})\n`);
|
|
956
|
+
// Gap G-40-1, requirement R2 (plan 40-09): THE BROKER mints/verifies the
|
|
957
|
+
// Ghidra runs-root handle here -- after the unconditional startup reap
|
|
958
|
+
// above, and BEFORE the control listener below accepts a single
|
|
959
|
+
// connection -- so a container-side MCP server with no host tooling of
|
|
960
|
+
// its own still finds the handle in place the moment it can reach this
|
|
961
|
+
// broker at all. This is deliberately NOT the only call site:
|
|
962
|
+
// resolveGhidraProject() (ghidra-project.mts) calls the same function as
|
|
963
|
+
// an idempotent precondition, because two host-side routes never involve
|
|
964
|
+
// a broker at all -- the direct spawn of resources/host-tool.mjs from
|
|
965
|
+
// host-tool-client.ts:269-273 (the everyday route on a host with no
|
|
966
|
+
// devcontainer, and the route CI uses), and tests importing that
|
|
967
|
+
// artifact directly. Both callers write the identical relative-target
|
|
968
|
+
// link, so a race between them is a benign EEXIST, not a conflict (see
|
|
969
|
+
// ensureGhidraRunsHandle()'s own header). The negative rule: container-
|
|
970
|
+
// side code must NEVER mint this handle -- the link target is relative
|
|
971
|
+
// and correct only when written from the host's view of the workspace.
|
|
972
|
+
//
|
|
973
|
+
// Handled WITHOUT throwing: run() has no try/catch around this region and
|
|
974
|
+
// the broker must start regardless of the outcome here -- it serves
|
|
975
|
+
// twelve allowlisted tool ids and only one of them (ghidra.analyze) needs
|
|
976
|
+
// this handle. A refusal is surfaced as ONE stderr line naming the
|
|
977
|
+
// consequence; every other tool id is unaffected.
|
|
978
|
+
const ghidraHandleResult = ensureGhidraRunsHandle(args.repoRoot);
|
|
979
|
+
if (ghidraHandleResult.ok) {
|
|
980
|
+
process.stderr.write(`vice-broker: ghidra runs handle ${ghidraHandleResult.handle} -> ${ghidraHandleResult.target}\n`);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
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
|
+
}
|
|
875
985
|
// 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.
|
|
876
986
|
let listener;
|
|
877
987
|
try {
|
|
@@ -879,29 +989,52 @@ async function run(args) {
|
|
|
879
989
|
host: controlHost,
|
|
880
990
|
port: controlPort,
|
|
881
991
|
token,
|
|
882
|
-
onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state, {
|
|
992
|
+
onAcquire: (requestId, profile) => handleAcquire(requestId, args.stateDir, state, {
|
|
883
993
|
backend,
|
|
884
994
|
// Plan 03-04 (DIRECT-06, D-13): threaded down to
|
|
885
995
|
// acquirePortAndLaunch()'s own gate (backend === "stock"); this
|
|
886
|
-
// callback does
|
|
996
|
+
// callback does not re-read any environment variable itself.
|
|
887
997
|
allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
|
|
998
|
+
// Phase 33, plan 33-06 (REPRO-05, D-15): the ALREADY-NARROWED
|
|
999
|
+
// profile broker-control.mts handed this callback. Nothing here
|
|
1000
|
+
// re-validates it and nothing here reads a raw wire field --
|
|
1001
|
+
// normaliseLaunchProfile() is the single narrowing site, and it ran
|
|
1002
|
+
// before this callback was ever invoked.
|
|
1003
|
+
profile,
|
|
888
1004
|
}),
|
|
889
1005
|
onRelease: (requestId) => handleRelease(requestId, state),
|
|
890
1006
|
onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
|
|
891
1007
|
onStatus: () => handleStatus(state),
|
|
892
|
-
|
|
893
|
-
|
|
1008
|
+
// Phase 34, plan 34-01 (SEAM-01): its OWN callback, wired alongside
|
|
1009
|
+
// (never derived from) the other six above -- handed only
|
|
1010
|
+
// `args.repoRoot` and a stderr logger, never this broker's `state` map,
|
|
1011
|
+
// so it structurally cannot reach lease state through this closure.
|
|
1012
|
+
// 34-09 (CR-04): deliberately supplies no timeout, and that is
|
|
1013
|
+
// authoritative here, not an omission -- the per-tool budget table
|
|
1014
|
+
// inside runHostTool()/hostToolTimeoutMs() (host-tool.mts) is the ONE
|
|
1015
|
+
// place a budget is decided, and no wire field carries one across the
|
|
1016
|
+
// seam at all (the `deps.timeoutMs` this callback could pass is an
|
|
1017
|
+
// in-process test seam, not something a caller's request ever
|
|
1018
|
+
// supplies). A reader arriving here from the artifact this plan's
|
|
1019
|
+
// completeness case checks should find this comment as the answer,
|
|
1020
|
+
// not an apparent gap.
|
|
1021
|
+
onHostTool: (raw) => runHostTool(raw, {
|
|
1022
|
+
repoRoot: args.repoRoot,
|
|
1023
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
1024
|
+
}),
|
|
1025
|
+
onMonitorClaim: (requestId, targetId, channel) => handleMonitorClaim(requestId, targetId, channel, state),
|
|
1026
|
+
onMonitorRelease: (requestId, targetId, channel) => handleMonitorRelease(requestId, targetId, channel, state),
|
|
894
1027
|
onHostState: () => ({
|
|
895
1028
|
pid: process.pid,
|
|
896
1029
|
startedAt,
|
|
897
1030
|
nodeVersion: process.version,
|
|
898
1031
|
viceBin: resolveViceBinForHostState(),
|
|
899
|
-
warmFloor: resolveWarmFloorForRecord(),
|
|
900
1032
|
maxInstances: resolveCeilingForRecord(),
|
|
901
1033
|
basePort: resolveBasePort(),
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
1034
|
+
// FORKRM-01 (plan 52-06): the verdict THIS process resolved once, at
|
|
1035
|
+
// startup, above -- kept on the wire because text-tools.ts's own
|
|
1036
|
+
// broker-identity cross-check (out of this plan's scope) still reads
|
|
1037
|
+
// it. Never a second resolvedBackend() call.
|
|
905
1038
|
backend,
|
|
906
1039
|
}),
|
|
907
1040
|
});
|
|
@@ -951,11 +1084,21 @@ async function run(args) {
|
|
|
951
1084
|
registerShutdownHandlers({ state });
|
|
952
1085
|
// A successful bind writes the record UNCONDITIONALLY, overwriting
|
|
953
1086
|
// whatever was there -- the bind itself is the proof of singleton status
|
|
954
|
-
// (D-17). The fourteen-field set (D-27, criterion G
|
|
955
|
-
//
|
|
956
|
-
//
|
|
1087
|
+
// (D-17). The fourteen-field set (D-27, criterion G; narrowed from
|
|
1088
|
+
// fourteen to thirteen by plan 41-05, then widened back to fourteen to add
|
|
1089
|
+
// node_exec_path): the lease time-to-live field the bash original carried
|
|
1090
|
+
// is gone -- the connection is the lease now (D-12) -- `warm_floor` is
|
|
1091
|
+
// likewise gone (plan 41-05: there is no warm floor left to echo a
|
|
1092
|
+
// configured value for) -- and every other config-echo field survives
|
|
957
1093
|
// even though no consumer parses it beyond a status message, because a
|
|
958
1094
|
// human reading this file by hand benefits from the full echo.
|
|
1095
|
+
//
|
|
1096
|
+
// node_exec_path is process.execPath, not something threaded in from
|
|
1097
|
+
// outside: exec() replaces the process image, so whatever interpreter the
|
|
1098
|
+
// launcher resolved and gated IS this process's own execPath by the time
|
|
1099
|
+
// this line runs -- the record tells the truth without either side having
|
|
1100
|
+
// to pass anything, and it stays truthful even when this broker was
|
|
1101
|
+
// started directly, bypassing the launcher entirely.
|
|
959
1102
|
let record = {
|
|
960
1103
|
version: 1,
|
|
961
1104
|
written_by: WRITTEN_BY,
|
|
@@ -963,17 +1106,17 @@ async function run(args) {
|
|
|
963
1106
|
started_at: startedAt,
|
|
964
1107
|
heartbeat_at: new Date().toISOString(),
|
|
965
1108
|
node_version: process.version,
|
|
1109
|
+
node_exec_path: process.execPath,
|
|
966
1110
|
control_host: listener.host,
|
|
967
1111
|
control_port: listener.port,
|
|
968
1112
|
control_token: token, // never logged -- T-01.6.2-02
|
|
969
|
-
warm_floor: resolveWarmFloorForRecord(),
|
|
970
1113
|
max_instances: resolveCeilingForRecord(),
|
|
971
1114
|
base_port: resolveBasePort(),
|
|
972
1115
|
poll_ms: pollMs,
|
|
973
1116
|
dry_run: args.dryRun,
|
|
974
1117
|
};
|
|
975
1118
|
writeBrokerRecordFile(args.stateDir, record);
|
|
976
|
-
process.stderr.write(`vice-broker: wrote ${finalPath} (node ${record.node_version}); control listener bound on ${listener.host}:${listener.port}\n`);
|
|
1119
|
+
process.stderr.write(`vice-broker: wrote ${finalPath} (node ${record.node_version} at ${record.node_exec_path}); control listener bound on ${listener.host}:${listener.port}\n`);
|
|
977
1120
|
const heartbeatMs = Number(process.env.VICE_BROKER_HEARTBEAT_MS) || 30000;
|
|
978
1121
|
setInterval(() => {
|
|
979
1122
|
// The refresh path goes through the SAME atomic tmp-then-rename choke
|
|
@@ -984,18 +1127,21 @@ async function run(args) {
|
|
|
984
1127
|
writeBrokerRecordFile(args.stateDir, record);
|
|
985
1128
|
}, heartbeatMs);
|
|
986
1129
|
// The fixed-order evaluation pass (runBrokerPass, broker-launch.mts):
|
|
987
|
-
// serve pending acquires, then
|
|
988
|
-
// vice-broker.sh's own broker_once() ordering
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
1130
|
+
// serve pending acquires, then promote launching -> ready -- mirroring
|
|
1131
|
+
// vice-broker.sh's own broker_once() ordering (plan 41-05, folded todo:
|
|
1132
|
+
// the warm floor this pass used to maintain as its second concern is
|
|
1133
|
+
// RETIRED; see runBrokerPass()'s own comment in broker-launch.mts for what
|
|
1134
|
+
// the fixed order still buys now that only serveAcquires() ever launches
|
|
1135
|
+
// anything). Ticks on VICE_BROKER_POLL_MS (default 500, the SAME env var
|
|
1136
|
+
// name and semantics the bash daemon used). serveAcquires now drains the
|
|
1137
|
+
// arrival-ordered pending-acquire structure this listener instance owns
|
|
1138
|
+
// (D-08's mechanism; plan 02's own `serveAcquires: () => {}` comment
|
|
1139
|
+
// reserved exactly this room) -- an acquire queued because a launch was
|
|
1140
|
+
// already in flight is retried here, on the SAME pass that also promotes
|
|
1141
|
+
// any newly-ready instance, so a stalled pass shows up as a stale record
|
|
1142
|
+
// rather than a silently wrong one. Re-entrancy guarded: a pass that is
|
|
1143
|
+
// still running (e.g. a slow readiness probe against a genuinely slow
|
|
1144
|
+
// host) is never overlapped by the next tick.
|
|
999
1145
|
let passInFlight = false;
|
|
1000
1146
|
setInterval(() => {
|
|
1001
1147
|
if (passInFlight)
|
|
@@ -1003,7 +1149,7 @@ async function run(args) {
|
|
|
1003
1149
|
passInFlight = true;
|
|
1004
1150
|
runBrokerPass({
|
|
1005
1151
|
serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
|
|
1006
|
-
|
|
1152
|
+
promoteLaunching: () => promoteLaunchingForRealBroker(state, backend),
|
|
1007
1153
|
})
|
|
1008
1154
|
.catch((e) => {
|
|
1009
1155
|
process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
|