@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.
@@ -59,21 +59,84 @@ let inFlightReason = null;
59
59
  export function isLaunchInFlight() {
60
60
  return inFlight;
61
61
  }
62
- /** Resolves the emulator's own argument vector. Two shapes, matching
63
- * resources/vice-supervisor.sh's own VICE_ARGS convention exactly: if
64
- * VICE_ARGS is set in the environment (a single space-separated string,
65
- * fully overridable -- vice-supervisor.sh's own header comment), it is used
66
- * AS-IS; otherwise the MCP server flags are constructed the way the bash
67
- * launcher builds them -- the MCP server flag, the MCP server host from
68
- * VICE_BROKER_MCP_HOST (default 0.0.0.0), and the MCP server port set to
69
- * the allocated port. The override exists because this broker's own tests
70
- * (and an operator's manual dry runs) need to launch a stand-in binary
71
- * (e.g. /bin/sleep) that does not understand -mcpserver flags. */
72
- export function buildViceArgs(port, { mcpHost, viceArgsEnv } = {}) {
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 viceArgs = buildViceArgs(port, { mcpHost: deps.mcpHost });
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
- const probe = deps.probe ?? ((port) => probeReady(port));
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
- const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs);
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
- deps.state.instances.delete(port);
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
- deps.state.instances.delete(port);
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
- const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs);
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
- function launchSupervised(reason, port, deps, crashTimes, backoffMs) {
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)) {