@henols/vice-mcp 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/anno-cli.ts +156 -158
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-export-asm.ts +100 -101
- package/anno-graphics.ts +16 -16
- package/anno-hazard-report.ts +2 -2
- package/anno-import.ts +15 -15
- package/anno-index.ts +8 -8
- package/anno-join.ts +35 -35
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +4 -4
- package/anno-regbits-gen.ts +13 -13
- package/anno-store-export.ts +11 -11
- package/anno-store.ts +139 -144
- package/anno-symbols.ts +7 -7
- package/anno-types.ts +55 -55
- package/package.json +1 -1
- package/resources/broker-control.mjs +85 -92
- package/resources/broker-epoch.mjs +6 -7
- package/resources/broker-kill.mjs +29 -30
- package/resources/broker-launch.mjs +352 -370
- package/resources/broker-state.mjs +9 -10
- package/resources/host-tool.mjs +636 -664
- package/resources/vice-broker.mjs +189 -191
- package/vice-broker-client.ts +98 -100
|
@@ -6,29 +6,28 @@
|
|
|
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
|
-
//
|
|
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
17
|
// the launching -> ready promotion sweep (promoteLaunchingInstances() --
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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.
|
|
23
23
|
//
|
|
24
|
-
//
|
|
25
|
-
// (C2/D-23), absorbing resources/vice-supervisor.sh wholesale: superviseChild()
|
|
24
|
+
// This file also grew a real per-child supervisor: superviseChild()
|
|
26
25
|
// launches an instance through tryLaunchOne() (the SAME single guarded
|
|
27
26
|
// primitive above) and installs an exit handler on the spawned child that
|
|
28
27
|
// respawns on crash (doubling backoff, clamped at a ceiling), gives up
|
|
29
28
|
// cleanly after too many crashes inside a window, never respawns a
|
|
30
29
|
// deliberately-killed instance, and writes the per-instance boot/crash log
|
|
31
|
-
//
|
|
30
|
+
// at the exact path shape the retiring bash supervisor used.
|
|
32
31
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
33
32
|
import { mkdirSync, mkdtempSync, openSync, closeSync, existsSync } from "node:fs";
|
|
34
33
|
import { join, basename } from "node:path";
|
|
@@ -37,13 +36,13 @@ import { tmpdir } from "node:os";
|
|
|
37
36
|
// synchronous check, synchronous set, released in a finally, with no
|
|
38
37
|
// `await` between the check and the set.
|
|
39
38
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
47
46
|
// regardless of which reason booted it (vice-broker.mts's
|
|
48
47
|
// selectWarmInstance() performs no `reason` check at all -- proven by
|
|
49
48
|
// vice-broker-acquire.test.ts). Priority governs only which REASON wins
|
|
@@ -54,10 +53,9 @@ let inFlight = false;
|
|
|
54
53
|
// only, never a second guard: nothing branches on this value's presence to
|
|
55
54
|
// decide whether a launch may proceed (that is `inFlight` alone, checked
|
|
56
55
|
// and set synchronously exactly as before). Its only consumer is the
|
|
57
|
-
// launch-slot decision log line
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// 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.
|
|
61
59
|
let inFlightReason = null;
|
|
62
60
|
/** True while a launch is in progress -- exported for the race test plan 02
|
|
63
61
|
* writes against two concurrent tryLaunchOne() calls. */
|
|
@@ -69,31 +67,31 @@ export function isLaunchInFlight() {
|
|
|
69
67
|
// most once per process -- the repo-root.ts `warnedEnvOutsideFrom` gate
|
|
70
68
|
// pattern, reused here.
|
|
71
69
|
let warnedBinmonBindWidened = false;
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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).
|
|
77
75
|
let warnedRemoteMonitorBindWidened = false;
|
|
78
|
-
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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. */
|
|
86
84
|
export const STOCK_DETERMINISM_SEED = 4242;
|
|
87
|
-
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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.
|
|
93
91
|
*
|
|
94
|
-
* The order is fixed and load-bearing beyond readability:
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
|
97
95
|
* intent. */
|
|
98
96
|
export const STOCK_DETERMINISM_FLAGS = Object.freeze([
|
|
99
97
|
"-seed",
|
|
@@ -115,56 +113,49 @@ export const STOCK_DETERMINISM_FLAGS = Object.freeze([
|
|
|
115
113
|
* understands neither `-mcpserver` nor `-binarymonitor` flags, and that need
|
|
116
114
|
* does not depend on which backend is configured.
|
|
117
115
|
*
|
|
118
|
-
* `backend: "stock"` (
|
|
119
|
-
*
|
|
120
|
-
* ip4://<host>:<port
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* emits exactly one stderr note per process, naming the resolved bind
|
|
128
|
-
* address and what the exposure grants.
|
|
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.
|
|
129
125
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* rationale for why the flag itself is set at launch time and not added
|
|
151
|
-
* later: doing so would require relaunching a live instance, destroying all
|
|
152
|
-
* emulation state).
|
|
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.
|
|
153
146
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* survives in full only for the `profile` half: an absent profile adds no
|
|
167
|
-
* flag. */
|
|
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. */
|
|
168
159
|
export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, profile, }) {
|
|
169
160
|
const rawViceArgs = viceArgsEnv ?? process.env.VICE_ARGS;
|
|
170
161
|
if (typeof rawViceArgs === "string" && rawViceArgs.trim() !== "") {
|
|
@@ -194,76 +185,77 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
|
|
|
194
185
|
// `-binarymonitor` or the monitor never binds and the subsequent connect
|
|
195
186
|
// hangs in the backlog looking exactly like a wedge.
|
|
196
187
|
//
|
|
197
|
-
// WORDING CORRECTED
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// adjacency.
|
|
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.
|
|
204
194
|
//
|
|
205
195
|
// The `-console` block's own citation was `alive=yes bound=1`, which does
|
|
206
|
-
// NOT cover this paragraph's property:
|
|
207
|
-
// silently reverting to 0 (NONE) WHILE THE MONITOR
|
|
208
|
-
// liveness and boundness cannot tell the good case
|
|
209
|
-
// guarded against. Re-verified against the
|
|
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
|
|
210
201
|
// [VERIFIED: live probe 2026-09-03, genuine unpatched stock
|
|
211
202
|
// /usr/bin/x64sc (VICE 3.9), DISPLAY and WAYLAND_DISPLAY both unset]:
|
|
212
203
|
// [-default -console -drive8type 1541 <determinism> -binarymonitor]
|
|
213
204
|
// alive=yes bound=1 Drive8Type=1541 Drive8TrueEmulation=1
|
|
214
205
|
// read over `RESOURCE_GET` (0x51) with `-console` interposed. So the
|
|
215
|
-
// citation now covers the RESOURCE and not only liveness. Confirmed
|
|
216
|
-
//
|
|
206
|
+
// citation now covers the RESOURCE and not only liveness. Confirmed
|
|
207
|
+
// sufficient live in a standalone probe:
|
|
217
208
|
// `resourceget "Drive8Type"` moved 0 -> 1541 and a `load` over the text
|
|
218
209
|
// monitor succeeded immediately. Deliberately NOT setting
|
|
219
210
|
// -drive8truedrive / Drive8TrueEmulation here: this build's own default
|
|
220
|
-
// already reads Drive8TrueEmulation=1 (same probe), so
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
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.
|
|
226
217
|
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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
|
|
232
223
|
// [CITED: vice-3.8/src/main.c:184-192, 232-238] -- and `console_mode`
|
|
233
224
|
// gates GTK initialisation at two call sites (`ui_init_with_args`,
|
|
234
225
|
// `ui_init`) that BOTH run before the late command-line parser
|
|
235
226
|
// `initcmdline_check_args()` [CITED: vice-3.8/src/main.c:296-345]. A
|
|
236
227
|
// `-console` seen only by the late parser therefore arrives after GTK has
|
|
237
228
|
// already tried and failed. MEASURED 2026-09-02 with `DISPLAY` and
|
|
238
|
-
// `WAYLAND_DISPLAY` both unset
|
|
229
|
+
// `WAYLAND_DISPLAY` both unset:
|
|
239
230
|
// [-default -console -binarymonitor] alive=yes bound=1
|
|
240
231
|
// [-default -drive8type 1541 -console -binarymonitor] alive=no bound=0 Gtk-WARNING: cannot open display:
|
|
241
232
|
// [-default -console -drive8type 1541 -binarymonitor] alive=yes bound=1
|
|
242
233
|
// So `-console` goes immediately after `-default` and BEFORE
|
|
243
|
-
// `-drive8type` -- which is compatible with the
|
|
244
|
-
// corrected
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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
|
|
247
238
|
// the monitor bound. It is pinned by an ordering assertion rather than by
|
|
248
239
|
// this comment -- a bare flag push with no reason is exactly what let the
|
|
249
240
|
// `-default` ordering constraint be rediscovered by a red CI run last
|
|
250
241
|
// time.
|
|
251
242
|
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// [VERIFIED: live probe 2026-09-02
|
|
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],
|
|
256
247
|
// `-raminitrandomchance 0` is the LOAD-BEARING one: the factory value is
|
|
257
248
|
// **10**, i.e. 0.1% of all RAM bits randomly flipped at power-up, and it is
|
|
258
249
|
// the dominant term in the divergence this milestone removes. The
|
|
259
250
|
// `-raminitstartrandom 0` / `-raminitrepeatrandom 0` pair already reads 0
|
|
260
251
|
// at factory and is DEFENSIVE against an operator `vicerc` -- belt and
|
|
261
|
-
// braces alongside the scratch `XDG_CONFIG_HOME`
|
|
252
|
+
// braces alongside the scratch `XDG_CONFIG_HOME` threaded in
|
|
262
253
|
// spawnAndRecordInstance() below.
|
|
263
254
|
//
|
|
264
|
-
// `+autostart-delay-random` is a FIFTH flag beyond
|
|
265
|
-
// (which names only `-seed` plus the three
|
|
266
|
-
// deliberate ADDITION rather than
|
|
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`
|
|
267
259
|
// ships at **1** on this build [VERIFIED: same probe], it draws an
|
|
268
260
|
// additional random delay of up to 10 frames
|
|
269
261
|
// [CITED: vice-3.8/src/autostart.c:1432-1436], and -- the effect that is
|
|
@@ -273,21 +265,20 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
|
|
|
273
265
|
// behavioural change and not only a timing one. Pin it once; never toggle
|
|
274
266
|
// it between the two runs of a capture pair.
|
|
275
267
|
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// wall-clock bracket, which it invalidates by 1.76x.
|
|
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.
|
|
291
282
|
const args = ["-default"];
|
|
292
283
|
if (profile?.headless) {
|
|
293
284
|
args.push("-console");
|
|
@@ -331,42 +322,40 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
331
322
|
const now = deps.now ?? (() => Date.now());
|
|
332
323
|
const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
|
|
333
324
|
const backend = deps.backend ?? "stock";
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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.
|
|
343
334
|
// The fork case is real and unaffected: this check is stock-only.
|
|
344
335
|
if (backend === "stock" && deps.remoteMonitorPort === undefined) {
|
|
345
|
-
throw new Error("spawnAndRecordInstance: backend \"stock\" requires remoteMonitorPort
|
|
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");
|
|
346
337
|
}
|
|
347
338
|
const viceArgs = buildViceArgs(port, {
|
|
348
339
|
backend,
|
|
349
340
|
mcpHost: deps.mcpHost,
|
|
350
341
|
binmonHost: deps.binmonHost,
|
|
351
342
|
remoteMonitorPort: deps.remoteMonitorPort,
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// cannot disagree.
|
|
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.
|
|
356
346
|
profile: deps.profile,
|
|
357
347
|
});
|
|
358
348
|
const log = deps.log ?? defaultLog;
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// must survive this widening.
|
|
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.
|
|
370
359
|
//
|
|
371
360
|
// Scope boundary (do not remove this note): the production broker daemon
|
|
372
361
|
// always supplies its own deps.spawn / deps.spawnFactory, so the widened
|
|
@@ -374,13 +363,12 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
374
363
|
// function's job is only to COMPUTE the value at the one seam that should
|
|
375
364
|
// own it; the forwarding to nodeSpawn() happens at three further hops --
|
|
376
365
|
// makeLoggingSpawn() in vice-broker.mts, and withCrashSupervision()'s
|
|
377
|
-
// wrapper body and launchSupervised()'s defaultRealSpawn in this file
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
// (plan 08.2-06 closed them in this same phase, with a handleAcquire()
|
|
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()
|
|
382
370
|
// composition test that omits buildColdSpawnFactory so an injected stub
|
|
383
|
-
// cannot fake the proof
|
|
371
|
+
// cannot fake the proof. If you add another spawn hop, it must forward
|
|
384
372
|
// options too, or production stock launches silently lose their config
|
|
385
373
|
// isolation again.
|
|
386
374
|
//
|
|
@@ -415,25 +403,25 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
415
403
|
viceBin,
|
|
416
404
|
viceArgs,
|
|
417
405
|
dryRun: false,
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
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.
|
|
422
410
|
monitorClients: {},
|
|
423
|
-
//
|
|
424
|
-
//
|
|
411
|
+
// Key omitted only on the FORK path now -- the guard above already
|
|
412
|
+
// throws before this point for any stock call with no
|
|
425
413
|
// remoteMonitorPort, so a stock record reaching this line always
|
|
426
414
|
// supplies the key. "Absent" means fork, never "stock allocation
|
|
427
415
|
// failed" (that state no longer exists).
|
|
428
416
|
...(deps.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: deps.remoteMonitorPort }),
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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.
|
|
437
425
|
...(deps.profile === undefined ? {} : { profile: { ...deps.profile } }),
|
|
438
426
|
};
|
|
439
427
|
deps.state.instances.set(port, record);
|
|
@@ -470,45 +458,45 @@ export function tryLaunchOne(reason, port, deps) {
|
|
|
470
458
|
* allocate-a-port-then-launch sequence -- not merely the synchronous spawn
|
|
471
459
|
* instant tryLaunchOne() alone guards. This closes a genuine race window
|
|
472
460
|
* tryLaunchOne() cannot: nextFreePort()'s own port-in-use probe is
|
|
473
|
-
* asynchronous (
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
461
|
+
* asynchronous (a real bind-and-release check), so two overlapping callers
|
|
462
|
+
* could otherwise BOTH be told the SAME candidate port is free before
|
|
463
|
+
* either commits it to state.instances -- a double-launch on one port,
|
|
464
|
+
* silently overwriting the earlier record. The guard is checked and set
|
|
465
|
+
* SYNCHRONOUSLY before the first `await`, exactly like tryLaunchOne()'s own
|
|
466
|
+
* discipline, so a second concurrent call is refused immediately
|
|
467
|
+
* (`launch_in_flight`) rather than racing on the allocation.
|
|
480
468
|
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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
|
|
489
477
|
* concurrent acquires -- e.g. two requests arriving over the TCP control
|
|
490
478
|
* listener at nearly the same moment, or one arriving while an EARLIER
|
|
491
479
|
* acquire's own launch is still resolving -- never a warming pass, which no
|
|
492
480
|
* longer exists. This is also the function that restores vice-broker.sh's
|
|
493
481
|
* own process_requests() throttle (its `in_flight` local): whatever launches
|
|
494
482
|
* this broker ever attempts, they never overlap, matching the bash
|
|
495
|
-
* original's declined-to-change behaviour
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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. */
|
|
508
496
|
export async function acquirePortAndLaunch(reason, deps) {
|
|
509
497
|
const log = deps.log ?? defaultLog;
|
|
510
498
|
if (inFlight) {
|
|
511
|
-
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`);
|
|
512
500
|
return { ok: false, reason: "launch_in_flight" };
|
|
513
501
|
}
|
|
514
502
|
inFlight = true;
|
|
@@ -522,14 +510,14 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
522
510
|
const supervisorDir = join(deps.stateDir, String(port));
|
|
523
511
|
const epochFile = join(supervisorDir, "epoch.json");
|
|
524
512
|
const spawn = deps.spawnFactory ? deps.spawnFactory(port) : deps.spawn;
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
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.
|
|
533
521
|
let remoteMonitorPort;
|
|
534
522
|
if (deps.backend === "stock" && deps.allocateRemoteMonitorPort) {
|
|
535
523
|
const remoteResult = await deps.allocateRemoteMonitorPort(deps.state, new Set([port]));
|
|
@@ -548,12 +536,12 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
548
536
|
remoteMonitorPort = remoteResult.port;
|
|
549
537
|
}
|
|
550
538
|
else {
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
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
|
|
557
545
|
// `nextFreePort()`'s own in-use probe blocks a candidate, and that
|
|
558
546
|
// never ran against the winning candidate) -- so it is already
|
|
559
547
|
// allocatable again on the very next call with no further release
|
|
@@ -561,7 +549,7 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
561
549
|
// it. This failure arm must NEVER call spawnAndRecordInstance() or
|
|
562
550
|
// otherwise leave a port "spoken for" on the caller's behalf.
|
|
563
551
|
log(`vice-broker: second (-remotemonitor) port allocation failed (${remoteResult.reason}) -- ` +
|
|
564
|
-
`abandoning the stock launch; the text-monitor port is mandatory on every stock launch
|
|
552
|
+
`abandoning the stock launch; the text-monitor port is mandatory on every stock launch and the acquire fails`);
|
|
565
553
|
return { ok: false, reason: "no_free_text_port" };
|
|
566
554
|
}
|
|
567
555
|
}
|
|
@@ -589,17 +577,17 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
589
577
|
* deleting the record AND handing its second (`-remotemonitor`) port back to
|
|
590
578
|
* the allocator in the same step.
|
|
591
579
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
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.
|
|
603
591
|
*
|
|
604
592
|
* A RESPAWN is deliberately NOT a call site: the replacement instance keeps
|
|
605
593
|
* BOTH the primary port and the remote-monitor port of the instance it
|
|
@@ -660,7 +648,7 @@ async function defaultHttpProbe(port, timeoutMs) {
|
|
|
660
648
|
}
|
|
661
649
|
}
|
|
662
650
|
// ---------------------------------------------------------------------------
|
|
663
|
-
//
|
|
651
|
+
// The STOCK readiness route.
|
|
664
652
|
//
|
|
665
653
|
// probeReady() below used to POST http://127.0.0.1:<port>/mcp unconditionally
|
|
666
654
|
// and require both "version" and "machine" in the body. On the stock backend
|
|
@@ -681,8 +669,9 @@ async function defaultHttpProbe(port, timeoutMs) {
|
|
|
681
669
|
// probe-binmon.mjs. What is written here is the minimum a READINESS check needs:
|
|
682
670
|
// one request header out, one response header in, four bytes checked.
|
|
683
671
|
// ---------------------------------------------------------------------------
|
|
684
|
-
/** Hand-copied from
|
|
685
|
-
*
|
|
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. */
|
|
686
675
|
const BINMON_STX = 0x02;
|
|
687
676
|
const BINMON_API_VERSION = 0x02;
|
|
688
677
|
const BINMON_REQUEST_HEADER_LEN = 11;
|
|
@@ -708,15 +697,15 @@ function binmonRequest(commandType, requestId) {
|
|
|
708
697
|
* collides between the two, which is exactly why demux must key on it. */
|
|
709
698
|
const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
|
|
710
699
|
/**
|
|
711
|
-
*
|
|
700
|
+
* One PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
|
|
712
701
|
* reply -- STX, the expected api_version, response type 0x81, error code 0x00,
|
|
713
702
|
* and this probe's own request id. A bare TCP accept is explicitly insufficient
|
|
714
703
|
* here for exactly the reason probeReady()'s own comment gives for the HTTP
|
|
715
704
|
* route: a C64 can accept a connection before it has finished booting.
|
|
716
705
|
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
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
|
|
720
709
|
* probe's own PING reply ever arrives. The naive "the first 12 bytes ARE the
|
|
721
710
|
* reply" read this code used to do treated that event frame's OWN response-
|
|
722
711
|
* type byte (0x31) as a malformed PING reply and answered `false` forever,
|
|
@@ -732,8 +721,8 @@ const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
|
|
|
732
721
|
* ViceMonitorClient chief among them).
|
|
733
722
|
*
|
|
734
723
|
* Then EXIT (0xaa), unconditionally, before closing -- because the PING ITSELF
|
|
735
|
-
* HALTS THE MACHINE. Any inbound byte does
|
|
736
|
-
*
|
|
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
|
|
737
726
|
* readiness probe that left every warm instance frozen would be a worse defect
|
|
738
727
|
* than the one it fixes: the emulator would be "ready" and stopped.
|
|
739
728
|
*
|
|
@@ -821,32 +810,29 @@ async function defaultBinmonProbe(port, timeoutMs) {
|
|
|
821
810
|
});
|
|
822
811
|
});
|
|
823
812
|
}
|
|
824
|
-
/**
|
|
825
|
-
* in the exact place a three-branch description used to
|
|
826
|
-
*
|
|
827
|
-
* not merely in the plan text (`01.6.2.1-02-PLAN.md`) or the validation
|
|
828
|
-
* 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.
|
|
829
816
|
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
* 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.
|
|
838
824
|
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
* mechanism
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
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.
|
|
850
836
|
*
|
|
851
837
|
* No retry loop, deliberately: a still-booting instance simply fails THIS
|
|
852
838
|
* pass and is re-probed on the next one (promoteLaunchingInstances()'s own
|
|
@@ -858,7 +844,7 @@ async function defaultBinmonProbe(port, timeoutMs) {
|
|
|
858
844
|
export async function probeReady(port, deps = {}) {
|
|
859
845
|
const timeoutS = Number(deps.probeTimeoutSEnv ?? process.env.VICE_BROKER_PROBE_TIMEOUT_S) || DEFAULT_PROBE_TIMEOUT_S;
|
|
860
846
|
const timeoutMs = timeoutS * 1000;
|
|
861
|
-
//
|
|
847
|
+
// The route is chosen by the backend, exactly like buildViceArgs()'s
|
|
862
848
|
// own argv choice, and from the SAME threaded-down verdict. The fork arm below
|
|
863
849
|
// is byte-identical to what this function always did, including the
|
|
864
850
|
// omitted-backend default -- a fork deployment sees no behaviour change.
|
|
@@ -880,7 +866,7 @@ export async function probeReady(port, deps = {}) {
|
|
|
880
866
|
export async function promoteLaunchingInstances(deps) {
|
|
881
867
|
const log = deps.log ?? defaultLog;
|
|
882
868
|
const now = deps.now ?? (() => Date.now());
|
|
883
|
-
//
|
|
869
|
+
// The DEFAULT probe follows this call's own backend, so a caller that
|
|
884
870
|
// threads `backend` and omits `probe` gets a matching readiness route
|
|
885
871
|
// rather than an HTTP POST at a binary-monitor port. An explicitly
|
|
886
872
|
// injected `probe` still wins, unchanged.
|
|
@@ -902,22 +888,21 @@ export async function promoteLaunchingInstances(deps) {
|
|
|
902
888
|
* comment names the ordering as load-bearing: "the spare invariant is
|
|
903
889
|
* always re-evaluated against the freshest possible grant/teardown
|
|
904
890
|
* state"). The bash version's third concern, the grant sweep, does NOT
|
|
905
|
-
* appear here -- it is one of
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
* 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.
|
|
912
897
|
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
898
|
+
* The warm floor that non-preemptive launch priority originally reasoned
|
|
899
|
+
* about here is GONE -- `promoteLaunching` never calls
|
|
915
900
|
* acquirePortAndLaunch() and so never competes for the single in-flight
|
|
916
901
|
* launch slot the way a warm-floor spare launch used to. `serveAcquires()`
|
|
917
902
|
* (via its own drainPendingAcquires()) is now the ONLY caller in this pass
|
|
918
|
-
* that ever launches anything, so
|
|
919
|
-
*
|
|
920
|
-
*
|
|
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
|
|
921
906
|
* acquires still resolve through the single in-flight owner
|
|
922
907
|
* (acquirePortAndLaunch()'s own invariant comment), which this reordering
|
|
923
908
|
* never weakens). What the fixed order still buys: promoting AFTER serving
|
|
@@ -932,14 +917,14 @@ export async function runBrokerPass(deps) {
|
|
|
932
917
|
await deps.promoteLaunching();
|
|
933
918
|
}
|
|
934
919
|
// ===========================================================================
|
|
935
|
-
// Per-child supervision
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
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.
|
|
943
928
|
// ===========================================================================
|
|
944
929
|
function resolveMs(envVar, defaultSeconds, override) {
|
|
945
930
|
if (typeof override === "number")
|
|
@@ -996,12 +981,11 @@ async function handleExit(reason, port, deps) {
|
|
|
996
981
|
return;
|
|
997
982
|
}
|
|
998
983
|
const log = deps.log ?? defaultLog;
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
// releasing can never hold this lock forever on any channel. Redundant
|
|
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
|
|
1005
989
|
// with the respawn/delete paths below (a fresh InstanceRecord never
|
|
1006
990
|
// carries this forward; a deleted one has no field to carry), but
|
|
1007
991
|
// explicit for the same reason broker-state.mts's own header comment
|
|
@@ -1024,20 +1008,19 @@ async function handleExit(reason, port, deps) {
|
|
|
1024
1008
|
const preKillState = record.state;
|
|
1025
1009
|
const preKillCrashTimes = record.crashTimes ?? [];
|
|
1026
1010
|
const preKillBackoffMs = record.backoffMs ?? resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
// 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.
|
|
1032
1015
|
const preKillRemoteMonitorPort = record.remoteMonitorPort;
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
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.
|
|
1041
1024
|
// Captured BEFORE launchSupervised() overwrites this port's map entry
|
|
1042
1025
|
// with a brand new record, same as the four values above.
|
|
1043
1026
|
const preKillProfile = record.profile;
|
|
@@ -1061,7 +1044,7 @@ async function handleExit(reason, port, deps) {
|
|
|
1061
1044
|
deps.onOutcome?.("recycled", port);
|
|
1062
1045
|
return;
|
|
1063
1046
|
}
|
|
1064
|
-
//
|
|
1047
|
+
// A deliberate teardown is the END of this instance -- its
|
|
1065
1048
|
// remote-monitor port must go back to the allocator with it.
|
|
1066
1049
|
deleteInstanceRecord(deps.state, port);
|
|
1067
1050
|
deps.onOutcome?.("deliberate_teardown", port);
|
|
@@ -1075,7 +1058,7 @@ async function handleExit(reason, port, deps) {
|
|
|
1075
1058
|
if (crashTimes.length >= maxRestarts) {
|
|
1076
1059
|
log(`vice-broker: giving up on port ${port} after ${crashTimes.length} crashes within ${crashWindowMs}ms -- ` +
|
|
1077
1060
|
`this is not a transient crash; check VICE_ARGS and whether the port is already bound`);
|
|
1078
|
-
//
|
|
1061
|
+
// Giving up is likewise terminal for this instance -- release its
|
|
1079
1062
|
// remote-monitor port rather than leaking it out of the allocation band.
|
|
1080
1063
|
deleteInstanceRecord(deps.state, port);
|
|
1081
1064
|
deps.onOutcome?.("given_up", port);
|
|
@@ -1086,13 +1069,13 @@ async function handleExit(reason, port, deps) {
|
|
|
1086
1069
|
await sleepMs(currentBackoffMs);
|
|
1087
1070
|
const maxBackoffMs = resolveMs("VICE_RESTART_BACKOFF_MAX_S", 30, deps.maxBackoffMs);
|
|
1088
1071
|
const nextBackoffMs = Math.min(currentBackoffMs * 2, maxBackoffMs);
|
|
1089
|
-
//
|
|
1072
|
+
// Same carry-forward as the recycle branch above -- a crash must not
|
|
1090
1073
|
// silently strip `-remotemonitor` (and its InstanceRecord field) off the
|
|
1091
|
-
// replacement, which
|
|
1092
|
-
// stop being true the first time an instance was
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
// replacement while leaving the record claiming them
|
|
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.
|
|
1096
1079
|
const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort, record.profile);
|
|
1097
1080
|
deps.onOutcome?.(respawned ? "respawned" : "given_up", port);
|
|
1098
1081
|
}
|
|
@@ -1118,12 +1101,11 @@ async function handleExit(reason, port, deps) {
|
|
|
1118
1101
|
* a second inline listener, is what keeps the "exactly one installation
|
|
1119
1102
|
* point" invariant a structural gate (broker-launch.test.ts) can hold. */
|
|
1120
1103
|
export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
// 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.
|
|
1127
1109
|
return (cmd, args, options) => {
|
|
1128
1110
|
const child = baseSpawn(cmd, args, options);
|
|
1129
1111
|
child.once("exit", () => {
|
|
@@ -1133,7 +1115,7 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
|
1133
1115
|
};
|
|
1134
1116
|
}
|
|
1135
1117
|
/** Launches (or relaunches) a supervised instance: spawns through
|
|
1136
|
-
* tryLaunchOne() (the SAME single guarded primitive
|
|
1118
|
+
* tryLaunchOne() (the SAME single guarded primitive established above --
|
|
1137
1119
|
* "spawn again through the SAME single guarded launch function", never a
|
|
1138
1120
|
* second, parallel spawn path), writes the per-instance boot/crash log at
|
|
1139
1121
|
* the path shape the retiring supervisor used (a `logs/` directory under
|
|
@@ -1148,26 +1130,26 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
|
1148
1130
|
* fact that spawnAndRecordInstance() creates a BRAND NEW InstanceRecord
|
|
1149
1131
|
* object on every launch, replacing the old one at the same port key.
|
|
1150
1132
|
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
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
|
|
1155
1137
|
* meanwhile), exactly as it reuses the primary `port` argument; this function
|
|
1156
1138
|
* stays fully synchronous and never allocates. `undefined` is the correct
|
|
1157
1139
|
* value for a FIRST launch through superviseChild() and for every fork launch,
|
|
1158
1140
|
* which is why the parameter is optional.
|
|
1159
1141
|
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
* mismatch-between-grant-and-request that
|
|
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
|
|
1166
1148
|
* make impossible. `undefined` is correct for a FIRST launch through
|
|
1167
1149
|
* superviseChild() -- production has no profile-less first-launch call site
|
|
1168
|
-
* of its own left
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
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. */
|
|
1171
1153
|
function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort, profile) {
|
|
1172
1154
|
const supervisorDir = join(deps.stateDir, String(port));
|
|
1173
1155
|
const epochFile = deps.epoch.epochPathFor(deps.stateDir, port);
|
|
@@ -1184,15 +1166,15 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
|
|
|
1184
1166
|
const logFileName = `${basename(viceBin)}-${Date.now()}-e${epoch}.log`;
|
|
1185
1167
|
const logPath = join(logDir, logFileName);
|
|
1186
1168
|
const logRelPath = `logs/${logFileName}`;
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
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.
|
|
1196
1178
|
const defaultRealSpawn = (cmd, args, options) => {
|
|
1197
1179
|
const fd = openSync(logPath, "a");
|
|
1198
1180
|
return nodeSpawn(cmd, args, { ...options, stdio: ["ignore", fd, fd] });
|
|
@@ -1249,16 +1231,16 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
|
|
|
1249
1231
|
* mirroring resources/vice-supervisor.sh's own respawn loop but expressed
|
|
1250
1232
|
* as an event-loop exit handler instead of a `while true` poll.
|
|
1251
1233
|
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
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. */
|
|
1262
1244
|
export function superviseChild(reason, port, deps, remoteMonitorPort) {
|
|
1263
1245
|
const initialBackoffMs = resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
|
|
1264
1246
|
return launchSupervised(reason, port, deps, [], initialBackoffMs, remoteMonitorPort);
|