@henols/vice-mcp 0.2.2 → 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/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- 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 +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- 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 +253 -108
- 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,12 +2,12 @@
|
|
|
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
|
//
|
|
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,26 +29,58 @@ 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
|
-
// here (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
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).
|
|
39
|
+
import { createBrokerState, nextFreePort, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
|
|
40
|
+
import { acquirePortAndLaunch, deleteInstanceRecord,
|
|
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.
|
|
45
|
+
promoteLaunchingInstances, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
|
|
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
|
|
52
|
+
// broker-launch.mts) because vice-broker.mts is ALWAYS run from its own
|
|
53
|
+
// compiled resources/ form -- both modules are compiled together in the
|
|
54
|
+
// same build.ts pass, so "./backend-detect.mjs" always exists as a real
|
|
55
|
+
// sibling file by the time this import resolves.
|
|
42
56
|
import { resolvedBackend } from "./backend-detect.mjs";
|
|
43
57
|
import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
|
|
44
58
|
import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
|
|
59
|
+
// A VALUE import of the host-tool executor -- safe here for the SAME reason
|
|
60
|
+
// every other sibling value import above is:
|
|
61
|
+
// this file is ALWAYS run from its own compiled resources/ form, and
|
|
62
|
+
// "./host-tool.mjs" is compiled into that same directory by the same build.ts
|
|
63
|
+
// pass (host-tool.mts is added to HOST_BOUND_ARTIFACTS/tsconfig.build.json's
|
|
64
|
+
// include[] in this same commit).
|
|
65
|
+
import { runHostTool } from "./host-tool.mjs";
|
|
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).
|
|
71
|
+
import { ensureGhidraRunsHandle } from "./ghidra-project.mjs";
|
|
45
72
|
import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
|
|
46
73
|
const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
|
|
47
74
|
/** `--repo-root` is required UNLESS `--check-container` is given -- the
|
|
48
75
|
* container guard needs no paths at all, matching the bash launcher's own
|
|
49
76
|
* `--check-container` handling (answered before any path resolution).
|
|
50
77
|
* `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
|
|
51
|
-
* otherwise `.
|
|
78
|
+
* otherwise `.c64-re-tools/supervisor` under the repo root (moved 2026-09-08
|
|
79
|
+
* from `.vice-supervisor`; the three-tier chain itself -- explicit
|
|
80
|
+
* `--state-dir`, then `VICE_POOL_DIR`, then this default -- is unchanged,
|
|
81
|
+
* only the default's location moved). This module is host-bound and compiled
|
|
82
|
+
* by `build.ts`, so it must not import the container-side `repo-root.ts`;
|
|
83
|
+
* the two segments are joined directly, matching that file's `toolsDir()`. */
|
|
52
84
|
export function parseArgs(argv) {
|
|
53
85
|
let repoRoot = null;
|
|
54
86
|
let stateDir = null;
|
|
@@ -73,39 +105,33 @@ export function parseArgs(argv) {
|
|
|
73
105
|
if (!checkContainer && !repoRoot) {
|
|
74
106
|
throw new Error(USAGE);
|
|
75
107
|
}
|
|
76
|
-
const resolvedStateDir = stateDir ??
|
|
108
|
+
const resolvedStateDir = stateDir ??
|
|
109
|
+
process.env.VICE_POOL_DIR ??
|
|
110
|
+
(repoRoot ? join(repoRoot, ".c64-re-tools", "supervisor") : join(".c64-re-tools", "supervisor"));
|
|
77
111
|
return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
|
|
78
112
|
}
|
|
79
|
-
/** The deployed JavaScript broker artifact's own name
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* 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. */
|
|
83
116
|
export const WRITTEN_BY = "vice-broker.mjs";
|
|
84
117
|
// ---------------------------------------------------------------------------
|
|
85
|
-
// Small, locally-duplicated env-var
|
|
118
|
+
// Small, locally-duplicated env-var reader (plan 05) -- the SAME pattern
|
|
86
119
|
// broker-kill.mts's own resolveBasePortForReap()/resolveViceBinForReap()
|
|
87
120
|
// 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
|
-
//
|
|
121
|
+
// PRIVATE resolveCeiling() (it is not exported, and this file is already
|
|
122
|
+
// the top-level wiring module value-importing every sibling .mjs directly --
|
|
123
|
+
// exporting it would widen broker-launch.mts's own surface for a one-line
|
|
124
|
+
// env-var read this file can duplicate exactly as cheaply). Mirrors
|
|
125
|
+
// broker-launch.mts's own default precisely (VICE_BROKER_MAX/16) so
|
|
126
|
+
// broker.json's config echo and host_state's own answer can never disagree
|
|
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
|
|
132
|
+
// ceiling's own default (16) is untouched, since it is a separate concern
|
|
133
|
+
// (VICE_BROKER_MAX / atCapacity()) this plan does not touch.
|
|
101
134
|
// ---------------------------------------------------------------------------
|
|
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
135
|
function resolveCeilingForRecord() {
|
|
110
136
|
const raw = process.env.VICE_BROKER_MAX;
|
|
111
137
|
if (raw === undefined || raw === "")
|
|
@@ -178,21 +204,21 @@ function writeBrokerRecordFile(stateDir, record) {
|
|
|
178
204
|
return finalPath;
|
|
179
205
|
}
|
|
180
206
|
/** Builds a spawn function that redirects the child's stdout/stderr into a
|
|
181
|
-
* FRESH per-launch log file under logDir (
|
|
182
|
-
*
|
|
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
|
|
183
209
|
* format as the retiring bash supervisor), returning both the spawn
|
|
184
210
|
* closure and the log's path relative to supervisorDir (the epoch
|
|
185
211
|
* record's own `log` field). Shared by both launch paths -- a cold
|
|
186
212
|
* acquire and warm-floor maintenance -- so there is exactly one place that
|
|
187
213
|
* opens a launch log fd.
|
|
188
214
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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. */
|
|
196
222
|
function makeLoggingSpawn(logDir) {
|
|
197
223
|
mkdirSync(logDir, { recursive: true });
|
|
198
224
|
const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
|
|
@@ -204,8 +230,9 @@ function makeLoggingSpawn(logDir) {
|
|
|
204
230
|
};
|
|
205
231
|
}
|
|
206
232
|
/** Writes the epoch record for a just-launched instance -- shared by both
|
|
207
|
-
* launch paths so
|
|
208
|
-
* 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
|
|
209
236
|
* exactly one place regardless of WHY the instance was launched. A
|
|
210
237
|
* granted instance and a still-warm instance are equally real processes; both
|
|
211
238
|
* need a real epoch.json the moment they exist, or plan 04's grant-time
|
|
@@ -233,26 +260,28 @@ function writeEpochForLaunch(record, logRelPath) {
|
|
|
233
260
|
record.epoch = epochRecord.epoch;
|
|
234
261
|
}
|
|
235
262
|
/** Builds the supervision dependency object for withCrashSupervision(),
|
|
236
|
-
* once per launch, so
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* the
|
|
263
|
+
* once per launch, so the real launch path (handleAcquire's own cold arm,
|
|
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.
|
|
247
276
|
*
|
|
248
|
-
*
|
|
277
|
+
* `backend` is a REQUIRED positional parameter, not an
|
|
249
278
|
* optional field a call site may quietly omit. Before this, both real call
|
|
250
279
|
* sites built their deps here WITHOUT it, so `spawnAndRecordInstance()`'s own
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
280
|
+
* unset-parameter default silently took over the moment crash supervision
|
|
281
|
+
* replaced an instance -- a stock instance's crash-respawn or `vice_recycle`
|
|
282
|
+
* could relaunch it with a different backend's argv shape than the one it
|
|
283
|
+
* was actually launched with, leaving a pool member that can never be
|
|
284
|
+
* reached over the binary monitor again while still counting toward
|
|
256
285
|
* countReady()/countTotal(). Making it positional and required is what makes
|
|
257
286
|
* that omission a compile error rather than a silent backend swap: the FIRST
|
|
258
287
|
* launch and every REPLACEMENT of it now build their argv from the SAME
|
|
@@ -275,8 +304,8 @@ function superviseDepsFor(stateDir, state, backend, binmonHost) {
|
|
|
275
304
|
/** Exported ONLY so a test can install withCrashSupervision() through the
|
|
276
305
|
* REAL deps object this module actually uses in production, rather than a
|
|
277
306
|
* hand-built SuperviseChildDeps that can (and did) diverge from it -- the
|
|
278
|
-
* exact blind spot
|
|
279
|
-
* 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
|
|
280
309
|
* `backend: "stock"` directly, so the production builder's missing field was
|
|
281
310
|
* invisible to the whole suite. Same discipline as broker-kill.mts's
|
|
282
311
|
* `_HANDLED_SIGNALS`: an underscore-prefixed alias, never called by any
|
|
@@ -294,37 +323,75 @@ function markDeliberateDeath(instance, respawnAfterKill) {
|
|
|
294
323
|
instance.deliberateKill = true;
|
|
295
324
|
instance.respawnAfterKill = respawnAfterKill;
|
|
296
325
|
}
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// THE WARM-INSTANCE PROFILE-ELIGIBILITY RULE.
|
|
328
|
+
//
|
|
329
|
+
// THE DECISION, stated out loud because two of the three available answers
|
|
330
|
+
// are wrong in ways the CALLER CANNOT DETECT:
|
|
331
|
+
// - Refuse the acquire outright when a mismatched warm instance exists ->
|
|
332
|
+
// warp becomes unusable whenever a warm floor exists (the default is 1,
|
|
333
|
+
// so: essentially always).
|
|
334
|
+
// - Serve the request with the mismatched instance -> the caller asked for
|
|
335
|
+
// warp, got an unwarped machine, and received a confident grant. The knob
|
|
336
|
+
// is a lie and nothing in the response says so.
|
|
337
|
+
// - What this implements: the mismatched instance is INELIGIBLE. The
|
|
338
|
+
// walk skips it and the acquire falls through to the cold arm, which
|
|
339
|
+
// launches a DEDICATED instance for that grant.
|
|
340
|
+
//
|
|
341
|
+
// WHAT MUST NEVER BE ADDED HERE: a retro-warp, and a kill-then-relaunch of a
|
|
342
|
+
// mismatched warm instance. There is no runtime `WarpMode` resource on stock
|
|
343
|
+
// at all (vsync.c:220-241, deliberately), so an existing instance cannot be
|
|
344
|
+
// adjusted -- it can only be ineligible. And "killing or relaunching
|
|
345
|
+
// preemptively to serve a newer request" is a NAMED anti-pattern in this
|
|
346
|
+
// project (CLAUDE.md): it would make an interactive session's emulator vanish
|
|
347
|
+
// because some capture run asked for warp. A test asserts the kill dependency
|
|
348
|
+
// is not called and the instance stays `ready`.
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
/** True when `record` was launched with the SAME profile `requested` asks
|
|
351
|
+
* for. FULLY SYNCHRONOUS by requirement, not by convenience -- see the call
|
|
352
|
+
* site inside selectWarmInstance() below for why.
|
|
353
|
+
*
|
|
354
|
+
* Absent is `{}`: a record with no `profile` field (a pre-33-06 record, a
|
|
355
|
+
* fork launch, a warm-floor spare, or a record a broker restarted mid-phase
|
|
356
|
+
* read from a state directory written before the field existed) is compared
|
|
357
|
+
* as though it carried `{}`, and so is an absent request. Each knob is
|
|
358
|
+
* compared `=== true` on BOTH sides, so `undefined` and `false` are the same
|
|
359
|
+
* request -- which is what makes an absent profile, an explicit `{}` and
|
|
360
|
+
* `{warp:false, headless:false}` one single behaviour rather than three. */
|
|
361
|
+
export function profileEligible(record, requested) {
|
|
362
|
+
const have = record.profile ?? {};
|
|
363
|
+
const want = requested ?? {};
|
|
364
|
+
return (have.warp === true) === (want.warp === true) && (have.headless === true) === (want.headless === true);
|
|
365
|
+
}
|
|
297
366
|
/** Walks `state.instances` for probe-live `ready` candidates, in iteration
|
|
298
367
|
* order, and returns the first that answers a grant-time re-probe (P-02) --
|
|
299
368
|
* or `null` once every candidate has been tried and none answered, letting
|
|
300
|
-
* the caller fall through to a cold launch
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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 --
|
|
304
373
|
* handleRelease() below already deletes a released instance's record
|
|
305
374
|
* outright, so a released instance is structurally absent from
|
|
306
375
|
* `state.instances` and can never be a candidate.
|
|
307
376
|
*
|
|
308
377
|
* A candidate whose grant-time probe FAILS is dropped -- de-registered from
|
|
309
378
|
* `state.instances` -- and identity-verified-killed BEFORE the walk
|
|
310
|
-
* continues to the next candidate, but
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* the
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* request's hot path once the warm floor is configured above its default
|
|
320
|
-
* 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()`
|
|
321
388
|
* plus `state.instances.delete()` -- still happens SYNCHRONOUSLY, in the
|
|
322
389
|
* same tick as the probe failure, before `deps.kill(...)` is even invoked;
|
|
323
|
-
* only the kill's own SETTLEMENT is decoupled from this walk. This
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
328
395
|
* log line's own ordering is decoupled accordingly (see below) -- it can no
|
|
329
396
|
* longer name the kill's resolved stage synchronously, since nothing here
|
|
330
397
|
* waits for it to resolve. The marker is set BEFORE any signal reaches the
|
|
@@ -337,29 +404,50 @@ function markDeliberateDeath(instance, respawnAfterKill) {
|
|
|
337
404
|
* immediately after every `await` (the probe call itself) and BEFORE ever
|
|
338
405
|
* treating a probe-live candidate as the winner -- this is what makes the
|
|
339
406
|
* caller's own "no await between selection and the grant-recording step"
|
|
340
|
-
* property
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* 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
|
|
351
417
|
* `state.instances.get(record.port) === record` (identity, not merely a
|
|
352
418
|
* port-number lookup) closes that case too. */
|
|
353
419
|
async function selectWarmInstance(state, deps) {
|
|
354
420
|
for (const record of Array.from(state.instances.values())) {
|
|
355
421
|
if (record.state !== "ready")
|
|
356
422
|
continue;
|
|
423
|
+
// A SYNCHRONOUS `continue`, sitting
|
|
424
|
+
// immediately beside the `record.state !== "ready"` filter directly
|
|
425
|
+
// above and BEFORE the readiness probe below. That placement is
|
|
426
|
+
// load-bearing twice over, and neither reason is stylistic:
|
|
427
|
+
//
|
|
428
|
+
// 1. It introduces NO new `await` into the region the single-owner
|
|
429
|
+
// `inFlight` launch guard protects. That guard exists because of the
|
|
430
|
+
// 2026-08-01 triple-launch outage and must stay a synchronous
|
|
431
|
+
// check-and-set with no `await` between (CLAUDE.md, regression-
|
|
432
|
+
// tested). A filter placed after the probe would put a fresh
|
|
433
|
+
// suspension point inside that region -- which is why this plan
|
|
434
|
+
// verifies the placement by line-number comparison, not by comment.
|
|
435
|
+
// 2. An ineligible candidate costs no probe at all -- no socket, no
|
|
436
|
+
// round trip, no wait.
|
|
437
|
+
//
|
|
438
|
+
// An ineligible miss falls through EXACTLY as a "no warm instance" miss
|
|
439
|
+
// does: to the caller's own cold arm, which records the one and only
|
|
440
|
+
// grant. It opens no second `state.grants.set()` call, and it never
|
|
441
|
+
// kills, recycles or re-warps the mismatched instance (see
|
|
442
|
+
// profileEligible()'s own banner for why those are excluded by design).
|
|
443
|
+
if (!profileEligible(record, deps.requestedProfile))
|
|
444
|
+
continue;
|
|
357
445
|
const isReady = await deps.probe(record.port);
|
|
358
446
|
// A sibling acquire may have granted OR dropped this exact candidate
|
|
359
447
|
// while this probe was in flight. "Granted" changes record.state;
|
|
360
448
|
// "dropped" removes the record from state.instances outright and never
|
|
361
449
|
// touches record.state -- so map membership must be rechecked too, not
|
|
362
|
-
// merely the state field
|
|
450
|
+
// merely the state field.
|
|
363
451
|
if (record.state !== "ready" || state.instances.get(record.port) !== record) {
|
|
364
452
|
continue;
|
|
365
453
|
}
|
|
@@ -367,27 +455,27 @@ async function selectWarmInstance(state, deps) {
|
|
|
367
455
|
return record;
|
|
368
456
|
}
|
|
369
457
|
// Drop and de-register FIRST, synchronously, before the kill is even
|
|
370
|
-
// invoked -- this is what
|
|
371
|
-
//
|
|
372
|
-
// concurrent sibling's own probe on this same candidate resolves.
|
|
373
|
-
//
|
|
374
|
-
// 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.
|
|
375
463
|
markDeliberateDeath(record, false);
|
|
376
|
-
//
|
|
464
|
+
// Dropping a record is also where its second
|
|
377
465
|
// (`-remotemonitor`) port stops being spoken for -- deleteInstanceRecord()
|
|
378
466
|
// is the ONE place both mutations happen together, so a drop can never
|
|
379
467
|
// leak a port out of the fixed allocation band.
|
|
380
468
|
deleteInstanceRecord(state, record.port);
|
|
381
469
|
// Distinct wording from shutdown()'s own "shutdown complete" line
|
|
382
470
|
// (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
|
|
383
|
-
// path --
|
|
471
|
+
// path -- the standing constraint that a lifecycle decision must be
|
|
384
472
|
// reconstructable from the log after an incident (both 2026-08-01 and
|
|
385
473
|
// 2026-08-02 were diagnosed from broker log lines). Logged BEFORE the
|
|
386
|
-
// kill settles
|
|
474
|
+
// kill settles: the walk does not wait for deps.kill(...) to
|
|
387
475
|
// resolve, so this line can no longer name the kill's resolved stage --
|
|
388
476
|
// that gets its own, separately-logged line once the kill settles,
|
|
389
477
|
// below.
|
|
390
|
-
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)`);
|
|
391
479
|
// Fire-and-forget, matching handleRelease()'s own posture
|
|
392
480
|
// (`verifiedKill(...).catch(...)`, a few hundred lines below in this
|
|
393
481
|
// same file) -- the acquire walk moves on to the next candidate (or
|
|
@@ -414,8 +502,8 @@ async function selectWarmInstance(state, deps) {
|
|
|
414
502
|
* The warm-instance selection arm (selectWarmInstance(), P-01) runs BEFORE
|
|
415
503
|
* the cold-launch arm; `atCapacity()` gates ONLY the cold-launch arm --
|
|
416
504
|
* checked only once selectWarmInstance() has already answered `null` (no
|
|
417
|
-
* probe-live candidate available) -- NOT before either arm
|
|
418
|
-
*
|
|
505
|
+
* probe-live candidate available) -- NOT before either arm. A full host
|
|
506
|
+
* still refuses a fresh cold launch before
|
|
419
507
|
* ever touching the port allocator, but a ready, probe-live warm candidate
|
|
420
508
|
* is grantable even when the ceiling is already reached: granting it
|
|
421
509
|
* creates no NEW instance and does not raise `countTotal()`, so refusing to
|
|
@@ -434,17 +522,17 @@ async function selectWarmInstance(state, deps) {
|
|
|
434
522
|
* control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
|
|
435
523
|
* retry it later rather than refusing it. */
|
|
436
524
|
export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
437
|
-
//
|
|
525
|
+
// The readiness probe is backend-aware, from the SAME threaded-down
|
|
438
526
|
// verdict handleAcquire already uses for buildViceArgs() -- on stock the port
|
|
439
527
|
// speaks the binary monitor, so an HTTP POST there can never succeed.
|
|
440
|
-
const backend = deps.backend ?? "
|
|
528
|
+
const backend = deps.backend ?? "stock";
|
|
441
529
|
const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
|
|
442
530
|
// Textually a verifiedKill( call site, not merely a reference -- reused
|
|
443
|
-
// UNCHANGED from broker-kill.mts
|
|
444
|
-
//
|
|
531
|
+
// UNCHANGED from broker-kill.mts, never re-derived, and never replaced by
|
|
532
|
+
// a bare process.kill().
|
|
445
533
|
const kill = deps.kill ?? ((opts) => verifiedKill(opts));
|
|
446
534
|
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
447
|
-
const winner = await selectWarmInstance(state, { probe, kill, log });
|
|
535
|
+
const winner = await selectWarmInstance(state, { probe, kill, log, requestedProfile: deps.profile });
|
|
448
536
|
let record;
|
|
449
537
|
if (winner) {
|
|
450
538
|
record = winner;
|
|
@@ -467,12 +555,20 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
467
555
|
state,
|
|
468
556
|
stateDir,
|
|
469
557
|
allocatePort: nextFreePort,
|
|
470
|
-
//
|
|
558
|
+
// The SAME local `backend` const resolved at the
|
|
471
559
|
// top of this function feeds BOTH the initial argv (here) and the
|
|
472
560
|
// supervision deps below, so a crash-respawn of this instance can never
|
|
473
561
|
// build a different backend's argv than the launch it replaces.
|
|
474
562
|
backend,
|
|
475
563
|
allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
|
|
564
|
+
// The profile the warm arm just
|
|
565
|
+
// refused to compromise on reaches buildViceArgs() here, and is
|
|
566
|
+
// mirrored onto the fresh InstanceRecord by spawnAndRecordInstance()
|
|
567
|
+
// in the SAME step -- so this instance's recorded profile and its real
|
|
568
|
+
// argv are written together and cannot disagree. This is the arm that
|
|
569
|
+
// makes "a dedicated instance for that grant" true rather than
|
|
570
|
+
// aspirational.
|
|
571
|
+
profile: deps.profile,
|
|
476
572
|
spawnFactory: deps.buildColdSpawnFactory ??
|
|
477
573
|
((port) => {
|
|
478
574
|
const supervisorDir = join(stateDir, String(port));
|
|
@@ -482,46 +578,76 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
482
578
|
}),
|
|
483
579
|
});
|
|
484
580
|
if (!result.ok) {
|
|
581
|
+
// `result.reason` passes
|
|
582
|
+
// straight through -- `AcquireLaunchResult`'s reason union
|
|
583
|
+
// ("launch_in_flight" | "no_free_port" | "no_free_text_port") is a
|
|
584
|
+
// subset of `AcquireOutcome`'s, so a failed text-port allocation's own
|
|
585
|
+
// `no_free_text_port` reaches the control plane as its own distinct
|
|
586
|
+
// code (broker-control.mts's ControlErrorCode) rather than collapsing
|
|
587
|
+
// to `internal` or to the generic `no_free_port`.
|
|
485
588
|
return { ok: false, reason: result.reason };
|
|
486
589
|
}
|
|
487
590
|
if (result.record.pid === null) {
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
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).
|
|
498
607
|
deleteInstanceRecord(state, result.record.port);
|
|
499
608
|
return { ok: false, reason: "internal" };
|
|
500
609
|
}
|
|
501
610
|
record = result.record;
|
|
502
|
-
// Only the cold-launch arm ever writes a FRESH epoch record --
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
611
|
+
// Only the cold-launch arm ever writes a FRESH epoch record here --
|
|
612
|
+
// selectWarmInstance()'s own winner already has one. WHY that is true
|
|
613
|
+
// changed without changing that it IS true: a
|
|
614
|
+
// ready, ungranted candidate no longer comes from a warm-floor pass's
|
|
615
|
+
// own onLaunched hook (retired along with the floor) -- it comes from
|
|
616
|
+
// broker-launch.mts's own crash-supervision respawn path
|
|
617
|
+
// (launchSupervised(), which writes its own epoch record via
|
|
618
|
+
// deps.epoch.writeEpochRecord() on every launch and every respawn).
|
|
619
|
+
// Either way, rewriting the epoch here would advance an epoch no restart
|
|
620
|
+
// caused, which the container-side assertSameMachine() would read as a
|
|
621
|
+
// machine change.
|
|
507
622
|
writeEpochForLaunch(record, lastLogRelPath);
|
|
508
623
|
}
|
|
509
624
|
// THE single grant-recording step, fed by both arms above -- no `await`
|
|
510
625
|
// between resolving `record` (whichever arm produced it) and this
|
|
511
626
|
// synchronous pair, so two concurrent acquires can never both grant the
|
|
512
|
-
// SAME record (
|
|
627
|
+
// SAME record (see selectWarmInstance()'s own re-check for
|
|
513
628
|
// the other half of that guarantee).
|
|
514
629
|
state.grants.set(requestId, { id: requestId, port: record.port, grantedAt: Date.now(), pid: record.pid });
|
|
515
630
|
record.state = "granted";
|
|
516
631
|
return {
|
|
517
632
|
ok: true,
|
|
518
|
-
grant: {
|
|
633
|
+
grant: {
|
|
634
|
+
port: record.port,
|
|
635
|
+
url: record.url,
|
|
636
|
+
epochFile: record.epochFile,
|
|
637
|
+
supervisorDir: record.supervisorDir,
|
|
638
|
+
// Key omitted entirely when the record has none --
|
|
639
|
+
// the fork case, and (until a later plan closes the port-allocation
|
|
640
|
+
// degrade path) a stock instance whose second port allocation itself
|
|
641
|
+
// failed. Same key-omitted-when-undefined idiom
|
|
642
|
+
// spawnAndRecordInstance() already uses for this same field.
|
|
643
|
+
...(record.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: record.remoteMonitorPort }),
|
|
644
|
+
},
|
|
519
645
|
};
|
|
520
646
|
}
|
|
521
647
|
/** Answers the `status` control-plane request: one entry per instance,
|
|
522
648
|
* computed on demand from the SAME in-memory map every other count reads --
|
|
523
649
|
* strictly better than the dropped broker-instances.json projection, which
|
|
524
|
-
* could go stale between passes
|
|
650
|
+
* could go stale between passes. */
|
|
525
651
|
function handleStatus(state) {
|
|
526
652
|
return Array.from(state.instances.values()).map((r) => ({
|
|
527
653
|
port: r.port,
|
|
@@ -529,64 +655,72 @@ function handleStatus(state) {
|
|
|
529
655
|
state: r.state,
|
|
530
656
|
reason: r.reason,
|
|
531
657
|
epoch: typeof r.epoch === "number" ? r.epoch : null,
|
|
532
|
-
|
|
658
|
+
// "at least one channel is claimed" -- promoted from
|
|
659
|
+
// a single-field check, byte-identical wire shape, meaning stated
|
|
660
|
+
// explicitly.
|
|
661
|
+
hasMonitorClient: Object.keys(r.monitorClients).length > 0,
|
|
533
662
|
}));
|
|
534
663
|
}
|
|
535
664
|
/** Resolves a monitor_claim/monitor_release target the SAME way
|
|
536
665
|
* handleRelease() and handleRecycleForRealBroker() already resolve theirs:
|
|
537
666
|
* `targetId` is a grant id, looked up in state.grants for its port, then
|
|
538
667
|
* the instance at that port. Returns `null` for an unknown target_id/port
|
|
539
|
-
* so callers answer `bad_request`, never `internal
|
|
540
|
-
* acceptance criterion). */
|
|
668
|
+
* so callers answer `bad_request`, never `internal`. */
|
|
541
669
|
function resolveInstanceForMonitorTarget(targetId, state) {
|
|
542
670
|
const grant = state.grants.get(targetId);
|
|
543
671
|
if (!grant)
|
|
544
672
|
return null;
|
|
545
673
|
return state.instances.get(grant.port) ?? null;
|
|
546
674
|
}
|
|
547
|
-
/** Answers `monitor_claim` (
|
|
548
|
-
*
|
|
549
|
-
* claim is refused by name
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* the SAME grant lookup
|
|
553
|
-
* use) and "the
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
|
|
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
|
|
678
|
+
* before any second `connect()` is ever attempted -- the one state stock
|
|
679
|
+
* VICE cannot report and no client-side heuristic can diagnose. `targetId`
|
|
680
|
+
* doubles as both "which instance" (resolved via the SAME grant lookup
|
|
681
|
+
* handleRelease()/handleRecycleForRealBroker() already use) and "the
|
|
682
|
+
* requesting grant's own identity" -- the claim IS the grant, so there is
|
|
683
|
+
* no separate identity to carry. A repeated claim from the SAME grant on
|
|
684
|
+
* the SAME channel is idempotent (`ok: true`, no second holder created); a
|
|
685
|
+
* claim from a DIFFERENT grant while that channel already has a holder is
|
|
686
|
+
* refused, naming the current holder and the channel (T-02-18) -- never the
|
|
687
|
+
* emulator's own fault. A DIFFERENT channel's holder is irrelevant to this
|
|
688
|
+
* decision -- claiming one channel never evicts or is refused by the
|
|
689
|
+
* other's holder. */
|
|
690
|
+
export function handleMonitorClaim(requestId, targetId, channel, state) {
|
|
559
691
|
void requestId; // correlation only -- the claim's own identity is targetId itself
|
|
560
692
|
const instance = resolveInstanceForMonitorTarget(targetId, state);
|
|
561
693
|
if (!instance)
|
|
562
694
|
return { ok: false, code: "bad_request" };
|
|
563
|
-
const existing = instance.
|
|
695
|
+
const existing = instance.monitorClients[channel];
|
|
564
696
|
if (!existing) {
|
|
565
|
-
instance.
|
|
697
|
+
instance.monitorClients[channel] = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
|
|
566
698
|
return { ok: true };
|
|
567
699
|
}
|
|
568
700
|
if (existing.grantId === targetId) {
|
|
569
|
-
return { ok: true }; // idempotent repeat from the SAME grant -- no second holder
|
|
701
|
+
return { ok: true }; // idempotent repeat from the SAME grant on the SAME channel -- no second holder
|
|
570
702
|
}
|
|
571
|
-
return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid } };
|
|
703
|
+
return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid, channel } };
|
|
572
704
|
}
|
|
573
|
-
/** Answers `monitor_release` (
|
|
574
|
-
* when `targetId` names
|
|
575
|
-
*
|
|
576
|
-
*
|
|
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
|
|
577
710
|
* release as a success, matching the container-side client's own documented
|
|
578
711
|
* tolerance for releasing a socket the broker already cleared. */
|
|
579
|
-
export function handleMonitorRelease(requestId, targetId, state) {
|
|
712
|
+
export function handleMonitorRelease(requestId, targetId, channel, state) {
|
|
580
713
|
void requestId; // correlation only, matching handleMonitorClaim()'s own posture
|
|
581
714
|
const instance = resolveInstanceForMonitorTarget(targetId, state);
|
|
582
715
|
if (!instance)
|
|
583
716
|
return { ok: false, code: "bad_request" };
|
|
584
|
-
|
|
717
|
+
const existing = instance.monitorClients[channel];
|
|
718
|
+
if (!existing)
|
|
585
719
|
return { ok: true }; // already cleared -- tolerated, not an error
|
|
586
|
-
if (
|
|
720
|
+
if (existing.grantId !== targetId) {
|
|
587
721
|
return { ok: false, code: "denied" };
|
|
588
722
|
}
|
|
589
|
-
clearMonitorClient(instance);
|
|
723
|
+
clearMonitorClient(instance, channel);
|
|
590
724
|
return { ok: true };
|
|
591
725
|
}
|
|
592
726
|
/** Resolves a recycle target's emulator child pid from THIS broker's own
|
|
@@ -664,90 +798,34 @@ async function handleRecycleForRealBroker(targetId, state) {
|
|
|
664
798
|
const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
|
|
665
799
|
return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
|
|
666
800
|
}
|
|
667
|
-
/** The
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
|
|
679
|
-
|
|
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({
|
|
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
|
|
806
|
+
* replaces, this one never launches anything -- it wires only
|
|
807
|
+
* broker-launch.mjs's real promoteLaunchingInstances() against this
|
|
808
|
+
* broker's own state and the backend-aware readiness probe, so a
|
|
809
|
+
* `launching` instance (however it got there -- a cold acquire's own
|
|
810
|
+
* instance, or a crash-respawn) is promoted to `ready` the moment it
|
|
811
|
+
* answers. */
|
|
812
|
+
function promoteLaunchingForRealBroker(state, backend) {
|
|
813
|
+
return promoteLaunchingInstances({
|
|
689
814
|
state,
|
|
690
|
-
stateDir,
|
|
691
815
|
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.
|
|
816
|
+
// Same backend-aware probe route as handleAcquire's, from the
|
|
817
|
+
// SAME resolved verdict this function already receives.
|
|
718
818
|
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
819
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
733
820
|
});
|
|
734
821
|
}
|
|
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
822
|
/** Releases a grant and identity-verified-kills its instance -- but ONLY
|
|
745
823
|
* when the port's CURRENT occupant is proven to be the SAME process this
|
|
746
824
|
* grant was actually issued for (its own recorded `pid`, set at grant time
|
|
747
825
|
* by handleAcquire()'s single state.grants.set() call site), not merely
|
|
748
|
-
* "whatever now holds this port number." This
|
|
749
|
-
*
|
|
750
|
-
*
|
|
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
|
|
751
829
|
* unsafe against any OTHER event that swaps a port's occupant without also
|
|
752
830
|
* clearing the grant -- the clearest independent example being an ordinary
|
|
753
831
|
* (non-deliberate) crash of a GRANTED instance that hits the give-up
|
|
@@ -771,7 +849,7 @@ export const _maintainWarmFloorForRealBroker = maintainWarmFloorForRealBroker;
|
|
|
771
849
|
* grant's own recorded pid, and the current occupant's pid (or "none" when
|
|
772
850
|
* the port is empty), worded distinctly from both the shutdown-complete
|
|
773
851
|
* line (broker-kill.mts) and the grant-time-probe-failure line this same
|
|
774
|
-
* file already emits (
|
|
852
|
+
* file already emits (the standing constraint that a lifecycle decision
|
|
775
853
|
* must be reconstructable from the log after an incident).
|
|
776
854
|
*
|
|
777
855
|
* A legitimate recycle (broker-launch.mts's handleExit() recycle branch)
|
|
@@ -785,15 +863,16 @@ export function handleRelease(requestId, state) {
|
|
|
785
863
|
const instance = state.instances.get(grant.port);
|
|
786
864
|
if (instance && instance.pid === grant.pid) {
|
|
787
865
|
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
|
|
866
|
+
// Plan 05: releasing clears monitor-client ownership (every channel) as
|
|
867
|
+
// a side effect -- redundant with the instance-map deletion two lines
|
|
868
|
+
// below (the WHOLE record, monitorClients included, is going away), but
|
|
869
|
+
// explicit for the same reason GrantRecord's own clearing is explicit
|
|
870
|
+
// here: the
|
|
792
871
|
// instance-map deletion is a Task-2-era invariant this task must not
|
|
793
872
|
// depend on silently continuing to hold.
|
|
794
873
|
clearMonitorClient(instance);
|
|
795
874
|
state.grants.delete(requestId);
|
|
796
|
-
//
|
|
875
|
+
// Kill-never-recycle means this instance is gone for good, so its
|
|
797
876
|
// second (`-remotemonitor`) port must go back to the allocator with it.
|
|
798
877
|
deleteInstanceRecord(state, grant.port);
|
|
799
878
|
verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
|
|
@@ -811,7 +890,7 @@ export function handleRelease(requestId, state) {
|
|
|
811
890
|
}
|
|
812
891
|
async function run(args) {
|
|
813
892
|
const finalPath = join(args.stateDir, "broker.json");
|
|
814
|
-
//
|
|
893
|
+
// An early tracer-era "refuse to overwrite
|
|
815
894
|
// a record naming a currently-live pid" pre-check is GONE -- REPLACED by
|
|
816
895
|
// the bind-before-write singleton guard below, not merely extended
|
|
817
896
|
// alongside it (this phase's own plan-time note is explicit: the
|
|
@@ -824,7 +903,7 @@ async function run(args) {
|
|
|
824
903
|
// port itself already held" -- and broker.json becomes a pure ARBITER of
|
|
825
904
|
// that question's two possible causes, never a gate in its own right.
|
|
826
905
|
//
|
|
827
|
-
//
|
|
906
|
+
// The mandatory start-time banner, printed unconditionally and
|
|
828
907
|
// BEFORE anything else in this function runs -- an operator must be told
|
|
829
908
|
// what a Ctrl-C costs before there is anything running for them to Ctrl-C.
|
|
830
909
|
process.stderr.write(`${startupBanner()}\n`);
|
|
@@ -834,17 +913,17 @@ async function run(args) {
|
|
|
834
913
|
const startedAt = new Date().toISOString(); // FIXED across every heartbeat refresh -- see writeBrokerRecordFile()'s callers below
|
|
835
914
|
const pollMs = Number(process.env.VICE_BROKER_POLL_MS) || 500;
|
|
836
915
|
const controlPort = resolveControlPort();
|
|
837
|
-
//
|
|
916
|
+
// The unconditional startup reap runs BEFORE the
|
|
838
917
|
// control listener accepts and before anything is launched. A SIGKILLed
|
|
839
918
|
// prior broker never ran a shutdown path, so this is the only place the
|
|
840
919
|
// "every emulator this project's port band could be squatting is either
|
|
841
920
|
// ours or a human's own work" guarantee can be enforced -- no marker file
|
|
842
921
|
// is consulted, per this reap's own header comment in broker-kill.mts.
|
|
843
922
|
//
|
|
844
|
-
// NOTE
|
|
923
|
+
// NOTE: this reap runs UNCONDITIONALLY, before the bind attempt
|
|
845
924
|
// below -- including for a process that goes on to LOSE the singleton
|
|
846
925
|
// race a moment later (see the EADDRINUSE handling below). That ordering
|
|
847
|
-
// is
|
|
926
|
+
// is deliberate and already established and tested
|
|
848
927
|
// (broker-kill.test.ts's own structural source-order check); this task
|
|
849
928
|
// does not change it. A losing second broker's own reap pass is an
|
|
850
929
|
// accepted, pre-existing consequence of "the reap is unconditional" --
|
|
@@ -855,59 +934,111 @@ async function run(args) {
|
|
|
855
934
|
nextEpochFor,
|
|
856
935
|
writeEpochRecord,
|
|
857
936
|
});
|
|
858
|
-
//
|
|
937
|
+
// Resolved ONCE here, after the unconditional
|
|
859
938
|
// startup reap and BEFORE the control listener binds -- never re-read per
|
|
860
939
|
// launch, and never called from inside broker-launch.mts's `inFlight`
|
|
861
940
|
// single-owner guard (this call sits entirely outside it; no launch is
|
|
862
941
|
// even possible yet at this point in run()). `supervisorDir: args.stateDir`
|
|
863
|
-
// is passed explicitly -- args.stateDir IS `.
|
|
942
|
+
// is passed explicitly -- args.stateDir IS `.c64-re-tools/supervisor` under this
|
|
864
943
|
// broker's own repo root (see parseArgs() above), so this is the SAME
|
|
865
944
|
// directory repo-root.ts's supervisorDir() would resolve to, without this
|
|
866
945
|
// 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
|
-
//
|
|
946
|
+
// (backend-detect.mts's own header comment explains why it cannot).
|
|
947
|
+
// There is nothing left to detect -- the resolved
|
|
948
|
+
// `backend` is always `"stock"`; what this call still does is resolve the
|
|
949
|
+
// binary's own identity for the log line below and initialise the
|
|
950
|
+
// capability cache backend-detect.mts's own record depends on.
|
|
872
951
|
const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
|
|
873
952
|
const backend = backendResult.backend;
|
|
874
|
-
process.stderr.write(`vice-broker: backend "${backend}" (
|
|
875
|
-
//
|
|
953
|
+
process.stderr.write(`vice-broker: backend "${backend}" (binary: ${backendResult.binPath})\n`);
|
|
954
|
+
// THE BROKER mints/verifies the
|
|
955
|
+
// Ghidra runs-root handle here -- after the unconditional startup reap
|
|
956
|
+
// above, and BEFORE the control listener below accepts a single
|
|
957
|
+
// connection -- so a container-side MCP server with no host tooling of
|
|
958
|
+
// its own still finds the handle in place the moment it can reach this
|
|
959
|
+
// broker at all. This is deliberately NOT the only call site:
|
|
960
|
+
// resolveGhidraProject() (ghidra-project.mts) calls the same function as
|
|
961
|
+
// an idempotent precondition, because two host-side routes never involve
|
|
962
|
+
// a broker at all -- the direct spawn of resources/host-tool.mjs from
|
|
963
|
+
// host-tool-client.ts:269-273 (the everyday route on a host with no
|
|
964
|
+
// devcontainer, and the route CI uses), and tests importing that
|
|
965
|
+
// artifact directly. Both callers write the identical relative-target
|
|
966
|
+
// link, so a race between them is a benign EEXIST, not a conflict (see
|
|
967
|
+
// ensureGhidraRunsHandle()'s own header). The negative rule: container-
|
|
968
|
+
// side code must NEVER mint this handle -- the link target is relative
|
|
969
|
+
// and correct only when written from the host's view of the workspace.
|
|
970
|
+
//
|
|
971
|
+
// Handled WITHOUT throwing: run() has no try/catch around this region and
|
|
972
|
+
// the broker must start regardless of the outcome here -- it serves
|
|
973
|
+
// twelve allowlisted tool ids and only one of them (ghidra.analyze) needs
|
|
974
|
+
// this handle. A refusal is surfaced as ONE stderr line naming the
|
|
975
|
+
// consequence; every other tool id is unaffected.
|
|
976
|
+
const ghidraHandleResult = ensureGhidraRunsHandle(args.repoRoot);
|
|
977
|
+
if (ghidraHandleResult.ok) {
|
|
978
|
+
process.stderr.write(`vice-broker: ghidra runs handle ${ghidraHandleResult.handle} -> ${ghidraHandleResult.target}\n`);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
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`);
|
|
982
|
+
}
|
|
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.
|
|
876
984
|
let listener;
|
|
877
985
|
try {
|
|
878
986
|
listener = await startControlListener({
|
|
879
987
|
host: controlHost,
|
|
880
988
|
port: controlPort,
|
|
881
989
|
token,
|
|
882
|
-
onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state, {
|
|
990
|
+
onAcquire: (requestId, profile) => handleAcquire(requestId, args.stateDir, state, {
|
|
883
991
|
backend,
|
|
884
|
-
//
|
|
992
|
+
// Threaded down to
|
|
885
993
|
// acquirePortAndLaunch()'s own gate (backend === "stock"); this
|
|
886
|
-
// callback does
|
|
994
|
+
// callback does not re-read any environment variable itself.
|
|
887
995
|
allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
|
|
996
|
+
// The ALREADY-NARROWED
|
|
997
|
+
// profile broker-control.mts handed this callback. Nothing here
|
|
998
|
+
// re-validates it and nothing here reads a raw wire field --
|
|
999
|
+
// normaliseLaunchProfile() is the single narrowing site, and it ran
|
|
1000
|
+
// before this callback was ever invoked.
|
|
1001
|
+
profile,
|
|
888
1002
|
}),
|
|
889
1003
|
onRelease: (requestId) => handleRelease(requestId, state),
|
|
890
1004
|
onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
|
|
891
1005
|
onStatus: () => handleStatus(state),
|
|
892
|
-
|
|
893
|
-
|
|
1006
|
+
// Its OWN callback, wired alongside
|
|
1007
|
+
// (never derived from) the other six above -- handed only
|
|
1008
|
+
// `args.repoRoot` and a stderr logger, never this broker's `state` map,
|
|
1009
|
+
// so it structurally cannot reach lease state through this closure.
|
|
1010
|
+
// Deliberately supplies no timeout, and that is
|
|
1011
|
+
// authoritative here, not an omission -- the per-tool budget table
|
|
1012
|
+
// inside runHostTool()/hostToolTimeoutMs() (host-tool.mts) is the ONE
|
|
1013
|
+
// place a budget is decided, and no wire field carries one across the
|
|
1014
|
+
// seam at all (the `deps.timeoutMs` this callback could pass is an
|
|
1015
|
+
// in-process test seam, not something a caller's request ever
|
|
1016
|
+
// supplies). A reader arriving here from the artifact this plan's
|
|
1017
|
+
// completeness case checks should find this comment as the answer,
|
|
1018
|
+
// not an apparent gap.
|
|
1019
|
+
onHostTool: (raw) => runHostTool(raw, {
|
|
1020
|
+
repoRoot: args.repoRoot,
|
|
1021
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
1022
|
+
}),
|
|
1023
|
+
onMonitorClaim: (requestId, targetId, channel) => handleMonitorClaim(requestId, targetId, channel, state),
|
|
1024
|
+
onMonitorRelease: (requestId, targetId, channel) => handleMonitorRelease(requestId, targetId, channel, state),
|
|
894
1025
|
onHostState: () => ({
|
|
895
1026
|
pid: process.pid,
|
|
896
1027
|
startedAt,
|
|
897
1028
|
nodeVersion: process.version,
|
|
898
1029
|
viceBin: resolveViceBinForHostState(),
|
|
899
|
-
warmFloor: resolveWarmFloorForRecord(),
|
|
900
1030
|
maxInstances: resolveCeilingForRecord(),
|
|
901
1031
|
basePort: resolveBasePort(),
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
1032
|
+
// The verdict THIS process resolved once, at
|
|
1033
|
+
// startup, above -- kept on the wire because text-tools.ts's own
|
|
1034
|
+
// broker-identity cross-check (out of this plan's scope) still reads
|
|
1035
|
+
// it. Never a second resolvedBackend() call.
|
|
905
1036
|
backend,
|
|
906
1037
|
}),
|
|
907
1038
|
});
|
|
908
1039
|
}
|
|
909
1040
|
catch (e) {
|
|
910
|
-
//
|
|
1041
|
+
// The singleton race closes here. A well-known TCP port
|
|
911
1042
|
// cannot be bound twice, so EADDRINUSE is the kernel enforcing the
|
|
912
1043
|
// singleton -- but the guarantee holds only while the control port
|
|
913
1044
|
// keeps its default (two brokers deliberately configured onto
|
|
@@ -942,7 +1073,7 @@ async function run(args) {
|
|
|
942
1073
|
process.exitCode = 1;
|
|
943
1074
|
return;
|
|
944
1075
|
}
|
|
945
|
-
//
|
|
1076
|
+
// Every catchable shutdown path (SIGTERM/SIGINT/SIGHUP, an uncaught
|
|
946
1077
|
// exception, an unhandled rejection, normal exit) converges on ONE
|
|
947
1078
|
// re-entrant-safe teardown that identity-verified-kills every instance
|
|
948
1079
|
// this broker launched and clears the map unconditionally
|
|
@@ -950,12 +1081,22 @@ async function run(args) {
|
|
|
950
1081
|
// nothing to tear down before that point.
|
|
951
1082
|
registerShutdownHandlers({ state });
|
|
952
1083
|
// A successful bind writes the record UNCONDITIONALLY, overwriting
|
|
953
|
-
// whatever was there -- the bind itself is the proof of singleton status
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
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
|
|
957
1091
|
// even though no consumer parses it beyond a status message, because a
|
|
958
1092
|
// human reading this file by hand benefits from the full echo.
|
|
1093
|
+
//
|
|
1094
|
+
// node_exec_path is process.execPath, not something threaded in from
|
|
1095
|
+
// outside: exec() replaces the process image, so whatever interpreter the
|
|
1096
|
+
// launcher resolved and gated IS this process's own execPath by the time
|
|
1097
|
+
// this line runs -- the record tells the truth without either side having
|
|
1098
|
+
// to pass anything, and it stays truthful even when this broker was
|
|
1099
|
+
// started directly, bypassing the launcher entirely.
|
|
959
1100
|
let record = {
|
|
960
1101
|
version: 1,
|
|
961
1102
|
written_by: WRITTEN_BY,
|
|
@@ -963,17 +1104,17 @@ async function run(args) {
|
|
|
963
1104
|
started_at: startedAt,
|
|
964
1105
|
heartbeat_at: new Date().toISOString(),
|
|
965
1106
|
node_version: process.version,
|
|
1107
|
+
node_exec_path: process.execPath,
|
|
966
1108
|
control_host: listener.host,
|
|
967
1109
|
control_port: listener.port,
|
|
968
1110
|
control_token: token, // never logged -- T-01.6.2-02
|
|
969
|
-
warm_floor: resolveWarmFloorForRecord(),
|
|
970
1111
|
max_instances: resolveCeilingForRecord(),
|
|
971
1112
|
base_port: resolveBasePort(),
|
|
972
1113
|
poll_ms: pollMs,
|
|
973
1114
|
dry_run: args.dryRun,
|
|
974
1115
|
};
|
|
975
1116
|
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`);
|
|
1117
|
+
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
1118
|
const heartbeatMs = Number(process.env.VICE_BROKER_HEARTBEAT_MS) || 30000;
|
|
978
1119
|
setInterval(() => {
|
|
979
1120
|
// The refresh path goes through the SAME atomic tmp-then-rename choke
|
|
@@ -984,18 +1125,21 @@ async function run(args) {
|
|
|
984
1125
|
writeBrokerRecordFile(args.stateDir, record);
|
|
985
1126
|
}, heartbeatMs);
|
|
986
1127
|
// The fixed-order evaluation pass (runBrokerPass, broker-launch.mts):
|
|
987
|
-
// serve pending acquires, then
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
1128
|
+
// serve pending acquires, then promote launching -> ready -- mirroring
|
|
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
|
|
1135
|
+
// arrival-ordered pending-acquire structure this listener instance owns
|
|
1136
|
+
// (an early stubbed `serveAcquires: () => {}` comment reserved exactly
|
|
1137
|
+
// this room) -- an acquire queued because a launch was
|
|
1138
|
+
// already in flight is retried here, on the SAME pass that also promotes
|
|
1139
|
+
// any newly-ready instance, so a stalled pass shows up as a stale record
|
|
1140
|
+
// rather than a silently wrong one. Re-entrancy guarded: a pass that is
|
|
1141
|
+
// still running (e.g. a slow readiness probe against a genuinely slow
|
|
1142
|
+
// host) is never overlapped by the next tick.
|
|
999
1143
|
let passInFlight = false;
|
|
1000
1144
|
setInterval(() => {
|
|
1001
1145
|
if (passInFlight)
|
|
@@ -1003,7 +1147,7 @@ async function run(args) {
|
|
|
1003
1147
|
passInFlight = true;
|
|
1004
1148
|
runBrokerPass({
|
|
1005
1149
|
serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
|
|
1006
|
-
|
|
1150
|
+
promoteLaunching: () => promoteLaunchingForRealBroker(state, backend),
|
|
1007
1151
|
})
|
|
1008
1152
|
.catch((e) => {
|
|
1009
1153
|
process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
|
|
@@ -1014,8 +1158,8 @@ async function run(args) {
|
|
|
1014
1158
|
}, pollMs);
|
|
1015
1159
|
}
|
|
1016
1160
|
/** Parses argv, evaluates the container guard FIRST -- before any state
|
|
1017
|
-
* directory is read or written and before anything is spawned
|
|
1018
|
-
*
|
|
1161
|
+
* directory is read or written and before anything is spawned -- then
|
|
1162
|
+
* runs the long-lived broker. Never calls process.exit(); always sets
|
|
1019
1163
|
* process.exitCode so pending I/O flushes first. */
|
|
1020
1164
|
export function main(argv = process.argv.slice(2)) {
|
|
1021
1165
|
let args;
|