@henols/vice-mcp 0.1.9 → 0.1.11
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 +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
|
@@ -59,21 +59,84 @@ let inFlightReason = null;
|
|
|
59
59
|
export function isLaunchInFlight() {
|
|
60
60
|
return inFlight;
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
// Gates the stock binmon-bind-widened stderr note below so a long-running
|
|
63
|
+
// broker (or a test suite driving buildViceArgs() many times) emits it at
|
|
64
|
+
// most once per process -- the repo-root.ts `warnedEnvOutsideFrom` gate
|
|
65
|
+
// pattern, reused here.
|
|
66
|
+
let warnedBinmonBindWidened = false;
|
|
67
|
+
// Plan 03-04 (DIRECT-06, D-13): the SAME one-time-note idiom as
|
|
68
|
+
// warnedBinmonBindWidened above, for the SECOND (`-remotemonitor`) port's
|
|
69
|
+
// bind -- a separate boolean because the two flags widen independently (a
|
|
70
|
+
// caller could widen one host override and not the other, though in
|
|
71
|
+
// practice both resolve from the same `binmonHost` value below).
|
|
72
|
+
let warnedRemoteMonitorBindWidened = false;
|
|
73
|
+
/** Resolves the emulator's own argument vector for the given `backend`. The
|
|
74
|
+
* `VICE_ARGS` full-override short-circuit (matching
|
|
75
|
+
* resources/vice-supervisor.sh's own VICE_ARGS convention exactly) is
|
|
76
|
+
* checked FIRST, ahead of either backend branch, and stays unchanged for
|
|
77
|
+
* both: it exists because this broker's own tests (and an operator's manual
|
|
78
|
+
* dry runs) need to launch a stand-in binary (e.g. /bin/sleep) that
|
|
79
|
+
* understands neither `-mcpserver` nor `-binarymonitor` flags, and that need
|
|
80
|
+
* does not depend on which backend is configured.
|
|
81
|
+
*
|
|
82
|
+
* `backend: "fork"` returns exactly the pre-Phase-2 shape, byte-identical:
|
|
83
|
+
* the MCP server flag, the MCP server host from `mcpHost` or
|
|
84
|
+
* VICE_BROKER_MCP_HOST (default `0.0.0.0`), and the MCP server port.
|
|
85
|
+
*
|
|
86
|
+
* `backend: "stock"` returns `-binarymonitor -binarymonitoraddress
|
|
87
|
+
* ip4://<host>:<port>` (docs/phase1-probe-results.md's confirmed real-world
|
|
88
|
+
* command line). The host resolves from `binmonHost` or
|
|
89
|
+
* VICE_BROKER_BINMON_HOST, defaulting to `127.0.0.1` -- deliberately
|
|
90
|
+
* narrower than the fork path's `0.0.0.0` default, because VICE's binary
|
|
91
|
+
* monitor is unauthenticated by design and grants full read/write over the
|
|
92
|
+
* emulated machine plus process control to anything that can reach it
|
|
93
|
+
* (planner decision, `02-03-PLAN.md`). Widening the bind away from loopback
|
|
94
|
+
* emits exactly one stderr note per process, naming the resolved bind
|
|
95
|
+
* address and what the exposure grants.
|
|
96
|
+
*
|
|
97
|
+
* Plan 03-04 (DIRECT-06, D-13): when `remoteMonitorPort` is a number, the
|
|
98
|
+
* stock branch APPENDS `-remotemonitor -remotemonitoraddress
|
|
99
|
+
* ip4://<host>:<remoteMonitorPort>`, reusing the SAME resolved `host` value
|
|
100
|
+
* the binmon address already used -- one resolution, not two. When
|
|
101
|
+
* `remoteMonitorPort` is omitted (undefined), the returned argv is
|
|
102
|
+
* byte-identical to what this function always returned -- no
|
|
103
|
+
* `-remotemonitor` at all. `-remotemonitoraddress`'s exact spelling is
|
|
104
|
+
* `[ASSUMED]` by symmetry with `-binarymonitoraddress` (RESEARCH.md
|
|
105
|
+
* Assumption A1) and is filed as probe debt under
|
|
106
|
+
* `.planning/todos/pending/`. Widening THIS bind away from loopback emits
|
|
107
|
+
* its own one-time stderr note (`warnedRemoteMonitorBindWidened`), naming
|
|
108
|
+
* the resolved address and stating that VICE's TEXT monitor accepts
|
|
109
|
+
* arbitrary monitor commands and is unauthenticated -- Phase 3 dials
|
|
110
|
+
* nothing on this port; only the launch flag lands now (see D-13's own
|
|
111
|
+
* rationale: adding the flag later would require relaunching a live
|
|
112
|
+
* instance, destroying all emulation state). */
|
|
113
|
+
export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, }) {
|
|
73
114
|
const rawViceArgs = viceArgsEnv ?? process.env.VICE_ARGS;
|
|
74
115
|
if (typeof rawViceArgs === "string" && rawViceArgs.trim() !== "") {
|
|
75
116
|
return rawViceArgs.trim().split(/\s+/);
|
|
76
117
|
}
|
|
118
|
+
if (backend === "stock") {
|
|
119
|
+
const host = binmonHost ?? process.env.VICE_BROKER_BINMON_HOST ?? "127.0.0.1";
|
|
120
|
+
if (host !== "127.0.0.1" && !warnedBinmonBindWidened) {
|
|
121
|
+
warnedBinmonBindWidened = true;
|
|
122
|
+
process.stderr.write(`vice-broker: stock binary-monitor bind widened to ${host} -- VICE's binary monitor is ` +
|
|
123
|
+
`unauthenticated and grants full memory read/write plus process control to anything that can ` +
|
|
124
|
+
`reach it; the default of 127.0.0.1 is the safe posture for a host-native install, widen only ` +
|
|
125
|
+
`when the MCP server itself runs in a container that must reach the host emulator\n`);
|
|
126
|
+
}
|
|
127
|
+
const args = ["-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`];
|
|
128
|
+
if (typeof remoteMonitorPort === "number") {
|
|
129
|
+
if (host !== "127.0.0.1" && !warnedRemoteMonitorBindWidened) {
|
|
130
|
+
warnedRemoteMonitorBindWidened = true;
|
|
131
|
+
process.stderr.write(`vice-broker: stock text (-remotemonitor) monitor bind widened to ${host} -- VICE's text monitor ` +
|
|
132
|
+
`accepts arbitrary monitor commands and is unauthenticated, exactly like the binary monitor; the ` +
|
|
133
|
+
`default of 127.0.0.1 is the safe posture for a host-native install, widen only when the MCP server ` +
|
|
134
|
+
`itself runs in a container that must reach the host emulator\n`);
|
|
135
|
+
}
|
|
136
|
+
args.push("-remotemonitor", "-remotemonitoraddress", `ip4://${host}:${remoteMonitorPort}`);
|
|
137
|
+
}
|
|
138
|
+
return args;
|
|
139
|
+
}
|
|
77
140
|
const host = mcpHost ?? process.env.VICE_BROKER_MCP_HOST ?? "0.0.0.0";
|
|
78
141
|
return ["-mcpserver", "-mcpserverhost", host, "-mcpserverport", String(port)];
|
|
79
142
|
}
|
|
@@ -94,7 +157,13 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
94
157
|
const spawnFn = deps.spawn ?? ((cmd, args) => nodeSpawn(cmd, args));
|
|
95
158
|
const now = deps.now ?? (() => Date.now());
|
|
96
159
|
const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
|
|
97
|
-
const
|
|
160
|
+
const backend = deps.backend ?? "fork";
|
|
161
|
+
const viceArgs = buildViceArgs(port, {
|
|
162
|
+
backend,
|
|
163
|
+
mcpHost: deps.mcpHost,
|
|
164
|
+
binmonHost: deps.binmonHost,
|
|
165
|
+
remoteMonitorPort: deps.remoteMonitorPort,
|
|
166
|
+
});
|
|
98
167
|
const log = deps.log ?? defaultLog;
|
|
99
168
|
log(`vice-broker: launching ${viceBin} ${viceArgs.join(" ")}`);
|
|
100
169
|
const child = spawnFn(viceBin, viceArgs);
|
|
@@ -112,6 +181,7 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
112
181
|
viceBin,
|
|
113
182
|
viceArgs,
|
|
114
183
|
dryRun: false,
|
|
184
|
+
...(deps.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: deps.remoteMonitorPort }),
|
|
115
185
|
};
|
|
116
186
|
deps.state.instances.set(port, record);
|
|
117
187
|
return record;
|
|
@@ -188,6 +258,38 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
188
258
|
const supervisorDir = join(deps.stateDir, String(port));
|
|
189
259
|
const epochFile = join(supervisorDir, "epoch.json");
|
|
190
260
|
const spawn = deps.spawnFactory ? deps.spawnFactory(port) : deps.spawn;
|
|
261
|
+
// Plan 03-04 (DIRECT-06, D-13): the second (`-remotemonitor`) port is
|
|
262
|
+
// resolved HERE, still inside the single in_flight owner's own
|
|
263
|
+
// try-block, immediately after the primary allocation succeeds -- both
|
|
264
|
+
// awaits stay inside this SAME try, after the guard's synchronous
|
|
265
|
+
// check-and-set above; neither is moved, duplicated, or awaited around
|
|
266
|
+
// that guard. Only ever attempted for `backend === "stock"`, and only
|
|
267
|
+
// when the caller actually provided the allocator -- every fork launch
|
|
268
|
+
// and every pre-Phase-3 caller never reaches this branch at all.
|
|
269
|
+
let remoteMonitorPort;
|
|
270
|
+
if (deps.backend === "stock" && deps.allocateRemoteMonitorPort) {
|
|
271
|
+
const remoteResult = await deps.allocateRemoteMonitorPort(deps.state, new Set([port]));
|
|
272
|
+
if (remoteResult.ok) {
|
|
273
|
+
// Assigned directly (not via broker-state.mjs's blockPort()) -- this
|
|
274
|
+
// module's own type-only import of that sibling is load-bearing
|
|
275
|
+
// (see this file's own header comment): a VALUE import would turn
|
|
276
|
+
// "./broker-state.mjs" into a real runtime resolution this file
|
|
277
|
+
// cannot satisfy when loaded directly, as this file's own unit test
|
|
278
|
+
// does. `state.blockedPorts` is a plain Set the type import already
|
|
279
|
+
// describes, so mutating it directly needs no value import at all --
|
|
280
|
+
// exactly the same discipline handleExit()'s own
|
|
281
|
+
// `record.monitorClient = undefined` uses in place of
|
|
282
|
+
// clearMonitorClient().
|
|
283
|
+
deps.state.blockedPorts.add(remoteResult.port);
|
|
284
|
+
remoteMonitorPort = remoteResult.port;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Degrade, never fail: a port nothing dials yet (Phase 3 builds no
|
|
288
|
+
// text-monitor client) must never make the backend unavailable.
|
|
289
|
+
log(`vice-broker: second (-remotemonitor) port allocation failed (${remoteResult.reason}) -- ` +
|
|
290
|
+
`launching WITHOUT -remotemonitor; nothing in Phase 3 dials the text-monitor port anyway`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
191
293
|
const record = spawnAndRecordInstance(reason, port, {
|
|
192
294
|
state: deps.state,
|
|
193
295
|
supervisorDir,
|
|
@@ -196,6 +298,9 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
196
298
|
now: deps.now,
|
|
197
299
|
viceBin: deps.viceBin,
|
|
198
300
|
mcpHost: deps.mcpHost,
|
|
301
|
+
backend: deps.backend,
|
|
302
|
+
binmonHost: deps.binmonHost,
|
|
303
|
+
remoteMonitorPort,
|
|
199
304
|
});
|
|
200
305
|
return { ok: true, record };
|
|
201
306
|
}
|
|
@@ -204,6 +309,43 @@ export async function acquirePortAndLaunch(reason, deps) {
|
|
|
204
309
|
inFlightReason = null;
|
|
205
310
|
}
|
|
206
311
|
}
|
|
312
|
+
/** The ONE way an instance record leaves `state.instances` for good --
|
|
313
|
+
* deleting the record AND handing its second (`-remotemonitor`) port back to
|
|
314
|
+
* the allocator in the same step.
|
|
315
|
+
*
|
|
316
|
+
* CR-02 (03-REVIEW.md): `acquirePortAndLaunch()` above adds every allocated
|
|
317
|
+
* remote-monitor port to `state.blockedPorts`, and until this function existed
|
|
318
|
+
* NOTHING ever removed one. `nextFreePort()` never reconsiders a blocked
|
|
319
|
+
* candidate for the lifetime of the process, so every teardown of a stock
|
|
320
|
+
* instance permanently consumed one more port out of the fixed
|
|
321
|
+
* PORT_SCAN_CEILING window even though the OS port was free again the instant
|
|
322
|
+
* the owning process exited -- a long-running broker (the explicit design goal
|
|
323
|
+
* of an on-demand pool with crash supervision and a warm floor) eventually
|
|
324
|
+
* exhausts its band and answers `no_free_port` to ordinary launches purely
|
|
325
|
+
* from routine churn, with no operator recourse short of a broker restart.
|
|
326
|
+
*
|
|
327
|
+
* A RESPAWN is deliberately NOT a call site: the replacement instance keeps
|
|
328
|
+
* BOTH the primary port and the remote-monitor port of the instance it
|
|
329
|
+
* replaces (launchSupervised() below threads the latter forward exactly like
|
|
330
|
+
* the port argument carries the former), so the block must stay in place for
|
|
331
|
+
* the whole chain of replacements rather than being released and immediately
|
|
332
|
+
* re-taken.
|
|
333
|
+
*
|
|
334
|
+
* Mutates `state.blockedPorts` directly rather than through a broker-state
|
|
335
|
+
* helper, for the SAME load-bearing reason acquirePortAndLaunch()'s own
|
|
336
|
+
* `blockedPorts.add` does (see this file's header comment): a VALUE import of
|
|
337
|
+
* "./broker-state.mjs" would turn a type-only dependency into a real runtime
|
|
338
|
+
* resolution this file cannot satisfy when loaded unbuilt, as its own unit
|
|
339
|
+
* test does. Callers outside this module import THIS function rather than
|
|
340
|
+
* re-deriving the pair of mutations. Idempotent, and safe for a record that
|
|
341
|
+
* never had a second port (every fork launch). */
|
|
342
|
+
export function deleteInstanceRecord(state, port) {
|
|
343
|
+
const record = state.instances.get(port);
|
|
344
|
+
if (record && typeof record.remoteMonitorPort === "number") {
|
|
345
|
+
state.blockedPorts.delete(record.remoteMonitorPort);
|
|
346
|
+
}
|
|
347
|
+
state.instances.delete(port);
|
|
348
|
+
}
|
|
207
349
|
const DEFAULT_PROBE_TIMEOUT_S = 1;
|
|
208
350
|
function defaultLog(line) {
|
|
209
351
|
process.stderr.write(`${line}\n`);
|
|
@@ -240,6 +382,122 @@ async function defaultHttpProbe(port, timeoutMs) {
|
|
|
240
382
|
clearTimeout(timer);
|
|
241
383
|
}
|
|
242
384
|
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// WR-01: the STOCK readiness route.
|
|
387
|
+
//
|
|
388
|
+
// probeReady() below used to POST http://127.0.0.1:<port>/mcp unconditionally
|
|
389
|
+
// and require both "version" and "machine" in the body. On the stock backend
|
|
390
|
+
// that port speaks the BINARY MONITOR, so the probe could never succeed:
|
|
391
|
+
// warm-floor instances stayed `launching` forever, countLaunching(state) > 0
|
|
392
|
+
// short-circuited every later warm pass, and a never-usable emulator process was
|
|
393
|
+
// retained until broker shutdown while still counting toward
|
|
394
|
+
// countTotal()/atCapacity(). Cold acquires kept working only because the cold
|
|
395
|
+
// arm grants without probing.
|
|
396
|
+
//
|
|
397
|
+
// WHY THE WIRE BYTES ARE HAND-BUILT HERE: stock-protocol.ts is the ONE place
|
|
398
|
+
// this tree frames and DEMULTIPLEXES the binmon protocol, and this probe is
|
|
399
|
+
// deliberately not a second copy of that -- it neither correlates request ids
|
|
400
|
+
// nor decodes bodies. But it cannot reuse even the constants: this file is a
|
|
401
|
+
// host-bound .mts compiled into resources/ by build.ts, and a .mts cannot
|
|
402
|
+
// value-import a .ts module (TS5097). The same constraint already produced
|
|
403
|
+
// hand-copied wire constants in binmon-fixtures.ts and a standalone client in
|
|
404
|
+
// probe-binmon.mjs. What is written here is the minimum a READINESS check needs:
|
|
405
|
+
// one request header out, one response header in, four bytes checked.
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
/** Hand-copied from docs/phase0-binmon-findings.md §5 -- see the block comment
|
|
408
|
+
* above for why these are not imported from stock-protocol.ts. */
|
|
409
|
+
const BINMON_STX = 0x02;
|
|
410
|
+
const BINMON_API_VERSION = 0x02;
|
|
411
|
+
const BINMON_REQUEST_HEADER_LEN = 11;
|
|
412
|
+
const BINMON_RESPONSE_HEADER_LEN = 12;
|
|
413
|
+
const BINMON_CMD_PING = 0x81;
|
|
414
|
+
const BINMON_CMD_EXIT = 0xaa;
|
|
415
|
+
const BINMON_PROBE_REQUEST_ID = 0x0000ca11;
|
|
416
|
+
function binmonRequest(commandType, requestId) {
|
|
417
|
+
const header = Buffer.alloc(BINMON_REQUEST_HEADER_LEN);
|
|
418
|
+
header[0] = BINMON_STX;
|
|
419
|
+
header[1] = BINMON_API_VERSION;
|
|
420
|
+
header.writeUInt32LE(0, 2); // no body
|
|
421
|
+
header.writeUInt32LE(requestId >>> 0, 6);
|
|
422
|
+
header[10] = commandType;
|
|
423
|
+
return header;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* WR-01: one PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
|
|
427
|
+
* reply -- STX, the expected api_version, response type 0x81, error code 0x00,
|
|
428
|
+
* and this probe's own request id. A bare TCP accept is explicitly insufficient
|
|
429
|
+
* here for exactly the reason probeReady()'s own comment gives for the HTTP
|
|
430
|
+
* route: a C64 can accept a connection before it has finished booting.
|
|
431
|
+
*
|
|
432
|
+
* Then EXIT (0xaa), unconditionally, before closing -- because the PING ITSELF
|
|
433
|
+
* HALTS THE MACHINE. Any inbound byte does (docs/phase0-binmon-findings.md §4,
|
|
434
|
+
* and CR-02, which fixed the same omission in the connect handshake). A
|
|
435
|
+
* readiness probe that left every warm instance frozen would be a worse defect
|
|
436
|
+
* than the one it fixes: the emulator would be "ready" and stopped.
|
|
437
|
+
*
|
|
438
|
+
* Never throws -- every failure (refused, timed out, wrong reply shape, socket
|
|
439
|
+
* error) is `false`, matching defaultHttpProbe()'s own posture, so a
|
|
440
|
+
* still-booting instance simply fails THIS pass and is re-probed on the next.
|
|
441
|
+
*
|
|
442
|
+
* The socket is ALWAYS destroyed before resolving: stock VICE services exactly
|
|
443
|
+
* one binmon client, so a probe that leaked its connection would occupy the
|
|
444
|
+
* single client slot the real session needs to claim.
|
|
445
|
+
*/
|
|
446
|
+
async function defaultBinmonProbe(port, timeoutMs) {
|
|
447
|
+
const { createConnection } = await import("node:net");
|
|
448
|
+
return new Promise((resolvePromise) => {
|
|
449
|
+
let settled = false;
|
|
450
|
+
let buffer = Buffer.alloc(0);
|
|
451
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
452
|
+
const finish = (result) => {
|
|
453
|
+
if (settled)
|
|
454
|
+
return;
|
|
455
|
+
settled = true;
|
|
456
|
+
clearTimeout(timer);
|
|
457
|
+
try {
|
|
458
|
+
socket.destroy();
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
/* already gone */
|
|
462
|
+
}
|
|
463
|
+
resolvePromise(result);
|
|
464
|
+
};
|
|
465
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
466
|
+
socket.on("error", () => finish(false));
|
|
467
|
+
socket.on("close", () => finish(false));
|
|
468
|
+
socket.on("connect", () => {
|
|
469
|
+
socket.write(binmonRequest(BINMON_CMD_PING, BINMON_PROBE_REQUEST_ID));
|
|
470
|
+
});
|
|
471
|
+
socket.on("data", (chunk) => {
|
|
472
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
473
|
+
if (buffer.length < BINMON_RESPONSE_HEADER_LEN)
|
|
474
|
+
return;
|
|
475
|
+
const wellFormed = buffer[0] === BINMON_STX &&
|
|
476
|
+
buffer[1] === BINMON_API_VERSION &&
|
|
477
|
+
buffer[6] === BINMON_CMD_PING &&
|
|
478
|
+
buffer[7] === 0x00 &&
|
|
479
|
+
buffer.readUInt32LE(8) === BINMON_PROBE_REQUEST_ID;
|
|
480
|
+
if (!wellFormed) {
|
|
481
|
+
finish(false);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Resume the machine this probe's own PING halted, then close GRACEFULLY:
|
|
485
|
+
// socket.end(data, cb) writes the EXIT and then sends FIN, so the bytes
|
|
486
|
+
// are delivered before the connection goes away. A bare write() followed
|
|
487
|
+
// by destroy() can discard them (destroy may RST), which would leave the
|
|
488
|
+
// instance "ready" and frozen -- the exact outcome the EXIT exists to
|
|
489
|
+
// prevent. The resume is best-effort in its OUTCOME, though: a failed
|
|
490
|
+
// resume must not turn a READY instance into a not-ready one, since the
|
|
491
|
+
// emulator demonstrably answered, which is what this function reports on.
|
|
492
|
+
try {
|
|
493
|
+
socket.end(binmonRequest(BINMON_CMD_EXIT, BINMON_PROBE_REQUEST_ID + 1), () => finish(true));
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
finish(true);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
}
|
|
243
501
|
/** D-05, AS AMENDED BY P-05 -- this comment is the amendment's record, kept
|
|
244
502
|
* in the exact place a three-branch description used to sit, per this
|
|
245
503
|
* plan's own instruction that a code reader must meet the amendment here,
|
|
@@ -277,6 +535,14 @@ async function defaultHttpProbe(port, timeoutMs) {
|
|
|
277
535
|
export async function probeReady(port, deps = {}) {
|
|
278
536
|
const timeoutS = Number(deps.probeTimeoutSEnv ?? process.env.VICE_BROKER_PROBE_TIMEOUT_S) || DEFAULT_PROBE_TIMEOUT_S;
|
|
279
537
|
const timeoutMs = timeoutS * 1000;
|
|
538
|
+
// WR-01: the route is chosen by the backend, exactly like buildViceArgs()'s
|
|
539
|
+
// own argv choice, and from the SAME threaded-down verdict. The fork arm below
|
|
540
|
+
// is byte-identical to what this function always did, including the
|
|
541
|
+
// omitted-backend default -- a fork deployment sees no behaviour change.
|
|
542
|
+
if (deps.backend === "stock") {
|
|
543
|
+
const binmonProbe = deps.binmonProbe ?? defaultBinmonProbe;
|
|
544
|
+
return binmonProbe(port, timeoutMs);
|
|
545
|
+
}
|
|
280
546
|
const httpProbe = deps.httpProbe ?? defaultHttpProbe;
|
|
281
547
|
return httpProbe(port, timeoutMs);
|
|
282
548
|
}
|
|
@@ -325,7 +591,11 @@ function resolveCeiling(override) {
|
|
|
325
591
|
export async function maintainWarmFloor(deps) {
|
|
326
592
|
const log = deps.log ?? defaultLog;
|
|
327
593
|
const now = deps.now ?? (() => Date.now());
|
|
328
|
-
|
|
594
|
+
// WR-01: the DEFAULT probe follows this call's own backend, so a caller that
|
|
595
|
+
// threads `backend` for the launch argv and omits `probe` gets a matching
|
|
596
|
+
// readiness route rather than an HTTP POST at a binary-monitor port. An
|
|
597
|
+
// explicitly injected `probe` still wins, unchanged.
|
|
598
|
+
const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "fork" }));
|
|
329
599
|
// Step 1: promote every "launching" instance whose probe now succeeds.
|
|
330
600
|
// Runs regardless of whether a launch is in flight -- promotion and
|
|
331
601
|
// speculative warming are independent concerns; an already-launched
|
|
@@ -380,11 +650,14 @@ export async function maintainWarmFloor(deps) {
|
|
|
380
650
|
state: deps.state,
|
|
381
651
|
stateDir: deps.stateDir,
|
|
382
652
|
allocatePort: deps.allocatePort,
|
|
653
|
+
allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
|
|
383
654
|
spawn: deps.spawn,
|
|
384
655
|
spawnFactory: deps.spawnFactory,
|
|
385
656
|
now: deps.now,
|
|
386
657
|
viceBin: deps.viceBin,
|
|
387
658
|
mcpHost: deps.mcpHost,
|
|
659
|
+
backend: deps.backend,
|
|
660
|
+
binmonHost: deps.binmonHost,
|
|
388
661
|
});
|
|
389
662
|
if (result.ok) {
|
|
390
663
|
log(`vice-broker: warmed 1 warm instance this pass -- ${ready + 1} of ${warmFloor} ready, remainder warmed on later passes`);
|
|
@@ -491,6 +764,22 @@ async function handleExit(reason, port, deps) {
|
|
|
491
764
|
return;
|
|
492
765
|
}
|
|
493
766
|
const log = deps.log ?? defaultLog;
|
|
767
|
+
// Plan 05 (BROK-02/PROTO-08): the process behind this instance's monitor
|
|
768
|
+
// socket has just exited, by every path this function can take (crash,
|
|
769
|
+
// recycle, or a deliberate teardown) -- clear the ownership record HERE,
|
|
770
|
+
// once, before any of those paths branch, so a client that died without
|
|
771
|
+
// releasing can never hold this lock forever. Redundant with the
|
|
772
|
+
// respawn/delete paths below (a fresh InstanceRecord never carries this
|
|
773
|
+
// field forward; a deleted one has no field to carry), but explicit for
|
|
774
|
+
// the same reason broker-state.mts's own header comment names this as one
|
|
775
|
+
// of the three required clearing sites. Assigned directly (not via
|
|
776
|
+
// broker-state.mjs's clearMonitorClient()) -- this module's own
|
|
777
|
+
// type-only import of that sibling (see this file's own header comment a
|
|
778
|
+
// few lines above) is load-bearing: a VALUE import would turn "./broker-
|
|
779
|
+
// state.mjs" into a real runtime resolution this file cannot satisfy when
|
|
780
|
+
// loaded directly (as broker-launch.test.ts does), rather than the
|
|
781
|
+
// compiled resources/ sibling this specifier is actually shaped for.
|
|
782
|
+
record.monitorClient = undefined;
|
|
494
783
|
if (record.deliberateKill) {
|
|
495
784
|
if (record.respawnAfterKill) {
|
|
496
785
|
// Recycle. Capture the pre-kill state, crash history and backoff
|
|
@@ -500,7 +789,13 @@ async function handleExit(reason, port, deps) {
|
|
|
500
789
|
const preKillState = record.state;
|
|
501
790
|
const preKillCrashTimes = record.crashTimes ?? [];
|
|
502
791
|
const preKillBackoffMs = record.backoffMs ?? resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
|
|
503
|
-
|
|
792
|
+
// CR-02 (03-REVIEW.md): the second (`-remotemonitor`) port is carried
|
|
793
|
+
// forward across the replacement exactly like the primary port is --
|
|
794
|
+
// captured BEFORE launchSupervised() overwrites this port's map entry
|
|
795
|
+
// with a brand new record, for the same reason the three values above
|
|
796
|
+
// are.
|
|
797
|
+
const preKillRemoteMonitorPort = record.remoteMonitorPort;
|
|
798
|
+
const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs, preKillRemoteMonitorPort);
|
|
504
799
|
if (respawned && preKillState === "granted") {
|
|
505
800
|
respawned.state = "granted";
|
|
506
801
|
}
|
|
@@ -520,7 +815,9 @@ async function handleExit(reason, port, deps) {
|
|
|
520
815
|
deps.onOutcome?.("recycled", port);
|
|
521
816
|
return;
|
|
522
817
|
}
|
|
523
|
-
|
|
818
|
+
// CR-02: a deliberate teardown is the END of this instance -- its
|
|
819
|
+
// remote-monitor port must go back to the allocator with it.
|
|
820
|
+
deleteInstanceRecord(deps.state, port);
|
|
524
821
|
deps.onOutcome?.("deliberate_teardown", port);
|
|
525
822
|
return;
|
|
526
823
|
}
|
|
@@ -532,7 +829,9 @@ async function handleExit(reason, port, deps) {
|
|
|
532
829
|
if (crashTimes.length >= maxRestarts) {
|
|
533
830
|
log(`vice-broker: giving up on port ${port} after ${crashTimes.length} crashes within ${crashWindowMs}ms -- ` +
|
|
534
831
|
`this is not a transient crash; check VICE_ARGS and whether the port is already bound`);
|
|
535
|
-
|
|
832
|
+
// CR-02: giving up is likewise terminal for this instance -- release its
|
|
833
|
+
// remote-monitor port rather than leaking it out of the allocation band.
|
|
834
|
+
deleteInstanceRecord(deps.state, port);
|
|
536
835
|
deps.onOutcome?.("given_up", port);
|
|
537
836
|
return;
|
|
538
837
|
}
|
|
@@ -541,7 +840,11 @@ async function handleExit(reason, port, deps) {
|
|
|
541
840
|
await sleepMs(currentBackoffMs);
|
|
542
841
|
const maxBackoffMs = resolveMs("VICE_RESTART_BACKOFF_MAX_S", 30, deps.maxBackoffMs);
|
|
543
842
|
const nextBackoffMs = Math.min(currentBackoffMs * 2, maxBackoffMs);
|
|
544
|
-
|
|
843
|
+
// CR-02: same carry-forward as the recycle branch above -- a crash must not
|
|
844
|
+
// silently strip `-remotemonitor` (and its InstanceRecord field) off the
|
|
845
|
+
// replacement, which is what made D-13's "the instance record carries it"
|
|
846
|
+
// stop being true the first time an instance was replaced.
|
|
847
|
+
const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort);
|
|
545
848
|
deps.onOutcome?.(respawned ? "respawned" : "given_up", port);
|
|
546
849
|
}
|
|
547
850
|
/** The single exit-listener installation point in the whole module tree.
|
|
@@ -585,8 +888,17 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
|
585
888
|
* crashTimes/backoffMs are threaded through explicitly (not reset to
|
|
586
889
|
* defaults) so a respawn's crash history and doubling backoff survive the
|
|
587
890
|
* fact that spawnAndRecordInstance() creates a BRAND NEW InstanceRecord
|
|
588
|
-
* object on every launch, replacing the old one at the same port key.
|
|
589
|
-
|
|
891
|
+
* object on every launch, replacing the old one at the same port key.
|
|
892
|
+
*
|
|
893
|
+
* CR-02 (03-REVIEW.md): `remoteMonitorPort` is threaded the SAME way and for
|
|
894
|
+
* the same reason -- it belongs to the instance, not to a single spawn of it.
|
|
895
|
+
* The replacement reuses the port the crashed/recycled process just vacated
|
|
896
|
+
* (already reserved in `state.blockedPorts`, so nothing else can have taken it
|
|
897
|
+
* meanwhile), exactly as it reuses the primary `port` argument; this function
|
|
898
|
+
* stays fully synchronous and never allocates. `undefined` is the correct
|
|
899
|
+
* value for a FIRST launch through superviseChild() and for every fork launch,
|
|
900
|
+
* which is why the parameter is optional. */
|
|
901
|
+
function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort) {
|
|
590
902
|
const supervisorDir = join(deps.stateDir, String(port));
|
|
591
903
|
const epochFile = deps.epoch.epochPathFor(deps.stateDir, port);
|
|
592
904
|
const logDir = deps.epoch.instanceLogDirFor(deps.stateDir, port);
|
|
@@ -616,6 +928,9 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs) {
|
|
|
616
928
|
now: deps.now,
|
|
617
929
|
viceBin: deps.viceBin,
|
|
618
930
|
mcpHost: deps.mcpHost,
|
|
931
|
+
backend: deps.backend,
|
|
932
|
+
binmonHost: deps.binmonHost,
|
|
933
|
+
remoteMonitorPort,
|
|
619
934
|
log: deps.log,
|
|
620
935
|
});
|
|
621
936
|
if (!record)
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
|
|
6
6
|
// rebuild.
|
|
7
7
|
import { createServer } from "node:net";
|
|
8
|
+
/** Clears `monitorClient` as a side effect of release, recycle, or the
|
|
9
|
+
* instance's own process exit (see InstanceRecord.monitorClient's own header
|
|
10
|
+
* comment for the three call sites) -- so a dead or torn-down client can
|
|
11
|
+
* never hold this lock forever. A no-op when no monitor client is currently
|
|
12
|
+
* recorded (idempotent, matching monitor_release's own tolerance for an
|
|
13
|
+
* already-cleared record). */
|
|
14
|
+
export function clearMonitorClient(record) {
|
|
15
|
+
record.monitorClient = undefined;
|
|
16
|
+
}
|
|
8
17
|
export function createBrokerState() {
|
|
9
18
|
return { instances: new Map(), grants: new Map(), blockedPorts: new Set() };
|
|
10
19
|
}
|
|
@@ -109,6 +118,8 @@ export async function nextFreePort(state, opts = {}) {
|
|
|
109
118
|
for (let port = basePort; port < limit; port++) {
|
|
110
119
|
if (state.instances.has(port))
|
|
111
120
|
continue;
|
|
121
|
+
if (opts.exclude?.has(port))
|
|
122
|
+
continue;
|
|
112
123
|
if (isPortBlocked(state, port))
|
|
113
124
|
continue;
|
|
114
125
|
if (await portInUse(port)) {
|