@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,29 +2,32 @@
|
|
|
2
2
|
// Compiled by `tsc` from broker-launch.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
|
// broker-launch.mts
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// goes through tryLaunchOne(), which
|
|
12
|
-
// guarantee mechanical rather than a
|
|
13
|
-
// race test
|
|
14
|
-
// in-process mechanism (collapsed from a
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
9
|
+
// This module owns three concerns that started life separately and were
|
|
10
|
+
// folded together here: the single `in_flight` launch-guard owner -- every
|
|
11
|
+
// launch call site in the whole broker goes through tryLaunchOne(), which
|
|
12
|
+
// is what makes the single-owner guarantee mechanical rather than a
|
|
13
|
+
// convention a concurrency race test could silently violate -- PLUS the
|
|
14
|
+
// readiness probe's single in-process mechanism (collapsed from a
|
|
15
|
+
// three-way branch down to one; see probeReady()'s own header comment
|
|
16
|
+
// below for the full record of that collapse and its later amendment),
|
|
17
|
+
// the launching -> ready promotion sweep (promoteLaunchingInstances() --
|
|
18
|
+
// this used to be step one inside a warm-floor maintenance function that
|
|
19
|
+
// speculatively pre-launched spare instances; that floor is RETIRED and
|
|
20
|
+
// VICE now launches strictly on demand, but the promotion sweep outlived
|
|
21
|
+
// it), and the fixed-order evaluation pass both surviving concerns run
|
|
22
|
+
// through.
|
|
19
23
|
//
|
|
20
|
-
//
|
|
21
|
-
// (C2/D-23), absorbing resources/vice-supervisor.sh wholesale: superviseChild()
|
|
24
|
+
// This file also grew a real per-child supervisor: superviseChild()
|
|
22
25
|
// launches an instance through tryLaunchOne() (the SAME single guarded
|
|
23
26
|
// primitive above) and installs an exit handler on the spawned child that
|
|
24
27
|
// respawns on crash (doubling backoff, clamped at a ceiling), gives up
|
|
25
28
|
// cleanly after too many crashes inside a window, never respawns a
|
|
26
29
|
// deliberately-killed instance, and writes the per-instance boot/crash log
|
|
27
|
-
//
|
|
30
|
+
// at the exact path shape the retiring bash supervisor used.
|
|
28
31
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
29
32
|
import { mkdirSync, mkdtempSync, openSync, closeSync, existsSync } from "node:fs";
|
|
30
33
|
import { join, basename } from "node:path";
|
|
@@ -33,13 +36,13 @@ import { tmpdir } from "node:os";
|
|
|
33
36
|
// synchronous check, synchronous set, released in a finally, with no
|
|
34
37
|
// `await` between the check and the set.
|
|
35
38
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
39
|
+
// Launch PRIORITY is layered on this owner, and never replaces or weakens
|
|
40
|
+
// it. An in-flight boot always completes and is NEVER killed or abandoned
|
|
41
|
+
// to serve a later arrival -- preemption was considered and rejected
|
|
42
|
+
// because a kill/relaunch overlap re-creates the exact concurrent-spawn
|
|
43
|
+
// window the 2026-08-01 outage came from (one SEGV, one exit 1, one exit 0
|
|
44
|
+
// at the identical spawn second). Once a boot reaches `ready`, a waiting
|
|
45
|
+
// request takes it
|
|
43
46
|
// regardless of which reason booted it (vice-broker.mts's
|
|
44
47
|
// selectWarmInstance() performs no `reason` check at all -- proven by
|
|
45
48
|
// vice-broker-acquire.test.ts). Priority governs only which REASON wins
|
|
@@ -50,10 +53,9 @@ let inFlight = false;
|
|
|
50
53
|
// only, never a second guard: nothing branches on this value's presence to
|
|
51
54
|
// decide whether a launch may proceed (that is `inFlight` alone, checked
|
|
52
55
|
// and set synchronously exactly as before). Its only consumer is the
|
|
53
|
-
// launch-slot decision log line
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// lines).
|
|
56
|
+
// launch-slot decision log line: a lifecycle decision must be
|
|
57
|
+
// reconstructable from the log after an incident -- both the 2026-08-01
|
|
58
|
+
// and 2026-08-02 outages were diagnosed from broker log lines.
|
|
57
59
|
let inFlightReason = null;
|
|
58
60
|
/** True while a launch is in progress -- exported for the race test plan 02
|
|
59
61
|
* writes against two concurrent tryLaunchOne() calls. */
|
|
@@ -65,12 +67,43 @@ export function isLaunchInFlight() {
|
|
|
65
67
|
// most once per process -- the repo-root.ts `warnedEnvOutsideFrom` gate
|
|
66
68
|
// pattern, reused here.
|
|
67
69
|
let warnedBinmonBindWidened = false;
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
70
|
+
// The SAME one-time-note idiom as warnedBinmonBindWidened above, for the
|
|
71
|
+
// SECOND (`-remotemonitor`) port's bind -- a separate boolean because the
|
|
72
|
+
// two flags widen independently (a caller could widen one host override
|
|
73
|
+
// and not the other, though in practice both resolve from the same
|
|
74
|
+
// `binmonHost` value below).
|
|
73
75
|
let warnedRemoteMonitorBindWidened = false;
|
|
76
|
+
/** The random seed the stock determinism block pins, and the exact value
|
|
77
|
+
* the reproduction was measured with on this host -- exported so a capture
|
|
78
|
+
* record's reproducibility key can cite ONE definition rather than
|
|
79
|
+
* re-deriving a literal that could silently drift away from the launches it
|
|
80
|
+
* claims to describe. MEASURED 2026-09-02 against genuine stock 3.9 over
|
|
81
|
+
* `-binarymonitor`: two cold boots WITHOUT the block differ at 59 of the
|
|
82
|
+
* 4080 addresses in the untouched `$C000-$CFEF` window; WITH it, at 0 of
|
|
83
|
+
* 4080. */
|
|
84
|
+
export const STOCK_DETERMINISM_SEED = 4242;
|
|
85
|
+
/** The determinism block, in ONE fixed order, emitted UNCONDITIONALLY by
|
|
86
|
+
* the stock branch below (never gated on `profile` -- see buildViceArgs()'s
|
|
87
|
+
* own comment above `args`). Exported and frozen so tests and evidence
|
|
88
|
+
* scripts assert against this single definition instead of a second
|
|
89
|
+
* hand-copied array, and so no caller can mutate the shared value into a
|
|
90
|
+
* launch that no longer matches the recorded seed.
|
|
91
|
+
*
|
|
92
|
+
* The order is fixed and load-bearing beyond readability: capture records
|
|
93
|
+
* key on an argv digest, so a block whose element order varied between two
|
|
94
|
+
* launches on the same port would produce two digests for one launch
|
|
95
|
+
* intent. */
|
|
96
|
+
export const STOCK_DETERMINISM_FLAGS = Object.freeze([
|
|
97
|
+
"-seed",
|
|
98
|
+
String(STOCK_DETERMINISM_SEED),
|
|
99
|
+
"-raminitstartrandom",
|
|
100
|
+
"0",
|
|
101
|
+
"-raminitrepeatrandom",
|
|
102
|
+
"0",
|
|
103
|
+
"-raminitrandomchance",
|
|
104
|
+
"0",
|
|
105
|
+
"+autostart-delay-random",
|
|
106
|
+
]);
|
|
74
107
|
/** Resolves the emulator's own argument vector for the given `backend`. The
|
|
75
108
|
* `VICE_ARGS` full-override short-circuit (matching
|
|
76
109
|
* resources/vice-supervisor.sh's own VICE_ARGS convention exactly) is
|
|
@@ -80,42 +113,50 @@ let warnedRemoteMonitorBindWidened = false;
|
|
|
80
113
|
* understands neither `-mcpserver` nor `-binarymonitor` flags, and that need
|
|
81
114
|
* does not depend on which backend is configured.
|
|
82
115
|
*
|
|
83
|
-
* `backend: "
|
|
84
|
-
*
|
|
85
|
-
*
|
|
116
|
+
* `backend: "stock"` (the only value `ViceBackend` has, now that the fork
|
|
117
|
+
* backend is gone) returns `-binarymonitor -binarymonitoraddress
|
|
118
|
+
* ip4://<host>:<port>`, the confirmed real-world command line. The host
|
|
119
|
+
* resolves from `binmonHost` or VICE_BROKER_BINMON_HOST, defaulting to
|
|
120
|
+
* `127.0.0.1` -- deliberately narrow, because VICE's binary monitor is
|
|
121
|
+
* unauthenticated by design and grants full read/write over the emulated
|
|
122
|
+
* machine plus process control to anything that can reach it. Widening the
|
|
123
|
+
* bind away from loopback emits exactly one stderr note per process,
|
|
124
|
+
* naming the resolved bind address and what the exposure grants.
|
|
86
125
|
*
|
|
87
|
-
* `
|
|
88
|
-
* ip4://<host>:<
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
126
|
+
* When `remoteMonitorPort` is a number, the stock branch APPENDS
|
|
127
|
+
* `-remotemonitor -remotemonitoraddress ip4://<host>:<remoteMonitorPort>`,
|
|
128
|
+
* reusing the SAME resolved `host` value the binmon address already used --
|
|
129
|
+
* one resolution, not two. When `remoteMonitorPort` is omitted (undefined),
|
|
130
|
+
* the returned argv is byte-identical to what this function always
|
|
131
|
+
* returned -- no `-remotemonitor` at all. `-remotemonitoraddress`'s exact
|
|
132
|
+
* spelling was live-probed against a real fork-3.10 binary and CONFIRMED:
|
|
133
|
+
* the flag bound a real, accepting text-monitor listener, corroborated
|
|
134
|
+
* independently by `ss -ltnp`. Genuine stock 3.9 was not independently
|
|
135
|
+
* probed in that run -- the spelling itself is a symmetrical CLI flag pair
|
|
136
|
+
* and is not version-sensitive, so this is recorded as a low-risk
|
|
137
|
+
* carry-forward rather than implied stock-3.9 coverage. Widening THIS bind
|
|
138
|
+
* away from loopback emits its own one-time stderr note
|
|
139
|
+
* (`warnedRemoteMonitorBindWidened`), naming the resolved address and
|
|
140
|
+
* stating that VICE's TEXT monitor accepts arbitrary monitor commands and
|
|
141
|
+
* is unauthenticated. text-connect.ts's textConnect() dials this port, and
|
|
142
|
+
* it is MANDATORY on every stock launch -- a stock launch that cannot bind
|
|
143
|
+
* it now fails the whole acquire rather than launching without it, because
|
|
144
|
+
* the flag has to be set at launch time: adding it later would require
|
|
145
|
+
* relaunching a live instance, destroying all emulation state.
|
|
97
146
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
* than implied stock-3.9 coverage. Widening THIS bind away from loopback emits
|
|
112
|
-
* its own one-time stderr note (`warnedRemoteMonitorBindWidened`), naming
|
|
113
|
-
* the resolved address and stating that VICE's TEXT monitor accepts
|
|
114
|
-
* arbitrary monitor commands and is unauthenticated -- Phase 3 dials
|
|
115
|
-
* nothing on this port; only the launch flag lands now (see D-13's own
|
|
116
|
-
* rationale: adding the flag later would require relaunching a live
|
|
117
|
-
* instance, destroying all emulation state). */
|
|
118
|
-
export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, }) {
|
|
147
|
+
* The stock branch also emits STOCK_DETERMINISM_FLAGS unconditionally, and
|
|
148
|
+
* takes an optional `profile` for the two additive launch knobs. Deliberately
|
|
149
|
+
* in the same register as tryLaunchOne's own widened `spawn` field below,
|
|
150
|
+
* and for the same reason: `profile` is optional, so every pre-existing
|
|
151
|
+
* caller and every pre-existing test stub keeps compiling and behaving
|
|
152
|
+
* identically, and an ABSENT profile produces exactly the same argv as an
|
|
153
|
+
* empty one or one whose knobs are both `false`. What optionality could NOT
|
|
154
|
+
* save: the determinism block is unconditional on stock, so all FIVE stock
|
|
155
|
+
* whole-argv assertions in broker-launch.test.ts move even with `profile`
|
|
156
|
+
* absent -- it is the block and not the profile that moves them. The
|
|
157
|
+
* byte-identity claim therefore survives in full only for the `profile`
|
|
158
|
+
* half: an absent profile adds no flag. */
|
|
159
|
+
export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, profile, }) {
|
|
119
160
|
const rawViceArgs = viceArgsEnv ?? process.env.VICE_ARGS;
|
|
120
161
|
if (typeof rawViceArgs === "string" && rawViceArgs.trim() !== "") {
|
|
121
162
|
return rawViceArgs.trim().split(/\s+/);
|
|
@@ -138,22 +179,116 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
|
|
|
138
179
|
// first element: it is VICE's reset-to-compiled-in-defaults instruction,
|
|
139
180
|
// not an inert "these are the baselines" no-op, so any flag emitted
|
|
140
181
|
// before it (including -drive8type) is silently clobbered back to its
|
|
141
|
-
// compiled-in value. `-drive8type 1541` therefore has to come
|
|
142
|
-
//
|
|
143
|
-
// constraint -- `-default` also has to come before
|
|
144
|
-
// the monitor never binds and the subsequent connect
|
|
145
|
-
// backlog looking exactly like a wedge.
|
|
146
|
-
//
|
|
182
|
+
// compiled-in value. `-drive8type 1541` therefore has to come AFTER
|
|
183
|
+
// `-default` -- not necessarily IMMEDIATELY after -- and -- per
|
|
184
|
+
// CLAUDE.md's documented constraint -- `-default` also has to come before
|
|
185
|
+
// `-binarymonitor` or the monitor never binds and the subsequent connect
|
|
186
|
+
// hangs in the backlog looking exactly like a wedge.
|
|
187
|
+
//
|
|
188
|
+
// WORDING CORRECTED. This paragraph used to say "immediately after
|
|
189
|
+
// `-default`", which the `-console` block below now violates by
|
|
190
|
+
// construction whenever `profile.headless` is set -- leaving the next
|
|
191
|
+
// editor to find code contradicting the comment and having to re-derive
|
|
192
|
+
// which one is authoritative. What is load-bearing is the RELATIVE
|
|
193
|
+
// ORDER (`-default` precedes everything it resets), not adjacency.
|
|
194
|
+
//
|
|
195
|
+
// The `-console` block's own citation was `alive=yes bound=1`, which does
|
|
196
|
+
// NOT cover this paragraph's property: the failure mode this flag guards
|
|
197
|
+
// against is Drive8Type silently reverting to 0 (NONE) WHILE THE MONITOR
|
|
198
|
+
// STILL BINDS FINE, so liveness and boundness cannot tell the good case
|
|
199
|
+
// from the failure being guarded against. Re-verified against the
|
|
200
|
+
// resource itself
|
|
201
|
+
// [VERIFIED: live probe 2026-09-03, genuine unpatched stock
|
|
202
|
+
// /usr/bin/x64sc (VICE 3.9), DISPLAY and WAYLAND_DISPLAY both unset]:
|
|
203
|
+
// [-default -console -drive8type 1541 <determinism> -binarymonitor]
|
|
204
|
+
// alive=yes bound=1 Drive8Type=1541 Drive8TrueEmulation=1
|
|
205
|
+
// read over `RESOURCE_GET` (0x51) with `-console` interposed. So the
|
|
206
|
+
// citation now covers the RESOURCE and not only liveness. Confirmed
|
|
207
|
+
// sufficient live in a standalone probe:
|
|
147
208
|
// `resourceget "Drive8Type"` moved 0 -> 1541 and a `load` over the text
|
|
148
209
|
// monitor succeeded immediately. Deliberately NOT setting
|
|
149
210
|
// -drive8truedrive / Drive8TrueEmulation here: this build's own default
|
|
150
|
-
// already reads Drive8TrueEmulation=1 (same probe), so
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
211
|
+
// already reads Drive8TrueEmulation=1 (same probe), so only
|
|
212
|
+
// `-drive8type` needs adding.
|
|
213
|
+
// A different stock build might default Drive8TrueEmulation to 0,
|
|
214
|
+
// which is read and deliberately not pre-emptively defended against
|
|
215
|
+
// here; a live test against that build is what would surface it if
|
|
216
|
+
// this assumption is ever wrong there.
|
|
217
|
+
//
|
|
218
|
+
// The headless route is `-console`, and its POSITION is as
|
|
219
|
+
// load-bearing as `-default`'s. `-console` is handled in the SAME
|
|
220
|
+
// `main.c` pre-scan as `-default` -- that loop `break`s at the first
|
|
221
|
+
// option it does not recognise and then strips the prefix it handled
|
|
222
|
+
// from argv
|
|
223
|
+
// [CITED: vice-3.8/src/main.c:184-192, 232-238] -- and `console_mode`
|
|
224
|
+
// gates GTK initialisation at two call sites (`ui_init_with_args`,
|
|
225
|
+
// `ui_init`) that BOTH run before the late command-line parser
|
|
226
|
+
// `initcmdline_check_args()` [CITED: vice-3.8/src/main.c:296-345]. A
|
|
227
|
+
// `-console` seen only by the late parser therefore arrives after GTK has
|
|
228
|
+
// already tried and failed. MEASURED 2026-09-02 with `DISPLAY` and
|
|
229
|
+
// `WAYLAND_DISPLAY` both unset:
|
|
230
|
+
// [-default -console -binarymonitor] alive=yes bound=1
|
|
231
|
+
// [-default -drive8type 1541 -console -binarymonitor] alive=no bound=0 Gtk-WARNING: cannot open display:
|
|
232
|
+
// [-default -console -drive8type 1541 -binarymonitor] alive=yes bound=1
|
|
233
|
+
// So `-console` goes immediately after `-default` and BEFORE
|
|
234
|
+
// `-drive8type` -- which is compatible with the paragraph above as
|
|
235
|
+
// corrected: that constraint is `-drive8type` AFTER `-default`, not
|
|
236
|
+
// adjacent to it, and the interposition was re-verified over
|
|
237
|
+
// `RESOURCE_GET` to leave Drive8Type=1541 rather than only to leave
|
|
238
|
+
// the monitor bound. It is pinned by an ordering assertion rather than by
|
|
239
|
+
// this comment -- a bare flag push with no reason is exactly what let the
|
|
240
|
+
// `-default` ordering constraint be rediscovered by a red CI run last
|
|
241
|
+
// time.
|
|
242
|
+
//
|
|
243
|
+
// The determinism block is emitted UNCONDITIONALLY on stock, never
|
|
244
|
+
// gated on `profile`. Read over `RESOURCE_GET` (0x51) on this build
|
|
245
|
+
// under `-default -drive8type 1541`
|
|
246
|
+
// [VERIFIED: live probe 2026-09-02],
|
|
247
|
+
// `-raminitrandomchance 0` is the LOAD-BEARING one: the factory value is
|
|
248
|
+
// **10**, i.e. 0.1% of all RAM bits randomly flipped at power-up, and it is
|
|
249
|
+
// the dominant term in the divergence this milestone removes. The
|
|
250
|
+
// `-raminitstartrandom 0` / `-raminitrepeatrandom 0` pair already reads 0
|
|
251
|
+
// at factory and is DEFENSIVE against an operator `vicerc` -- belt and
|
|
252
|
+
// braces alongside the scratch `XDG_CONFIG_HOME` threaded in
|
|
253
|
+
// spawnAndRecordInstance() below.
|
|
254
|
+
//
|
|
255
|
+
// `+autostart-delay-random` is a FIFTH flag beyond the determinism
|
|
256
|
+
// block's own headline text (which names only `-seed` plus the three
|
|
257
|
+
// `raminit*`), recorded here as a deliberate ADDITION rather than
|
|
258
|
+
// smuggled in. `AutostartDelayRandom`
|
|
259
|
+
// ships at **1** on this build [VERIFIED: same probe], it draws an
|
|
260
|
+
// additional random delay of up to 10 frames
|
|
261
|
+
// [CITED: vice-3.8/src/autostart.c:1432-1436], and -- the effect that is
|
|
262
|
+
// easy to miss -- it also SELECTS WHICH keyboard-buffer feed injects `RUN`
|
|
263
|
+
// (`kbdbuf_feed_runcmd` when set, `kbdbuf_feed` when clear)
|
|
264
|
+
// [CITED: vice-3.8/src/autostart.c:882-886]. Disabling it is therefore a
|
|
265
|
+
// behavioural change and not only a timing one. Pin it once; never toggle
|
|
266
|
+
// it between the two runs of a capture pair.
|
|
267
|
+
//
|
|
268
|
+
// `-warp` is position-free (MEASURED 2026-09-02), placed here only so
|
|
269
|
+
// the argv reads in the order a human would describe it. Launch-time is
|
|
270
|
+
// the ONLY route on stock: there is no runtime `WarpMode` resource at
|
|
271
|
+
// all (`RESOURCE_GET` replies `err=0x01` OBJECT_MISSING on 3.9), so no
|
|
272
|
+
// runtime setter can exist here. Worth roughly **1.97x** on this host
|
|
273
|
+
// and this launch profile -- 5.23 emulated seconds against 2.65 over
|
|
274
|
+
// the same 5 s wall clock [MEASURED: evidence/33-wallclock-control.md,
|
|
275
|
+
// Control B instance 1] -- and NOT the order of magnitude a reader may
|
|
276
|
+
// assume; `AUTOSTART` additionally turns warp on by itself during the
|
|
277
|
+
// load whatever argv says, so both loads are warped either way. It is
|
|
278
|
+
// behaviour-neutral under a frame-anchored protocol (identical registers
|
|
279
|
+
// and one identical 64K sha256 across a warped and an unwarped run, same
|
|
280
|
+
// source), which is a precondition for shipping `profile.warp`; it is
|
|
281
|
+
// NOT neutral for a wall-clock bracket, which it invalidates by 1.76x.
|
|
282
|
+
const args = ["-default"];
|
|
283
|
+
if (profile?.headless) {
|
|
284
|
+
args.push("-console");
|
|
285
|
+
}
|
|
286
|
+
args.push("-drive8type", "1541");
|
|
287
|
+
args.push(...STOCK_DETERMINISM_FLAGS);
|
|
288
|
+
if (profile?.warp) {
|
|
289
|
+
args.push("-warp");
|
|
290
|
+
}
|
|
291
|
+
args.push("-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`);
|
|
157
292
|
if (typeof remoteMonitorPort === "number") {
|
|
158
293
|
if (host !== "127.0.0.1" && !warnedRemoteMonitorBindWidened) {
|
|
159
294
|
warnedRemoteMonitorBindWidened = true;
|
|
@@ -186,42 +321,56 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
186
321
|
const spawnFn = deps.spawn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
|
|
187
322
|
const now = deps.now ?? (() => Date.now());
|
|
188
323
|
const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
|
|
189
|
-
const backend = deps.backend ?? "
|
|
324
|
+
const backend = deps.backend ?? "stock";
|
|
325
|
+
// The ONE construction site for a fresh InstanceRecord asserts the
|
|
326
|
+
// invariant every downstream consumer (HeldLease, textConnect(), etc.) was
|
|
327
|
+
// written against -- a stock record NEVER lacks a text-monitor port.
|
|
328
|
+
// acquirePortAndLaunch() above already fails the whole acquire before ever
|
|
329
|
+
// reaching this function when the second allocation fails, so a caller
|
|
330
|
+
// that lands here with `backend: "stock"` and no `remoteMonitorPort` is a
|
|
331
|
+
// defect in THIS module (a call site that bypassed that guarantee), not a
|
|
332
|
+
// state a stock record may legitimately carry -- throw by name rather
|
|
333
|
+
// than silently writing a record that violates it.
|
|
334
|
+
// The fork case is real and unaffected: this check is stock-only.
|
|
335
|
+
if (backend === "stock" && deps.remoteMonitorPort === undefined) {
|
|
336
|
+
throw new Error("spawnAndRecordInstance: backend \"stock\" requires remoteMonitorPort -- a stock launch that cannot bind a text-monitor port must fail the acquire before reaching this construction site, never write a portless stock record");
|
|
337
|
+
}
|
|
190
338
|
const viceArgs = buildViceArgs(port, {
|
|
191
339
|
backend,
|
|
192
340
|
mcpHost: deps.mcpHost,
|
|
193
341
|
binmonHost: deps.binmonHost,
|
|
194
342
|
remoteMonitorPort: deps.remoteMonitorPort,
|
|
343
|
+
// The ONE place a launch's profile becomes argv. The record built
|
|
344
|
+
// below mirrors the SAME value, so an instance's recorded profile and
|
|
345
|
+
// its actual argv are written in one step and cannot disagree.
|
|
346
|
+
profile: deps.profile,
|
|
195
347
|
});
|
|
196
348
|
const log = deps.log ?? defaultLog;
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// must survive this widening. For backend === "fork", spawnFn is called
|
|
208
|
-
// with NO third argument at all, so the fork path's observable behaviour
|
|
209
|
-
// stays bit-for-bit what it was (BACK-02 is a standing gate and the fork
|
|
210
|
-
// backend has been the sole production backend across all of v0.1.x).
|
|
349
|
+
// Production stock launches used to set no scratch XDG_CONFIG_HOME and
|
|
350
|
+
// would read whatever vicerc the operator's own $HOME already carried --
|
|
351
|
+
// shared with the operator's own VICE usage and with the fork build. For
|
|
352
|
+
// backend === "stock" only, compute a fresh, isolated config dir with
|
|
353
|
+
// mkdtempSync (atomic creation, random suffix, 0700 permissions -- the
|
|
354
|
+
// primitive that makes a collision or a symlink-swap into the operator's
|
|
355
|
+
// real config unreachable) and pass it as a third options argument
|
|
356
|
+
// carrying `env` only. Never `shell: true`: the existing array-form
|
|
357
|
+
// spawn(viceBin, viceArgs) call avoids shell interpretation entirely and
|
|
358
|
+
// that property must survive this widening.
|
|
211
359
|
//
|
|
212
360
|
// Scope boundary (do not remove this note): the production broker daemon
|
|
213
361
|
// always supplies its own deps.spawn / deps.spawnFactory, so the widened
|
|
214
362
|
// default wrapper above is dead code on the real launch paths. This
|
|
215
363
|
// function's job is only to COMPUTE the value at the one seam that should
|
|
216
|
-
// own it; the forwarding to nodeSpawn() happens at
|
|
217
|
-
// makeLoggingSpawn() and
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// buildColdSpawnFactory so an injected stub
|
|
223
|
-
// add
|
|
224
|
-
// launches silently lose their config
|
|
364
|
+
// own it; the forwarding to nodeSpawn() happens at three further hops --
|
|
365
|
+
// makeLoggingSpawn() in vice-broker.mts, and withCrashSupervision()'s
|
|
366
|
+
// wrapper body and launchSupervised()'s defaultRealSpawn in this file (a
|
|
367
|
+
// fourth hop, the retired warm floor's own inner stashingSpawn closure in
|
|
368
|
+
// vice-broker.mts, is REMOVED along with the function that held it). All
|
|
369
|
+
// three now forward the options argument, with a handleAcquire()
|
|
370
|
+
// composition test that omits buildColdSpawnFactory so an injected stub
|
|
371
|
+
// cannot fake the proof. If you add another spawn hop, it must forward
|
|
372
|
+
// options too, or production stock launches silently lose their config
|
|
373
|
+
// isolation again.
|
|
225
374
|
//
|
|
226
375
|
// Scratch-dir lifetime: this function deliberately does NOT clean the
|
|
227
376
|
// directory up -- the spawned emulator process outlives this function's
|
|
@@ -254,7 +403,26 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
254
403
|
viceBin,
|
|
255
404
|
viceArgs,
|
|
256
405
|
dryRun: false,
|
|
406
|
+
// Non-optional, defaulted to an empty map -- "no claim on any channel"
|
|
407
|
+
// is an empty map, never an absent field. The ONE place a fresh
|
|
408
|
+
// InstanceRecord is constructed, so this is the ONE place this default
|
|
409
|
+
// is set.
|
|
410
|
+
monitorClients: {},
|
|
411
|
+
// Key omitted only on the FORK path now -- the guard above already
|
|
412
|
+
// throws before this point for any stock call with no
|
|
413
|
+
// remoteMonitorPort, so a stock record reaching this line always
|
|
414
|
+
// supplies the key. "Absent" means fork, never "stock allocation
|
|
415
|
+
// failed" (that state no longer exists).
|
|
257
416
|
...(deps.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: deps.remoteMonitorPort }),
|
|
417
|
+
// Same key-omitted-when-undefined idiom as remoteMonitorPort directly
|
|
418
|
+
// above. An absent request must produce a record with NO `profile` key
|
|
419
|
+
// at all -- not `profile: undefined` -- because "absent means
|
|
420
|
+
// profile-less" is the property a broker restarted mid-phase relies on
|
|
421
|
+
// when it reads records written before this field existed. A copy, not
|
|
422
|
+
// the caller's own object: the record outlives this call and a caller
|
|
423
|
+
// mutating its profile afterwards must not silently change what this
|
|
424
|
+
// instance claims it was launched with.
|
|
425
|
+
...(deps.profile === undefined ? {} : { profile: { ...deps.profile } }),
|
|
258
426
|
};
|
|
259
427
|
deps.state.instances.set(port, record);
|
|
260
428
|
return record;
|
|
@@ -271,10 +439,10 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
271
439
|
*
|
|
272
440
|
* This is the RIGHT primitive when the port is already decided and fixed
|
|
273
441
|
* (most tests; any future caller with its own allocation scheme). It is
|
|
274
|
-
* deliberately NOT what handleAcquire
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
442
|
+
* deliberately NOT what handleAcquire calls for a FRESH port, because
|
|
443
|
+
* nextFreePort() itself is asynchronous (a real port-in-use probe requires
|
|
444
|
+
* it) -- see acquirePortAndLaunch()'s own header comment for the race that
|
|
445
|
+
* creates and how it is closed. */
|
|
278
446
|
export function tryLaunchOne(reason, port, deps) {
|
|
279
447
|
if (inFlight)
|
|
280
448
|
return null;
|
|
@@ -290,34 +458,45 @@ export function tryLaunchOne(reason, port, deps) {
|
|
|
290
458
|
* allocate-a-port-then-launch sequence -- not merely the synchronous spawn
|
|
291
459
|
* instant tryLaunchOne() alone guards. This closes a genuine race window
|
|
292
460
|
* tryLaunchOne() cannot: nextFreePort()'s own port-in-use probe is
|
|
293
|
-
* asynchronous (
|
|
294
|
-
* overlapping callers (a cold acquire arriving over the TCP control
|
|
295
|
-
* listener at any moment, and a warm-floor pass on its own poll timer)
|
|
461
|
+
* asynchronous (a real bind-and-release check), so two overlapping callers
|
|
296
462
|
* could otherwise BOTH be told the SAME candidate port is free before
|
|
297
463
|
* either commits it to state.instances -- a double-launch on one port,
|
|
298
464
|
* silently overwriting the earlier record. The guard is checked and set
|
|
299
|
-
* SYNCHRONOUSLY before the first `await`, exactly like tryLaunchOne()'s
|
|
300
|
-
*
|
|
465
|
+
* SYNCHRONOUSLY before the first `await`, exactly like tryLaunchOne()'s own
|
|
466
|
+
* discipline, so a second concurrent call is refused immediately
|
|
301
467
|
* (`launch_in_flight`) rather than racing on the allocation.
|
|
302
468
|
*
|
|
303
|
-
* This
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
469
|
+
* This guard's own reasoning OUTLIVED the warm floor it was originally
|
|
470
|
+
* written alongside -- it exists because of the 2026-08-01 triple-launch
|
|
471
|
+
* outage (three simultaneous x64sc launches: one SEGV, one exit 1, one exit
|
|
472
|
+
* 0 at the identical spawn second) and is regression-tested (CLAUDE.md),
|
|
473
|
+
* and that history has nothing to do with whether a warm floor exists.
|
|
474
|
+
* Today the only caller of this function is the cold-acquire arm
|
|
475
|
+
* (vice-broker.mts's handleAcquire(), via `serveAcquires()` in
|
|
476
|
+
* runBrokerPass()); the overlap this guard closes is now TWO OR MORE
|
|
477
|
+
* concurrent acquires -- e.g. two requests arriving over the TCP control
|
|
478
|
+
* listener at nearly the same moment, or one arriving while an EARLIER
|
|
479
|
+
* acquire's own launch is still resolving -- never a warming pass, which no
|
|
480
|
+
* longer exists. This is also the function that restores vice-broker.sh's
|
|
481
|
+
* own process_requests() throttle (its `in_flight` local): whatever launches
|
|
482
|
+
* this broker ever attempts, they never overlap, matching the bash
|
|
483
|
+
* original's declined-to-change behaviour. Non-preemptive launch PRIORITY
|
|
484
|
+
* layers on top of this same "one at a time" guard, never replacing it, and
|
|
485
|
+
* the anti-pattern it names -- killing or relaunching preemptively to serve
|
|
486
|
+
* a newer request -- is likewise unaffected by the floor's removal: this
|
|
487
|
+
* function still only ever refuses a second concurrent caller
|
|
488
|
+
* (`launch_in_flight`), and never kills or preempts whichever caller
|
|
489
|
+
* already holds the slot. Among multiple QUEUED acquires, which one wins
|
|
490
|
+
* this slot NEXT, once it frees, falls out of the arrival-ordered
|
|
491
|
+
* pending-acquire structure (broker-control.mts's own mechanism) that
|
|
492
|
+
* requeues a refused acquire for the next pass -- not from anything in this
|
|
493
|
+
* function. The refusal below logs which reason currently holds the slot
|
|
494
|
+
* and which reason is waiting, so the decision is reconstructable from the
|
|
495
|
+
* log after an incident. */
|
|
317
496
|
export async function acquirePortAndLaunch(reason, deps) {
|
|
318
497
|
const log = deps.log ?? defaultLog;
|
|
319
498
|
if (inFlight) {
|
|
320
|
-
log(`vice-broker: launch-slot decision -- ${inFlightReason ?? "unknown"} holds the slot; ${reason} waits
|
|
499
|
+
log(`vice-broker: launch-slot decision -- ${inFlightReason ?? "unknown"} holds the slot; ${reason} waits`);
|
|
321
500
|
return { ok: false, reason: "launch_in_flight" };
|
|
322
501
|
}
|
|
323
502
|
inFlight = true;
|
|
@@ -331,14 +510,14 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
331
510
|
const supervisorDir = join(deps.stateDir, String(port));
|
|
332
511
|
const epochFile = join(supervisorDir, "epoch.json");
|
|
333
512
|
const spawn = deps.spawnFactory ? deps.spawnFactory(port) : deps.spawn;
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
513
|
+
// The second (`-remotemonitor`) port is resolved HERE, still inside the
|
|
514
|
+
// single in_flight owner's own try-block, immediately after the primary
|
|
515
|
+
// allocation succeeds -- both awaits stay inside this SAME try, after
|
|
516
|
+
// the guard's synchronous check-and-set above; neither is moved,
|
|
517
|
+
// duplicated, or awaited around that guard. Only ever attempted for
|
|
518
|
+
// `backend === "stock"`, and only when the caller actually provided the
|
|
519
|
+
// allocator -- every fork launch and every caller before this feature
|
|
520
|
+
// existed never reaches this branch at all.
|
|
342
521
|
let remoteMonitorPort;
|
|
343
522
|
if (deps.backend === "stock" && deps.allocateRemoteMonitorPort) {
|
|
344
523
|
const remoteResult = await deps.allocateRemoteMonitorPort(deps.state, new Set([port]));
|
|
@@ -351,16 +530,27 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
351
530
|
// does. `state.blockedPorts` is a plain Set the type import already
|
|
352
531
|
// describes, so mutating it directly needs no value import at all --
|
|
353
532
|
// exactly the same discipline handleExit()'s own
|
|
354
|
-
// `record.
|
|
533
|
+
// `record.monitorClients = {}` uses in place of
|
|
355
534
|
// clearMonitorClient().
|
|
356
535
|
deps.state.blockedPorts.add(remoteResult.port);
|
|
357
536
|
remoteMonitorPort = remoteResult.port;
|
|
358
537
|
}
|
|
359
538
|
else {
|
|
360
|
-
//
|
|
361
|
-
//
|
|
539
|
+
// FAIL, never degrade. Owner direction, verbatim: "it should not be
|
|
540
|
+
// possible, vice must be started witht the text channel." A stock
|
|
541
|
+
// launch that cannot bind a text-monitor port fails the whole
|
|
542
|
+
// acquire -- no process is spawned. The PRIMARY port allocated
|
|
543
|
+
// moments earlier is not yet in `state.instances` and was never
|
|
544
|
+
// added to `state.blockedPorts` by this function (only
|
|
545
|
+
// `nextFreePort()`'s own in-use probe blocks a candidate, and that
|
|
546
|
+
// never ran against the winning candidate) -- so it is already
|
|
547
|
+
// allocatable again on the very next call with no further release
|
|
548
|
+
// step; a discriminating-power test proves this rather than assuming
|
|
549
|
+
// it. This failure arm must NEVER call spawnAndRecordInstance() or
|
|
550
|
+
// otherwise leave a port "spoken for" on the caller's behalf.
|
|
362
551
|
log(`vice-broker: second (-remotemonitor) port allocation failed (${remoteResult.reason}) -- ` +
|
|
363
|
-
`
|
|
552
|
+
`abandoning the stock launch; the text-monitor port is mandatory on every stock launch and the acquire fails`);
|
|
553
|
+
return { ok: false, reason: "no_free_text_port" };
|
|
364
554
|
}
|
|
365
555
|
}
|
|
366
556
|
const record = spawnAndRecordInstance(reason, port, {
|
|
@@ -374,6 +564,7 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
374
564
|
backend: deps.backend,
|
|
375
565
|
binmonHost: deps.binmonHost,
|
|
376
566
|
remoteMonitorPort,
|
|
567
|
+
profile: deps.profile,
|
|
377
568
|
});
|
|
378
569
|
return { ok: true, record };
|
|
379
570
|
}
|
|
@@ -386,16 +577,17 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
386
577
|
* deleting the record AND handing its second (`-remotemonitor`) port back to
|
|
387
578
|
* the allocator in the same step.
|
|
388
579
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* exhausts its band and answers `no_free_port` to ordinary
|
|
398
|
-
* from routine churn, with no operator recourse short of a
|
|
580
|
+
* `acquirePortAndLaunch()` above adds every allocated remote-monitor port to
|
|
581
|
+
* `state.blockedPorts`, and until this function existed NOTHING ever removed
|
|
582
|
+
* one. `nextFreePort()` never reconsiders a blocked candidate for the
|
|
583
|
+
* lifetime of the process, so every teardown of a stock instance permanently
|
|
584
|
+
* consumed one more port out of the fixed PORT_SCAN_CEILING window even
|
|
585
|
+
* though the OS port was free again the instant the owning process exited
|
|
586
|
+
* -- a long-running broker (the explicit design goal of an on-demand pool
|
|
587
|
+
* with crash supervision, launched strictly on demand rather than kept
|
|
588
|
+
* warm) eventually exhausts its band and answers `no_free_port` to ordinary
|
|
589
|
+
* launches purely from routine churn, with no operator recourse short of a
|
|
590
|
+
* broker restart.
|
|
399
591
|
*
|
|
400
592
|
* A RESPAWN is deliberately NOT a call site: the replacement instance keeps
|
|
401
593
|
* BOTH the primary port and the remote-monitor port of the instance it
|
|
@@ -456,7 +648,7 @@ async function defaultHttpProbe(port, timeoutMs) {
|
|
|
456
648
|
}
|
|
457
649
|
}
|
|
458
650
|
// ---------------------------------------------------------------------------
|
|
459
|
-
//
|
|
651
|
+
// The STOCK readiness route.
|
|
460
652
|
//
|
|
461
653
|
// probeReady() below used to POST http://127.0.0.1:<port>/mcp unconditionally
|
|
462
654
|
// and require both "version" and "machine" in the body. On the stock backend
|
|
@@ -477,8 +669,9 @@ async function defaultHttpProbe(port, timeoutMs) {
|
|
|
477
669
|
// probe-binmon.mjs. What is written here is the minimum a READINESS check needs:
|
|
478
670
|
// one request header out, one response header in, four bytes checked.
|
|
479
671
|
// ---------------------------------------------------------------------------
|
|
480
|
-
/** Hand-copied from
|
|
481
|
-
*
|
|
672
|
+
/** Hand-copied from this project's own measured binary-monitor wire format
|
|
673
|
+
* (the same constants stock-protocol.ts defines) -- see the block comment
|
|
674
|
+
* above for why these are not imported from stock-protocol.ts directly. */
|
|
482
675
|
const BINMON_STX = 0x02;
|
|
483
676
|
const BINMON_API_VERSION = 0x02;
|
|
484
677
|
const BINMON_REQUEST_HEADER_LEN = 11;
|
|
@@ -504,15 +697,15 @@ function binmonRequest(commandType, requestId) {
|
|
|
504
697
|
* collides between the two, which is exactly why demux must key on it. */
|
|
505
698
|
const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
|
|
506
699
|
/**
|
|
507
|
-
*
|
|
700
|
+
* One PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
|
|
508
701
|
* reply -- STX, the expected api_version, response type 0x81, error code 0x00,
|
|
509
702
|
* and this probe's own request id. A bare TCP accept is explicitly insufficient
|
|
510
703
|
* here for exactly the reason probeReady()'s own comment gives for the HTTP
|
|
511
704
|
* route: a C64 can accept a connection before it has finished booting.
|
|
512
705
|
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
706
|
+
* A live-discovered defect: a NEW binmon connection ALWAYS emits an
|
|
707
|
+
* unsolicited REGISTER_INFO (0x31) frame at request-id 0xffffffff the
|
|
708
|
+
* instant it opens (CLAUDE.md's own Protocol constraint) -- BEFORE this
|
|
516
709
|
* probe's own PING reply ever arrives. The naive "the first 12 bytes ARE the
|
|
517
710
|
* reply" read this code used to do treated that event frame's OWN response-
|
|
518
711
|
* type byte (0x31) as a malformed PING reply and answered `false` forever,
|
|
@@ -528,8 +721,8 @@ const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
|
|
|
528
721
|
* ViceMonitorClient chief among them).
|
|
529
722
|
*
|
|
530
723
|
* Then EXIT (0xaa), unconditionally, before closing -- because the PING ITSELF
|
|
531
|
-
* HALTS THE MACHINE. Any inbound byte does
|
|
532
|
-
*
|
|
724
|
+
* HALTS THE MACHINE. Any inbound byte does, and this project's own connect
|
|
725
|
+
* handshake had the same omission and was fixed for the same reason. A
|
|
533
726
|
* readiness probe that left every warm instance frozen would be a worse defect
|
|
534
727
|
* than the one it fixes: the emulator would be "ready" and stopped.
|
|
535
728
|
*
|
|
@@ -617,36 +810,33 @@ async function defaultBinmonProbe(port, timeoutMs) {
|
|
|
617
810
|
});
|
|
618
811
|
});
|
|
619
812
|
}
|
|
620
|
-
/**
|
|
621
|
-
* in the exact place a three-branch description used to
|
|
622
|
-
*
|
|
623
|
-
* not merely in the plan text (`01.6.2.1-02-PLAN.md`) or the validation
|
|
624
|
-
* ledger (`01.6.2-VALIDATION.md`, consolidated by plan 06).
|
|
813
|
+
/** This comment records why the readiness probe below looks the way it
|
|
814
|
+
* does, kept in the exact place a longer, three-branch description used to
|
|
815
|
+
* sit.
|
|
625
816
|
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
* plan-01 acquire hot path.
|
|
817
|
+
* The probe was originally specified as a bare in-process TCP connect to
|
|
818
|
+
* the instance's own monitor port, with a short timeout. The code that
|
|
819
|
+
* landed instead argued against that wording, in its OWN comment: "a bare
|
|
820
|
+
* TCP accept is explicitly not sufficient (a C64 can accept a connection
|
|
821
|
+
* before it has finished booting)" -- a booting emulator promoted to ready
|
|
822
|
+
* on nothing more than an accepted connection is exactly the kind of false
|
|
823
|
+
* positive this probe exists to prevent.
|
|
634
824
|
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* mechanism
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
825
|
+
* The ping-shaped request body stays -- it is what proves the emulator
|
|
826
|
+
* ANSWERS, not merely that a port is bound, which is the whole difference
|
|
827
|
+
* between a liveness check and a readiness check. Two other mechanisms the
|
|
828
|
+
* landed code originally carried (an external-command mechanism, and a
|
|
829
|
+
* "neither mechanism available -> report ready unconditionally" fallback)
|
|
830
|
+
* were retired outright: with no second mechanism to prefer and no "no
|
|
831
|
+
* mechanism" state left to report, there is no longer a pair of
|
|
832
|
+
* indistinguishable states (a deliberately-zero warm floor and a broken
|
|
833
|
+
* host) for an operator to confuse in the logs. The original intent --
|
|
834
|
+
* exactly one check, no external command, no ambiguity -- is fully
|
|
835
|
+
* honoured by this collapse, not reversed by it.
|
|
646
836
|
*
|
|
647
837
|
* No retry loop, deliberately: a still-booting instance simply fails THIS
|
|
648
|
-
* pass and is re-probed on the next one (
|
|
649
|
-
* cadence, or a later grant-time re-probe) -- this is what makes the
|
|
838
|
+
* pass and is re-probed on the next one (promoteLaunchingInstances()'s own
|
|
839
|
+
* per-pass cadence, or a later grant-time re-probe) -- this is what makes the
|
|
650
840
|
* shortened ~1s default below safe rather than reckless: a slow host is
|
|
651
841
|
* re-probed, never starved, and the seconds-valued timeout knob
|
|
652
842
|
* (VICE_BROKER_PROBE_TIMEOUT_S) still lets an operator on a slow host raise
|
|
@@ -654,7 +844,7 @@ async function defaultBinmonProbe(port, timeoutMs) {
|
|
|
654
844
|
export async function probeReady(port, deps = {}) {
|
|
655
845
|
const timeoutS = Number(deps.probeTimeoutSEnv ?? process.env.VICE_BROKER_PROBE_TIMEOUT_S) || DEFAULT_PROBE_TIMEOUT_S;
|
|
656
846
|
const timeoutMs = timeoutS * 1000;
|
|
657
|
-
//
|
|
847
|
+
// The route is chosen by the backend, exactly like buildViceArgs()'s
|
|
658
848
|
// own argv choice, and from the SAME threaded-down verdict. The fork arm below
|
|
659
849
|
// is byte-identical to what this function always did, including the
|
|
660
850
|
// omitted-backend default -- a fork deployment sees no behaviour change.
|
|
@@ -665,61 +855,22 @@ export async function probeReady(port, deps = {}) {
|
|
|
665
855
|
const httpProbe = deps.httpProbe ?? defaultHttpProbe;
|
|
666
856
|
return httpProbe(port, timeoutMs);
|
|
667
857
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
const n = Number(raw);
|
|
678
|
-
return Number.isFinite(n) ? n : 1;
|
|
679
|
-
}
|
|
680
|
-
function resolveCeiling(override) {
|
|
681
|
-
if (typeof override === "number")
|
|
682
|
-
return override;
|
|
683
|
-
const raw = process.env.VICE_BROKER_MAX;
|
|
684
|
-
if (raw === undefined || raw === "")
|
|
685
|
-
return 16;
|
|
686
|
-
const n = Number(raw);
|
|
687
|
-
return Number.isFinite(n) ? n : 16;
|
|
688
|
-
}
|
|
689
|
-
/** Promotes launching instances via probe, then -- unless a launch is
|
|
690
|
-
* already in flight -- launches AT MOST ONE instance toward the warm floor
|
|
691
|
-
* and returns. Never loops to reach the floor in one call: reaching
|
|
692
|
-
* VICE_BROKER_WARM_FLOOR this way costs one
|
|
693
|
-
* additional CALL per warm instance instead of one call total, which is the exact
|
|
694
|
-
* trade the 2026-08-01 outage made non-negotiable (three simultaneous
|
|
695
|
-
* x64sc launches: one SEGV, one exit 1, one exit 0 at the identical spawn
|
|
696
|
-
* second). `async`/`await` makes launching everything needed in one go
|
|
697
|
-
* look free and idiomatic; it is actively dangerous here. DO NOT gather
|
|
698
|
-
* several pending launches into a single concurrent await, and do not
|
|
699
|
-
* "helpfully" loop this function internally until the floor is met.
|
|
700
|
-
*
|
|
701
|
-
* D-05's probe-live floor evaluation (count_ready() trusting probe-live
|
|
702
|
-
* instances rather than a recorded `ready` state) is explicitly Phase
|
|
703
|
-
* 01.6.2.1's criterion L, NOT this plan's -- countReady() here still
|
|
704
|
-
* counts by RECORDED state, exactly like the bash original's count_ready()
|
|
705
|
-
* before Decision 5.2. A reviewer must not mistake this for an oversight:
|
|
706
|
-
* it is the declined-for-this-phase choice RESEARCH.md §A1 recommends, and
|
|
707
|
-
* grant_from_spare()'s own live re-probe at GRANT time (broker-kill.mts /
|
|
708
|
-
* plan 04's territory) is a separate, already-correct mechanism this plan
|
|
709
|
-
* does not touch. */
|
|
710
|
-
export async function maintainWarmFloor(deps) {
|
|
858
|
+
/** Promotes every `launching` instance whose readiness probe now succeeds to
|
|
859
|
+
* `ready`, recording its readiness timestamp and logging the elapsed boot
|
|
860
|
+
* time. Runs regardless of whether a launch is in flight -- promotion and a
|
|
861
|
+
* NEW launch starting are independent concerns; an already-launched instance
|
|
862
|
+
* becomes usable the moment it answers, whether or not this same pass goes on
|
|
863
|
+
* to start anything further. No retry loop: a still-booting instance simply
|
|
864
|
+
* fails THIS pass and is re-probed on the next one (runBrokerPass()'s own
|
|
865
|
+
* per-tick cadence). */
|
|
866
|
+
export async function promoteLaunchingInstances(deps) {
|
|
711
867
|
const log = deps.log ?? defaultLog;
|
|
712
868
|
const now = deps.now ?? (() => Date.now());
|
|
713
|
-
//
|
|
714
|
-
// threads `backend`
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "
|
|
718
|
-
// Step 1: promote every "launching" instance whose probe now succeeds.
|
|
719
|
-
// Runs regardless of whether a launch is in flight -- promotion and
|
|
720
|
-
// speculative warming are independent concerns; an already-launched
|
|
721
|
-
// instance becomes usable the moment it answers, whether or not this
|
|
722
|
-
// pass goes on to warm anything further.
|
|
869
|
+
// The DEFAULT probe follows this call's own backend, so a caller that
|
|
870
|
+
// threads `backend` and omits `probe` gets a matching readiness route
|
|
871
|
+
// rather than an HTTP POST at a binary-monitor port. An explicitly
|
|
872
|
+
// injected `probe` still wins, unchanged.
|
|
873
|
+
const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "stock" }));
|
|
723
874
|
for (const record of deps.state.instances.values()) {
|
|
724
875
|
if (record.state !== "launching")
|
|
725
876
|
continue;
|
|
@@ -732,103 +883,48 @@ export async function maintainWarmFloor(deps) {
|
|
|
732
883
|
log(`vice-broker: port ${record.port} launching -> ready (${elapsedMs}ms)`);
|
|
733
884
|
}
|
|
734
885
|
}
|
|
735
|
-
// Step 2 (P-06: the warm-zero "no readiness mechanism" branch that used
|
|
736
|
-
// to sit here is GONE -- the surviving probe mechanism is in-process and
|
|
737
|
-
// always available, so there is no "no mechanism" state left to warm
|
|
738
|
-
// zero against). No new boot starts while one is already under way --
|
|
739
|
-
// THE single in-flight counter (countLaunching) both this function and a
|
|
740
|
-
// cold acquire (vice-broker.mts's handleAcquire) consult.
|
|
741
|
-
if (deps.countLaunching(deps.state) > 0) {
|
|
742
|
-
// D-07's launch-slot decision log line, this decision point's own half:
|
|
743
|
-
// name WHICH reason currently holds the slot (the launching record's
|
|
744
|
-
// own `reason`, whichever call produced it -- cold acquire or an
|
|
745
|
-
// earlier warming pass), not merely that warming is waiting.
|
|
746
|
-
const inFlightRecord = Array.from(deps.state.instances.values()).find((r) => r.state === "launching");
|
|
747
|
-
const winningReason = inFlightRecord?.reason ?? "unknown";
|
|
748
|
-
log(`vice-broker: launch-slot decision -- ${winningReason} holds the slot; spare waits (D-07)`);
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
const ready = deps.countReady(deps.state);
|
|
752
|
-
const total = deps.countTotal(deps.state);
|
|
753
|
-
const warmFloor = resolveWarmFloor(deps.warmFloor);
|
|
754
|
-
const ceiling = resolveCeiling(deps.ceiling);
|
|
755
|
-
if (!(ready < warmFloor && total < ceiling)) {
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
// acquirePortAndLaunch() holds the SAME single in_flight owner across
|
|
759
|
-
// its own async port allocation -- not merely tryLaunchOne()'s
|
|
760
|
-
// synchronous spawn instant. This is what actually closes the race
|
|
761
|
-
// between this warm-floor launch and a cold acquire (vice-broker.mts's
|
|
762
|
-
// handleAcquire) arriving over the TCP control listener at any moment:
|
|
763
|
-
// the countLaunching() check just above is a cheap PRE-check (bails
|
|
764
|
-
// early when a launch is already recorded), but nextFreePort() is
|
|
765
|
-
// itself asynchronous, so without the guard held across the allocation
|
|
766
|
-
// too, two overlapping callers could still both be told the same
|
|
767
|
-
// candidate port is free before either commits it.
|
|
768
|
-
const result = await acquirePortAndLaunch("spare", {
|
|
769
|
-
state: deps.state,
|
|
770
|
-
stateDir: deps.stateDir,
|
|
771
|
-
allocatePort: deps.allocatePort,
|
|
772
|
-
allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
|
|
773
|
-
spawn: deps.spawn,
|
|
774
|
-
spawnFactory: deps.spawnFactory,
|
|
775
|
-
now: deps.now,
|
|
776
|
-
viceBin: deps.viceBin,
|
|
777
|
-
mcpHost: deps.mcpHost,
|
|
778
|
-
backend: deps.backend,
|
|
779
|
-
binmonHost: deps.binmonHost,
|
|
780
|
-
});
|
|
781
|
-
if (result.ok) {
|
|
782
|
-
log(`vice-broker: warmed 1 warm instance this pass -- ${ready + 1} of ${warmFloor} ready, remainder warmed on later passes`);
|
|
783
|
-
deps.onLaunched?.(result.record);
|
|
784
|
-
}
|
|
785
|
-
else if (result.reason === "no_free_port") {
|
|
786
|
-
log(`vice-broker: no free port available -- warming no further warm instances; ${ready} of ${warmFloor} ready`);
|
|
787
|
-
}
|
|
788
|
-
else {
|
|
789
|
-
// A launch started (cold or warm) between this function's own
|
|
790
|
-
// countLaunching() check above and this call -- a narrow window
|
|
791
|
-
// closed by the guard rather than assumed impossible.
|
|
792
|
-
log("vice-broker: a warm-floor launch was attempted but a launch was already in flight -- deferring to a later pass");
|
|
793
|
-
}
|
|
794
886
|
}
|
|
795
887
|
/** The fixed pass order (mirrors vice-broker.sh's own broker_once(), whose
|
|
796
888
|
* comment names the ordering as load-bearing: "the spare invariant is
|
|
797
889
|
* always re-evaluated against the freshest possible grant/teardown
|
|
798
890
|
* state"). The bash version's third concern, the grant sweep, does NOT
|
|
799
|
-
* appear here -- it is one of
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
* real broker, a real port or a real launch.
|
|
891
|
+
* appear here -- it is one of several retiring file-lease mechanisms; the
|
|
892
|
+
* TCP connection itself is the lease. The broker-instances.json projection
|
|
893
|
+
* write does not appear either (see broker-state.mts's own FINDING 2
|
|
894
|
+
* comment). Takes plain callbacks rather than the full BrokerState/deps
|
|
895
|
+
* shape so a test can inject two instrumented no-op functions and assert
|
|
896
|
+
* call ORDER without needing a real broker, a real port or a real launch.
|
|
806
897
|
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
* own
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
898
|
+
* The warm floor that non-preemptive launch priority originally reasoned
|
|
899
|
+
* about here is GONE -- `promoteLaunching` never calls
|
|
900
|
+
* acquirePortAndLaunch() and so never competes for the single in-flight
|
|
901
|
+
* launch slot the way a warm-floor spare launch used to. `serveAcquires()`
|
|
902
|
+
* (via its own drainPendingAcquires()) is now the ONLY caller in this pass
|
|
903
|
+
* that ever launches anything, so the original "which reason wins a freed
|
|
904
|
+
* slot" question has nothing left to decide BETWEEN these two steps -- that
|
|
905
|
+
* reasoning still applies WITHIN the acquire arm itself (two overlapping
|
|
906
|
+
* acquires still resolve through the single in-flight owner
|
|
907
|
+
* (acquirePortAndLaunch()'s own invariant comment), which this reordering
|
|
908
|
+
* never weakens). What the fixed order still buys: promoting AFTER serving
|
|
909
|
+
* means an instance that becomes probe-ready DURING this exact tick is not
|
|
910
|
+
* available to any acquire THIS SAME pass -- selectWarmInstance() sees it on
|
|
911
|
+
* the NEXT pass instead, a bound of one poll interval (VICE_BROKER_POLL_MS),
|
|
912
|
+
* never a correctness gap, since a cold acquire finding no ready candidate
|
|
913
|
+
* falls straight through to its own dedicated cold launch rather than
|
|
914
|
+
* waiting on one. */
|
|
819
915
|
export async function runBrokerPass(deps) {
|
|
820
916
|
await deps.serveAcquires();
|
|
821
|
-
await deps.
|
|
917
|
+
await deps.promoteLaunching();
|
|
822
918
|
}
|
|
823
919
|
// ===========================================================================
|
|
824
|
-
// Per-child supervision
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
920
|
+
// Per-child supervision: absorbs resources/vice-supervisor.sh WHOLESALE. The
|
|
921
|
+
// respawn loop becomes an exit-event handler installed on the spawned
|
|
922
|
+
// child; the backoff shape (initial delay, doubling, ceiling), the
|
|
923
|
+
// crash-loop give-up (too many crashes inside a window), and the
|
|
924
|
+
// per-instance boot/crash log are ported exactly, keeping the same
|
|
925
|
+
// configuration knobs -- VICE_RESTART_BACKOFF_S, VICE_RESTART_BACKOFF_MAX_S,
|
|
926
|
+
// VICE_MAX_RESTARTS, VICE_CRASH_WINDOW_S all keep their exact names and
|
|
927
|
+
// semantics.
|
|
832
928
|
// ===========================================================================
|
|
833
929
|
function resolveMs(envVar, defaultSeconds, override) {
|
|
834
930
|
if (typeof override === "number")
|
|
@@ -857,9 +953,11 @@ function resolveCount(envVar, defaultValue, override) {
|
|
|
857
953
|
* UNEXPLAINED exit, not this one. The pre-kill crash history and backoff
|
|
858
954
|
* are carried forward UNCHANGED, and a pre-kill "granted" state is
|
|
859
955
|
* restored on the fresh record -- the relaunch primitive always creates a
|
|
860
|
-
* new record in the "launching" state, and leaving it there
|
|
861
|
-
*
|
|
862
|
-
*
|
|
956
|
+
* new record in the "launching" state, and leaving it there (once
|
|
957
|
+
* promoted to "ready" by the next probe pass) would let a LATER,
|
|
958
|
+
* UNRELATED acquire's own selectWarmInstance() walk (vice-broker.mts)
|
|
959
|
+
* mistake a recycled session's own machine for an available candidate to
|
|
960
|
+
* grant out from under the session that already owns it.
|
|
863
961
|
* - deliberateKill set WITHOUT respawnAfterKill -> "deliberate_teardown":
|
|
864
962
|
* drop the instance, no respawn. This is T-01.6.2-21's whole point --
|
|
865
963
|
* without reading this flag, every deliberate teardown would respawn
|
|
@@ -883,22 +981,24 @@ async function handleExit(reason, port, deps) {
|
|
|
883
981
|
return;
|
|
884
982
|
}
|
|
885
983
|
const log = deps.log ?? defaultLog;
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
// respawn/delete paths below (a fresh InstanceRecord never
|
|
892
|
-
//
|
|
893
|
-
// the same reason broker-state.mts's own header comment
|
|
894
|
-
// of the
|
|
895
|
-
// broker-state.mjs's clearMonitorClient()) -- this module's own
|
|
984
|
+
// The process behind this instance's monitor sockets has just exited, by
|
|
985
|
+
// every path this function can take (crash, recycle, or a deliberate
|
|
986
|
+
// teardown) -- clear EVERY channel's ownership record HERE, once, before
|
|
987
|
+
// any of those paths branch, so a client that died without releasing can
|
|
988
|
+
// never hold this lock forever on any channel. Redundant
|
|
989
|
+
// with the respawn/delete paths below (a fresh InstanceRecord never
|
|
990
|
+
// carries this forward; a deleted one has no field to carry), but
|
|
991
|
+
// explicit for the same reason broker-state.mts's own header comment
|
|
992
|
+
// names this as one of the required clearing sites. Assigned directly
|
|
993
|
+
// (not via broker-state.mjs's clearMonitorClient()) -- this module's own
|
|
896
994
|
// type-only import of that sibling (see this file's own header comment a
|
|
897
995
|
// few lines above) is load-bearing: a VALUE import would turn "./broker-
|
|
898
996
|
// state.mjs" into a real runtime resolution this file cannot satisfy when
|
|
899
997
|
// loaded directly (as broker-launch.test.ts does), rather than the
|
|
900
|
-
// compiled resources/ sibling this specifier is actually shaped for.
|
|
901
|
-
|
|
998
|
+
// compiled resources/ sibling this specifier is actually shaped for. `{}`
|
|
999
|
+
// (not `undefined`) since `monitorClients` is non-optional -- "no claim
|
|
1000
|
+
// on any channel" is an empty map.
|
|
1001
|
+
record.monitorClients = {};
|
|
902
1002
|
if (record.deliberateKill) {
|
|
903
1003
|
if (record.respawnAfterKill) {
|
|
904
1004
|
// Recycle. Capture the pre-kill state, crash history and backoff
|
|
@@ -908,13 +1008,23 @@ async function handleExit(reason, port, deps) {
|
|
|
908
1008
|
const preKillState = record.state;
|
|
909
1009
|
const preKillCrashTimes = record.crashTimes ?? [];
|
|
910
1010
|
const preKillBackoffMs = record.backoffMs ?? resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
// are.
|
|
1011
|
+
// The second (`-remotemonitor`) port is carried forward across the
|
|
1012
|
+
// replacement exactly like the primary port is -- captured BEFORE
|
|
1013
|
+
// launchSupervised() overwrites this port's map entry with a brand
|
|
1014
|
+
// new record, for the same reason the three values above are.
|
|
916
1015
|
const preKillRemoteMonitorPort = record.remoteMonitorPort;
|
|
917
|
-
|
|
1016
|
+
// The launch PROFILE is carried forward for exactly the reason the
|
|
1017
|
+
// remote-monitor port is carried forward above, and the failure it
|
|
1018
|
+
// prevents is sharper. Without this, a recycled `{warp:true}`
|
|
1019
|
+
// instance would come back UNWARPED while its fresh record still
|
|
1020
|
+
// claimed `profile:{warp:true}` -- after which profileEligible()
|
|
1021
|
+
// (vice-broker.mts) would happily hand that instance to the next warp
|
|
1022
|
+
// request. That is precisely the undetectable lie this carry-forward
|
|
1023
|
+
// exists to structurally exclude, reintroduced one respawn later.
|
|
1024
|
+
// Captured BEFORE launchSupervised() overwrites this port's map entry
|
|
1025
|
+
// with a brand new record, same as the four values above.
|
|
1026
|
+
const preKillProfile = record.profile;
|
|
1027
|
+
const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs, preKillRemoteMonitorPort, preKillProfile);
|
|
918
1028
|
if (respawned && preKillState === "granted") {
|
|
919
1029
|
respawned.state = "granted";
|
|
920
1030
|
}
|
|
@@ -934,7 +1044,7 @@ async function handleExit(reason, port, deps) {
|
|
|
934
1044
|
deps.onOutcome?.("recycled", port);
|
|
935
1045
|
return;
|
|
936
1046
|
}
|
|
937
|
-
//
|
|
1047
|
+
// A deliberate teardown is the END of this instance -- its
|
|
938
1048
|
// remote-monitor port must go back to the allocator with it.
|
|
939
1049
|
deleteInstanceRecord(deps.state, port);
|
|
940
1050
|
deps.onOutcome?.("deliberate_teardown", port);
|
|
@@ -948,7 +1058,7 @@ async function handleExit(reason, port, deps) {
|
|
|
948
1058
|
if (crashTimes.length >= maxRestarts) {
|
|
949
1059
|
log(`vice-broker: giving up on port ${port} after ${crashTimes.length} crashes within ${crashWindowMs}ms -- ` +
|
|
950
1060
|
`this is not a transient crash; check VICE_ARGS and whether the port is already bound`);
|
|
951
|
-
//
|
|
1061
|
+
// Giving up is likewise terminal for this instance -- release its
|
|
952
1062
|
// remote-monitor port rather than leaking it out of the allocation band.
|
|
953
1063
|
deleteInstanceRecord(deps.state, port);
|
|
954
1064
|
deps.onOutcome?.("given_up", port);
|
|
@@ -959,11 +1069,14 @@ async function handleExit(reason, port, deps) {
|
|
|
959
1069
|
await sleepMs(currentBackoffMs);
|
|
960
1070
|
const maxBackoffMs = resolveMs("VICE_RESTART_BACKOFF_MAX_S", 30, deps.maxBackoffMs);
|
|
961
1071
|
const nextBackoffMs = Math.min(currentBackoffMs * 2, maxBackoffMs);
|
|
962
|
-
//
|
|
1072
|
+
// Same carry-forward as the recycle branch above -- a crash must not
|
|
963
1073
|
// silently strip `-remotemonitor` (and its InstanceRecord field) off the
|
|
964
|
-
// replacement, which
|
|
965
|
-
// stop being true the first time an instance was
|
|
966
|
-
|
|
1074
|
+
// replacement, which would otherwise make the instance record's claim to
|
|
1075
|
+
// carry that field stop being true the first time an instance was
|
|
1076
|
+
// replaced. Same reasoning applies to the launch profile -- an
|
|
1077
|
+
// unexplained crash must not silently strip `-warp`/`-console` off the
|
|
1078
|
+
// replacement while leaving the record claiming them.
|
|
1079
|
+
const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort, record.profile);
|
|
967
1080
|
deps.onOutcome?.(respawned ? "respawned" : "given_up", port);
|
|
968
1081
|
}
|
|
969
1082
|
/** The single exit-listener installation point in the whole module tree.
|
|
@@ -988,11 +1101,11 @@ async function handleExit(reason, port, deps) {
|
|
|
988
1101
|
* a second inline listener, is what keeps the "exactly one installation
|
|
989
1102
|
* point" invariant a structural gate (broker-launch.test.ts) can hold. */
|
|
990
1103
|
export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
// caller's options at this call site.
|
|
1104
|
+
// Forwards a third options argument in the BODY, not just the type --
|
|
1105
|
+
// this is the hop that matters most, because it wraps every real launch
|
|
1106
|
+
// path (cold acquire and every respawn -- a warm floor used to be a
|
|
1107
|
+
// third path here and has since been retired). A type-only widening
|
|
1108
|
+
// would still silently drop a caller's options at this call site.
|
|
996
1109
|
return (cmd, args, options) => {
|
|
997
1110
|
const child = baseSpawn(cmd, args, options);
|
|
998
1111
|
child.once("exit", () => {
|
|
@@ -1002,7 +1115,7 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
|
1002
1115
|
};
|
|
1003
1116
|
}
|
|
1004
1117
|
/** Launches (or relaunches) a supervised instance: spawns through
|
|
1005
|
-
* tryLaunchOne() (the SAME single guarded primitive
|
|
1118
|
+
* tryLaunchOne() (the SAME single guarded primitive established above --
|
|
1006
1119
|
* "spawn again through the SAME single guarded launch function", never a
|
|
1007
1120
|
* second, parallel spawn path), writes the per-instance boot/crash log at
|
|
1008
1121
|
* the path shape the retiring supervisor used (a `logs/` directory under
|
|
@@ -1017,15 +1130,27 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
|
1017
1130
|
* fact that spawnAndRecordInstance() creates a BRAND NEW InstanceRecord
|
|
1018
1131
|
* object on every launch, replacing the old one at the same port key.
|
|
1019
1132
|
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1133
|
+
* `remoteMonitorPort` is threaded the SAME way and for the same reason --
|
|
1134
|
+
* it belongs to the instance, not to a single spawn of it. The replacement
|
|
1135
|
+
* reuses the port the crashed/recycled process just vacated (already
|
|
1136
|
+
* reserved in `state.blockedPorts`, so nothing else can have taken it
|
|
1024
1137
|
* meanwhile), exactly as it reuses the primary `port` argument; this function
|
|
1025
1138
|
* stays fully synchronous and never allocates. `undefined` is the correct
|
|
1026
1139
|
* value for a FIRST launch through superviseChild() and for every fork launch,
|
|
1027
|
-
* which is why the parameter is optional.
|
|
1028
|
-
|
|
1140
|
+
* which is why the parameter is optional.
|
|
1141
|
+
*
|
|
1142
|
+
* `profile` is threaded the SAME way and for a sharper version of the same
|
|
1143
|
+
* reason -- it belongs to the instance, not to a single spawn of it, and
|
|
1144
|
+
* warp is fixed at spawn (there is no runtime `WarpMode` resource on stock
|
|
1145
|
+
* at all). A replacement that dropped it would come back unwarped while
|
|
1146
|
+
* its record still claimed warp, which is exactly the
|
|
1147
|
+
* mismatch-between-grant-and-request that this carry-forward exists to
|
|
1148
|
+
* make impossible. `undefined` is correct for a FIRST launch through
|
|
1149
|
+
* superviseChild() -- production has no profile-less first-launch call site
|
|
1150
|
+
* of its own left now that the warm floor is retired, but this module's
|
|
1151
|
+
* own unit tests still drive one directly -- and for every fork launch,
|
|
1152
|
+
* which is why this parameter is optional too. */
|
|
1153
|
+
function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort, profile) {
|
|
1029
1154
|
const supervisorDir = join(deps.stateDir, String(port));
|
|
1030
1155
|
const epochFile = deps.epoch.epochPathFor(deps.stateDir, port);
|
|
1031
1156
|
const logDir = deps.epoch.instanceLogDirFor(deps.stateDir, port);
|
|
@@ -1041,15 +1166,15 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
|
|
|
1041
1166
|
const logFileName = `${basename(viceBin)}-${Date.now()}-e${epoch}.log`;
|
|
1042
1167
|
const logPath = join(logDir, logFileName);
|
|
1043
1168
|
const logRelPath = `logs/${logFileName}`;
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1169
|
+
// Forwards a third options argument and MERGES it with the per-instance
|
|
1170
|
+
// log stdio -- caller options spread FIRST, `stdio` set LAST, so the
|
|
1171
|
+
// per-instance log fd always wins. Never the other order: a
|
|
1172
|
+
// caller-supplied `stdio` would silently redirect a crash-respawn's
|
|
1173
|
+
// output away from the log file the epoch record names, and the
|
|
1174
|
+
// forensic per-instance log would point at a file that received nothing.
|
|
1175
|
+
// Without this fix, a stock instance that crashes and respawns comes back
|
|
1176
|
+
// reading the operator's real `vicerc` even though its original launch
|
|
1177
|
+
// was isolated.
|
|
1053
1178
|
const defaultRealSpawn = (cmd, args, options) => {
|
|
1054
1179
|
const fd = openSync(logPath, "a");
|
|
1055
1180
|
return nodeSpawn(cmd, args, { ...options, stdio: ["ignore", fd, fd] });
|
|
@@ -1067,6 +1192,7 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
|
|
|
1067
1192
|
backend: deps.backend,
|
|
1068
1193
|
binmonHost: deps.binmonHost,
|
|
1069
1194
|
remoteMonitorPort,
|
|
1195
|
+
profile,
|
|
1070
1196
|
log: deps.log,
|
|
1071
1197
|
});
|
|
1072
1198
|
if (!record)
|
|
@@ -1103,8 +1229,19 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
|
|
|
1103
1229
|
* (crash respawn with backoff, crash-loop give-up, kill-never-recycle via
|
|
1104
1230
|
* the deliberate-kill marker, and the per-instance boot/crash log), exactly
|
|
1105
1231
|
* mirroring resources/vice-supervisor.sh's own respawn loop but expressed
|
|
1106
|
-
* as an event-loop exit handler instead of a `while true` poll.
|
|
1107
|
-
|
|
1232
|
+
* as an event-loop exit handler instead of a `while true` poll.
|
|
1233
|
+
*
|
|
1234
|
+
* `remoteMonitorPort` is an OPTIONAL fourth parameter, threaded straight
|
|
1235
|
+
* through to launchSupervised() exactly like every other optional trailing
|
|
1236
|
+
* parameter in this file. A `backend: "stock"` caller MUST supply it:
|
|
1237
|
+
* spawnAndRecordInstance()'s own construction-site assertion throws
|
|
1238
|
+
* otherwise, since this function is a genuine first-launch call site, not
|
|
1239
|
+
* merely a respawn. This is not a production stock first-launch path today
|
|
1240
|
+
* (only acquirePortAndLaunch() is) -- it exists for this module's own unit
|
|
1241
|
+
* tests to drive a supervised first launch directly, and the parameter
|
|
1242
|
+
* exists so a stock test case can do so without violating the same
|
|
1243
|
+
* guarantee production code enforces. */
|
|
1244
|
+
export function superviseChild(reason, port, deps, remoteMonitorPort) {
|
|
1108
1245
|
const initialBackoffMs = resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
|
|
1109
|
-
return launchSupervised(reason, port, deps, [], initialBackoffMs);
|
|
1246
|
+
return launchSupervised(reason, port, deps, [], initialBackoffMs, remoteMonitorPort);
|
|
1110
1247
|
}
|