@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
|
@@ -29,8 +29,17 @@ import { join, basename, resolve as resolvePath } from "node:path";
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
31
31
|
import { containerGuardReport, containerGuardEnforce } from "./container-guard.mjs";
|
|
32
|
-
import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, } from "./broker-state.mjs";
|
|
33
|
-
import { acquirePortAndLaunch, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
|
|
32
|
+
import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
|
|
33
|
+
import { acquirePortAndLaunch, deleteInstanceRecord, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
|
|
34
|
+
// Plan 02-07: resolvedBackend() is now the ONE reader of VICE_BACKEND in
|
|
35
|
+
// this tree -- ViceBackend's own definition moved to backend-detect.mts too,
|
|
36
|
+
// so broker-launch.mjs's own (type-only) re-import of it and this file's
|
|
37
|
+
// VALUE import both name the same one home. A real value import is safe
|
|
38
|
+
// here (unlike inside broker-launch.mts) because vice-broker.mts is ALWAYS
|
|
39
|
+
// run from its own compiled resources/ form -- both modules are compiled
|
|
40
|
+
// together in the same build.ts pass, so "./backend-detect.mjs" always
|
|
41
|
+
// exists as a real sibling file by the time this import resolves.
|
|
42
|
+
import { resolvedBackend } from "./backend-detect.mjs";
|
|
34
43
|
import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
|
|
35
44
|
import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
|
|
36
45
|
import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
|
|
@@ -226,15 +235,45 @@ function writeEpochForLaunch(record, logRelPath) {
|
|
|
226
235
|
* epoch record naming the wrong one. Leaving it unset means a respawn's
|
|
227
236
|
* output lands in the supervision module's own log file under the same
|
|
228
237
|
* per-instance logs directory D-23 requires, and the epoch record names
|
|
229
|
-
* the file that actually received the output.
|
|
230
|
-
|
|
238
|
+
* the file that actually received the output.
|
|
239
|
+
*
|
|
240
|
+
* CR-01 (03-REVIEW.md): `backend` is a REQUIRED positional parameter, not an
|
|
241
|
+
* optional field a call site may quietly omit. Before this, both real call
|
|
242
|
+
* sites built their deps here WITHOUT it, so `spawnAndRecordInstance()`'s own
|
|
243
|
+
* `deps.backend ?? "fork"` default silently took over the moment crash
|
|
244
|
+
* supervision replaced an instance -- a stock instance's crash-respawn or
|
|
245
|
+
* `vice_recycle` relaunched it with the FORK's `-mcpserver` argv, which stock
|
|
246
|
+
* upstream VICE does not understand at all, leaving a pool member that can
|
|
247
|
+
* never be reached over the binary monitor again while still counting toward
|
|
248
|
+
* countReady()/countTotal(). Making it positional and required is what makes
|
|
249
|
+
* that omission a compile error rather than a silent backend swap: the FIRST
|
|
250
|
+
* launch and every REPLACEMENT of it now build their argv from the SAME
|
|
251
|
+
* resolved verdict. `binmonHost` is threaded for the same reason, one step
|
|
252
|
+
* ahead of need -- no broker call site configures a stock bind override today
|
|
253
|
+
* (acquirePortAndLaunch()'s own `binmonHost` is likewise unset), so it is
|
|
254
|
+
* always `undefined` in production right now; the parameter exists so that
|
|
255
|
+
* adding one later cannot reintroduce exactly this divergence between a
|
|
256
|
+
* launch's argv and its respawn's argv. */
|
|
257
|
+
function superviseDepsFor(stateDir, state, backend, binmonHost) {
|
|
231
258
|
return {
|
|
232
259
|
state,
|
|
233
260
|
stateDir,
|
|
234
261
|
epoch: { epochPathFor, instanceLogDirFor, nextEpochFor, writeEpochRecord },
|
|
235
262
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
263
|
+
backend,
|
|
264
|
+
binmonHost,
|
|
236
265
|
};
|
|
237
266
|
}
|
|
267
|
+
/** Exported ONLY so a test can install withCrashSupervision() through the
|
|
268
|
+
* REAL deps object this module actually uses in production, rather than a
|
|
269
|
+
* hand-built SuperviseChildDeps that can (and did) diverge from it -- the
|
|
270
|
+
* exact blind spot CR-01 (03-REVIEW.md) lived in: broker-launch.test.ts's own
|
|
271
|
+
* respawn/recycle tests each construct their deps inline and therefore pass
|
|
272
|
+
* `backend: "stock"` directly, so the production builder's missing field was
|
|
273
|
+
* invisible to the whole suite. Same discipline as broker-kill.mts's
|
|
274
|
+
* `_HANDLED_SIGNALS`: an underscore-prefixed alias, never called by any
|
|
275
|
+
* production code path in this module. */
|
|
276
|
+
export const _superviseDepsFor = superviseDepsFor;
|
|
238
277
|
/** Sets the deliberate-death marker and its respawn-after-kill answer
|
|
239
278
|
* TOGETHER -- the single place in this module that ever writes either
|
|
240
279
|
* field, so a call site can never set one and forget the other, which is
|
|
@@ -326,7 +365,11 @@ async function selectWarmInstance(state, deps) {
|
|
|
326
365
|
// WR-02 only changes what happens to the kill's own PROMISE next, never
|
|
327
366
|
// this ordering.
|
|
328
367
|
markDeliberateDeath(record, false);
|
|
329
|
-
|
|
368
|
+
// CR-02 (03-REVIEW.md): dropping a record is also where its second
|
|
369
|
+
// (`-remotemonitor`) port stops being spoken for -- deleteInstanceRecord()
|
|
370
|
+
// is the ONE place both mutations happen together, so a drop can never
|
|
371
|
+
// leak a port out of the fixed allocation band.
|
|
372
|
+
deleteInstanceRecord(state, record.port);
|
|
330
373
|
// Distinct wording from shutdown()'s own "shutdown complete" line
|
|
331
374
|
// (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
|
|
332
375
|
// path -- D-07's standing constraint that a lifecycle decision must be
|
|
@@ -383,7 +426,11 @@ async function selectWarmInstance(state, deps) {
|
|
|
383
426
|
* control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
|
|
384
427
|
* retry it later rather than refusing it. */
|
|
385
428
|
export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
386
|
-
|
|
429
|
+
// WR-01: the readiness probe is backend-aware, from the SAME threaded-down
|
|
430
|
+
// verdict handleAcquire already uses for buildViceArgs() -- on stock the port
|
|
431
|
+
// speaks the binary monitor, so an HTTP POST there can never succeed.
|
|
432
|
+
const backend = deps.backend ?? "fork";
|
|
433
|
+
const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
|
|
387
434
|
// Textually a verifiedKill( call site, not merely a reference -- reused
|
|
388
435
|
// UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
|
|
389
436
|
// re-derived, and never replaced by a bare process.kill().
|
|
@@ -412,12 +459,18 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
412
459
|
state,
|
|
413
460
|
stateDir,
|
|
414
461
|
allocatePort: nextFreePort,
|
|
462
|
+
// CR-01 (03-REVIEW.md): the SAME local `backend` const resolved at the
|
|
463
|
+
// top of this function feeds BOTH the initial argv (here) and the
|
|
464
|
+
// supervision deps below, so a crash-respawn of this instance can never
|
|
465
|
+
// build a different backend's argv than the launch it replaces.
|
|
466
|
+
backend,
|
|
467
|
+
allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
|
|
415
468
|
spawnFactory: deps.buildColdSpawnFactory ??
|
|
416
469
|
((port) => {
|
|
417
470
|
const supervisorDir = join(stateDir, String(port));
|
|
418
471
|
const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
|
|
419
472
|
lastLogRelPath = logRelPath;
|
|
420
|
-
return withCrashSupervision("acquire", port, spawn, superviseDepsFor(stateDir, state));
|
|
473
|
+
return withCrashSupervision("acquire", port, spawn, superviseDepsFor(stateDir, state, backend));
|
|
421
474
|
}),
|
|
422
475
|
});
|
|
423
476
|
if (!result.ok) {
|
|
@@ -431,7 +484,10 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
|
431
484
|
// toward countTotal()/atCapacity() until crash supervision's own
|
|
432
485
|
// delayed respawn/give-up machinery eventually noticed and freed it,
|
|
433
486
|
// even though the caller was already told "internal" right now.
|
|
434
|
-
|
|
487
|
+
// CR-02: deleteInstanceRecord(), not a bare map delete -- a stock launch
|
|
488
|
+
// that failed this way already had its second port allocated and
|
|
489
|
+
// blocked by acquirePortAndLaunch().
|
|
490
|
+
deleteInstanceRecord(state, result.record.port);
|
|
435
491
|
return { ok: false, reason: "internal" };
|
|
436
492
|
}
|
|
437
493
|
record = result.record;
|
|
@@ -465,8 +521,66 @@ function handleStatus(state) {
|
|
|
465
521
|
state: r.state,
|
|
466
522
|
reason: r.reason,
|
|
467
523
|
epoch: typeof r.epoch === "number" ? r.epoch : null,
|
|
524
|
+
hasMonitorClient: r.monitorClient !== undefined,
|
|
468
525
|
}));
|
|
469
526
|
}
|
|
527
|
+
/** Resolves a monitor_claim/monitor_release target the SAME way
|
|
528
|
+
* handleRelease() and handleRecycleForRealBroker() already resolve theirs:
|
|
529
|
+
* `targetId` is a grant id, looked up in state.grants for its port, then
|
|
530
|
+
* the instance at that port. Returns `null` for an unknown target_id/port
|
|
531
|
+
* so callers answer `bad_request`, never `internal` (plan 05's own
|
|
532
|
+
* acceptance criterion). */
|
|
533
|
+
function resolveInstanceForMonitorTarget(targetId, state) {
|
|
534
|
+
const grant = state.grants.get(targetId);
|
|
535
|
+
if (!grant)
|
|
536
|
+
return null;
|
|
537
|
+
return state.instances.get(grant.port) ?? null;
|
|
538
|
+
}
|
|
539
|
+
/** Answers `monitor_claim` (plan 05, BROK-02/PROTO-08, D-13): exclusive
|
|
540
|
+
* monitor-socket ownership enforced HERE, broker-side, so a conflicting
|
|
541
|
+
* claim is refused by name before any second `connect()` is ever attempted
|
|
542
|
+
* -- the one state stock VICE cannot report and no client-side heuristic
|
|
543
|
+
* can diagnose. `targetId` doubles as both "which instance" (resolved via
|
|
544
|
+
* the SAME grant lookup handleRelease()/handleRecycleForRealBroker() already
|
|
545
|
+
* use) and "the requesting grant's own identity" -- the claim IS the grant,
|
|
546
|
+
* so there is no separate identity to carry. A repeated claim from the SAME
|
|
547
|
+
* grant is idempotent (`ok: true`, no second holder created); a claim from
|
|
548
|
+
* a DIFFERENT grant while the instance already has a holder is refused,
|
|
549
|
+
* naming the current holder (T-02-18) -- never the emulator's own fault. */
|
|
550
|
+
export function handleMonitorClaim(requestId, targetId, state) {
|
|
551
|
+
void requestId; // correlation only -- the claim's own identity is targetId itself
|
|
552
|
+
const instance = resolveInstanceForMonitorTarget(targetId, state);
|
|
553
|
+
if (!instance)
|
|
554
|
+
return { ok: false, code: "bad_request" };
|
|
555
|
+
const existing = instance.monitorClient;
|
|
556
|
+
if (!existing) {
|
|
557
|
+
instance.monitorClient = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
|
|
558
|
+
return { ok: true };
|
|
559
|
+
}
|
|
560
|
+
if (existing.grantId === targetId) {
|
|
561
|
+
return { ok: true }; // idempotent repeat from the SAME grant -- no second holder
|
|
562
|
+
}
|
|
563
|
+
return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid } };
|
|
564
|
+
}
|
|
565
|
+
/** Answers `monitor_release` (plan 05, T-02-01): clears `monitorClient` ONLY
|
|
566
|
+
* when `targetId` names the CURRENT holder -- a non-holder is refused, not
|
|
567
|
+
* silently accepted (spoofing a release is exactly T-02-01's own
|
|
568
|
+
* disposition). An instance with no current holder at all tolerates the
|
|
569
|
+
* release as a success, matching the container-side client's own documented
|
|
570
|
+
* tolerance for releasing a socket the broker already cleared. */
|
|
571
|
+
export function handleMonitorRelease(requestId, targetId, state) {
|
|
572
|
+
void requestId; // correlation only, matching handleMonitorClaim()'s own posture
|
|
573
|
+
const instance = resolveInstanceForMonitorTarget(targetId, state);
|
|
574
|
+
if (!instance)
|
|
575
|
+
return { ok: false, code: "bad_request" };
|
|
576
|
+
if (!instance.monitorClient)
|
|
577
|
+
return { ok: true }; // already cleared -- tolerated, not an error
|
|
578
|
+
if (instance.monitorClient.grantId !== targetId) {
|
|
579
|
+
return { ok: false, code: "denied" };
|
|
580
|
+
}
|
|
581
|
+
clearMonitorClient(instance);
|
|
582
|
+
return { ok: true };
|
|
583
|
+
}
|
|
470
584
|
/** Resolves a recycle target's emulator child pid from THIS broker's own
|
|
471
585
|
* in-memory instance record -- record.pid is, by construction, exactly the
|
|
472
586
|
* same value broker-epoch.mts's writer puts in epoch.json's own `pid` field
|
|
@@ -530,6 +644,13 @@ async function handleRecycleForRealBroker(targetId, state) {
|
|
|
530
644
|
}
|
|
531
645
|
const epochBefore = typeof instance.epoch === "number" ? instance.epoch : null;
|
|
532
646
|
markDeliberateDeath(instance, true);
|
|
647
|
+
// Plan 05: a recycle clears monitor-client ownership as a side effect --
|
|
648
|
+
// the respawned record the exit handler creates is a BRAND NEW
|
|
649
|
+
// InstanceRecord object (broker-launch.mts's spawnAndRecordInstance())
|
|
650
|
+
// that never carries this field forward regardless, but clearing it here
|
|
651
|
+
// too keeps the CURRENT (pre-kill) record's own state honest for the
|
|
652
|
+
// window between this call and that respawn.
|
|
653
|
+
clearMonitorClient(instance);
|
|
533
654
|
const killStage = await verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity });
|
|
534
655
|
const outcome = killStage === "identity_refused" ? "identity_refused" : "ok";
|
|
535
656
|
const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
|
|
@@ -554,11 +675,12 @@ async function handleRecycleForRealBroker(targetId, state) {
|
|
|
554
675
|
* because of invariants -- at most one launch per call, never invoked
|
|
555
676
|
* concurrently with itself -- enforced elsewhere and never checked at the
|
|
556
677
|
* point the variable used to be declared). */
|
|
557
|
-
function maintainWarmFloorForRealBroker(stateDir, state) {
|
|
678
|
+
function maintainWarmFloorForRealBroker(stateDir, state, backend) {
|
|
558
679
|
let lastWarmLaunchLogRelPath = "";
|
|
559
680
|
return maintainWarmFloor({
|
|
560
681
|
state,
|
|
561
682
|
stateDir,
|
|
683
|
+
backend,
|
|
562
684
|
spawnFactory: (port) => {
|
|
563
685
|
const supervisorDir = join(stateDir, String(port));
|
|
564
686
|
const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
|
|
@@ -574,10 +696,21 @@ function maintainWarmFloorForRealBroker(stateDir, state) {
|
|
|
574
696
|
lastWarmLaunchLogRelPath = logRelPath;
|
|
575
697
|
return child;
|
|
576
698
|
};
|
|
577
|
-
|
|
699
|
+
// CR-01 (03-REVIEW.md): the SAME resolved `backend` this function
|
|
700
|
+
// already receives for the launch argv is threaded into the supervision
|
|
701
|
+
// deps, so a warm instance's own crash-respawn stays on its backend.
|
|
702
|
+
return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state, backend));
|
|
578
703
|
},
|
|
579
|
-
|
|
704
|
+
// WR-01: same backend-aware probe route as handleAcquire's, from the SAME
|
|
705
|
+
// resolved verdict this function already receives for the launch argv.
|
|
706
|
+
probe: (port) => probeReady(port, { backend }),
|
|
580
707
|
allocatePort: nextFreePort,
|
|
708
|
+
// Plan 03-04 (DIRECT-06, D-13): same wiring as handleAcquire()'s own
|
|
709
|
+
// cold-launch arm -- acquirePortAndLaunch() (reached via
|
|
710
|
+
// maintainWarmFloor() below) gates the second allocation on
|
|
711
|
+
// `backend === "stock"` itself, so this function need not check the
|
|
712
|
+
// backend before passing it.
|
|
713
|
+
allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
|
|
581
714
|
countReady,
|
|
582
715
|
countTotal,
|
|
583
716
|
countLaunching,
|
|
@@ -631,8 +764,17 @@ export function handleRelease(requestId, state) {
|
|
|
631
764
|
const instance = state.instances.get(grant.port);
|
|
632
765
|
if (instance && instance.pid === grant.pid) {
|
|
633
766
|
markDeliberateDeath(instance, false);
|
|
767
|
+
// Plan 05: releasing clears monitor-client ownership as a side effect
|
|
768
|
+
// -- redundant with the instance-map deletion two lines below (the
|
|
769
|
+
// WHOLE record, monitorClient included, is going away), but explicit
|
|
770
|
+
// for the same reason GrantRecord's own clearing is explicit here: the
|
|
771
|
+
// instance-map deletion is a Task-2-era invariant this task must not
|
|
772
|
+
// depend on silently continuing to hold.
|
|
773
|
+
clearMonitorClient(instance);
|
|
634
774
|
state.grants.delete(requestId);
|
|
635
|
-
|
|
775
|
+
// CR-02: kill-never-recycle means this instance is gone for good, so its
|
|
776
|
+
// second (`-remotemonitor`) port must go back to the allocator with it.
|
|
777
|
+
deleteInstanceRecord(state, grant.port);
|
|
636
778
|
verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
|
|
637
779
|
// best-effort; nothing further to report on this path this task
|
|
638
780
|
});
|
|
@@ -692,6 +834,23 @@ async function run(args) {
|
|
|
692
834
|
nextEpochFor,
|
|
693
835
|
writeEpochRecord,
|
|
694
836
|
});
|
|
837
|
+
// Plan 02-07 (D-01, D-03): resolved ONCE here, after the unconditional
|
|
838
|
+
// startup reap and BEFORE the control listener binds -- never re-read per
|
|
839
|
+
// launch, and never called from inside broker-launch.mts's `inFlight`
|
|
840
|
+
// single-owner guard (this call sits entirely outside it; no launch is
|
|
841
|
+
// even possible yet at this point in run()). `supervisorDir: args.stateDir`
|
|
842
|
+
// is passed explicitly -- args.stateDir IS `.vice-supervisor` under this
|
|
843
|
+
// broker's own repo root (see parseArgs() above), so this is the SAME
|
|
844
|
+
// directory repo-root.ts's supervisorDir() would resolve to, without this
|
|
845
|
+
// host-bound module ever importing that container-side resolver directly
|
|
846
|
+
// (backend-detect.mts's own header comment explains why it cannot). An
|
|
847
|
+
// `indeterminate` outcome does not prevent the broker from starting: it
|
|
848
|
+
// logs its own note (backend-detect.mts) and this line proceeds with the
|
|
849
|
+
// "fork" answer resolvedBackend() already returns for that case -- the
|
|
850
|
+
// pre-Phase-2 behaviour every existing install already has.
|
|
851
|
+
const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
|
|
852
|
+
const backend = backendResult.backend;
|
|
853
|
+
process.stderr.write(`vice-broker: backend "${backend}" (source: ${backendResult.source}, binary: ${backendResult.binPath})\n`);
|
|
695
854
|
// D-18: the singleton guarantee holds only while the control port keeps its default -- two brokers deliberately configured onto different ports are two brokers, and no code prevents that.
|
|
696
855
|
let listener;
|
|
697
856
|
try {
|
|
@@ -699,10 +858,18 @@ async function run(args) {
|
|
|
699
858
|
host: controlHost,
|
|
700
859
|
port: controlPort,
|
|
701
860
|
token,
|
|
702
|
-
onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state
|
|
861
|
+
onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state, {
|
|
862
|
+
backend,
|
|
863
|
+
// Plan 03-04 (DIRECT-06, D-13): threaded down to
|
|
864
|
+
// acquirePortAndLaunch()'s own gate (backend === "stock"); this
|
|
865
|
+
// callback does NOT re-read VICE_BACKEND itself.
|
|
866
|
+
allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
|
|
867
|
+
}),
|
|
703
868
|
onRelease: (requestId) => handleRelease(requestId, state),
|
|
704
869
|
onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
|
|
705
870
|
onStatus: () => handleStatus(state),
|
|
871
|
+
onMonitorClaim: (requestId, targetId) => handleMonitorClaim(requestId, targetId, state),
|
|
872
|
+
onMonitorRelease: (requestId, targetId) => handleMonitorRelease(requestId, targetId, state),
|
|
706
873
|
onHostState: () => ({
|
|
707
874
|
pid: process.pid,
|
|
708
875
|
startedAt,
|
|
@@ -711,6 +878,10 @@ async function run(args) {
|
|
|
711
878
|
warmFloor: resolveWarmFloorForRecord(),
|
|
712
879
|
maxInstances: resolveCeilingForRecord(),
|
|
713
880
|
basePort: resolveBasePort(),
|
|
881
|
+
// WR-04: the verdict THIS process resolved once, at startup, above --
|
|
882
|
+
// the same one every launch argv is built from. Never a second
|
|
883
|
+
// resolvedBackend() call (backend-detect.mts's own prohibition).
|
|
884
|
+
backend,
|
|
714
885
|
}),
|
|
715
886
|
});
|
|
716
887
|
}
|
|
@@ -811,7 +982,7 @@ async function run(args) {
|
|
|
811
982
|
passInFlight = true;
|
|
812
983
|
runBrokerPass({
|
|
813
984
|
serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
|
|
814
|
-
maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state),
|
|
985
|
+
maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state, backend),
|
|
815
986
|
})
|
|
816
987
|
.catch((e) => {
|
|
817
988
|
process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
|
package/stock-address.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-address.ts
|
|
3
|
+
//
|
|
4
|
+
// THE ONE address parser for the stock backend (D-04). Every stock handler
|
|
5
|
+
// that accepts an address or a byte count parses it through parseAddress()/
|
|
6
|
+
// parseByteCount() here -- never a second, re-derived regex or range check
|
|
7
|
+
// in a family module.
|
|
8
|
+
//
|
|
9
|
+
// WHY THIS FILE EXISTS: re-deriving an address parser per tool family is
|
|
10
|
+
// this codebase's own named anti-pattern ("re-deriving a cross-cutting seam
|
|
11
|
+
// locally") -- the memory and checkpoint families (Phase 3) both need
|
|
12
|
+
// identical decimal/$hex/0x parsing and identical 0..0xffff range
|
|
13
|
+
// enforcement, and D-04 requires a single pluggable symbol-resolution hook
|
|
14
|
+
// so Phase 5's symbol store (DERIV-04) can fill it later without every call
|
|
15
|
+
// site changing.
|
|
16
|
+
//
|
|
17
|
+
// WHAT NOT TO DO:
|
|
18
|
+
// - Never re-derive an address regex in a family module -- import
|
|
19
|
+
// parseAddress()/parseByteCount() from here instead.
|
|
20
|
+
// - Never treat a bare decimal string as hex here. This is the MCP
|
|
21
|
+
// argument surface an agent (or a caller) types a value into, not
|
|
22
|
+
// VICE's own condition lexer -- CLAUDE.md's "bare integer literals are
|
|
23
|
+
// hex by default" rule belongs to the checkpoint-condition emitter, not
|
|
24
|
+
// this parser. Do not conflate the two.
|
|
25
|
+
// - Never implement symbol resolution in Phase 3. setSymbolResolver() is
|
|
26
|
+
// a deliberately empty extension point until Phase 5's DERIV-04 symbol
|
|
27
|
+
// store installs a real one; the default here stays `null`.
|
|
28
|
+
// - Never add a second resolver holder. `nameFor` (address -> name,
|
|
29
|
+
// DISASM-06's first consumer, Phase 4) and `resolve` (name -> address,
|
|
30
|
+
// Phase 3) live on the SAME `SymbolResolver` object, read from the SAME
|
|
31
|
+
// module-level `symbolResolver` holder above. Phase 5's DERIV-04 store
|
|
32
|
+
// installs one object implementing both; a second holder or a
|
|
33
|
+
// re-derived address->name map in a family module would force it to
|
|
34
|
+
// install itself twice.
|
|
35
|
+
import { ViceError, type ViceErrorOptions } from "./vice.ts";
|
|
36
|
+
|
|
37
|
+
export interface SymbolResolver {
|
|
38
|
+
resolve(name: string): number | undefined;
|
|
39
|
+
/** The inverse direction (DISASM-06, Phase 4's first consumer of it) --
|
|
40
|
+
* optional so the Phase 3 default (`null`, no resolver installed at all)
|
|
41
|
+
* and every existing test fake stay valid without implementing it. */
|
|
42
|
+
nameFor?(address: number): string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The ONE module-level holder for the installed resolver. `null` in Phase 3
|
|
46
|
+
// -- no symbol resolution happens until a later phase installs one.
|
|
47
|
+
let symbolResolver: SymbolResolver | null = null;
|
|
48
|
+
|
|
49
|
+
/** The deliberately-empty extension point Phase 5's DERIV-04 symbol store
|
|
50
|
+
* fills. Passing `null` (the Phase 3 default) restores the "no symbol table
|
|
51
|
+
* loaded" refusal. */
|
|
52
|
+
export function setSymbolResolver(resolver: SymbolResolver | null): void {
|
|
53
|
+
symbolResolver = resolver;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** DISASM-06's reverse lookup: address -> name, read from the SAME holder
|
|
57
|
+
* `parseAddress()` reads. Returns `undefined` -- never throws -- when no
|
|
58
|
+
* resolver is installed, or when the installed resolver has no `nameFor`
|
|
59
|
+
* (e.g. a Phase 3-era fake that only implements `resolve`). This is a
|
|
60
|
+
* client-side convenience read, not an MCP argument parse, so there is no
|
|
61
|
+
* "no symbol table is loaded" refusal here -- that wording belongs to
|
|
62
|
+
* `parseAddress()`'s own symbolic-name path; a caller wanting an explanatory
|
|
63
|
+
* note for its own answer uses `hasSymbolStore()` to decide that itself. */
|
|
64
|
+
export function symbolNameFor(address: number): string | undefined {
|
|
65
|
+
if (!symbolResolver || typeof symbolResolver.nameFor !== "function") {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return symbolResolver.nameFor(address);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True iff a resolver is installed AND it implements the reverse (`nameFor`)
|
|
72
|
+
* direction -- what D-14 reads to decide whether `show_symbols` is a
|
|
73
|
+
* no-op that SAYS SO on the answer, rather than the handler guessing from an
|
|
74
|
+
* empty/undefined result (which could equally mean "store installed, but no
|
|
75
|
+
* name for this particular address"). */
|
|
76
|
+
export function hasSymbolStore(): boolean {
|
|
77
|
+
return symbolResolver !== null && typeof symbolResolver.nameFor === "function";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The one address/byte-count error type this module ever throws -- never a
|
|
81
|
+
* bare Error, matching vice.ts's established ViceError hierarchy. */
|
|
82
|
+
export class StockAddressError extends ViceError {
|
|
83
|
+
constructor(message: string, options: ViceErrorOptions = {}) {
|
|
84
|
+
super(message, options);
|
|
85
|
+
this.name = "StockAddressError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A bare word that COULD be a symbol name -- checked only after every
|
|
90
|
+
* numeric form below has already failed to match, so a malformed numeric
|
|
91
|
+
* string (e.g. "0xzz") is refused as malformed, never misread as a
|
|
92
|
+
* candidate symbol. */
|
|
93
|
+
const SYMBOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
94
|
+
|
|
95
|
+
function inAddressRange(value: number): boolean {
|
|
96
|
+
return Number.isInteger(value) && value >= 0 && value <= 0xffff;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parses `input` into a 0..0xffff address (D-04). Accepted forms: a JS
|
|
101
|
+
* `number` in range; a `"$hex"` string (leading `$`, hex digits,
|
|
102
|
+
* case-insensitive); a `"0x"`/`"0X"` string; or a bare decimal string
|
|
103
|
+
* (`"4096"`) -- decimal here, deliberately, since this is the MCP argument
|
|
104
|
+
* surface, not VICE's own condition lexer where bare literals are hex.
|
|
105
|
+
* Surrounding whitespace is trimmed. A symbolic name (matching
|
|
106
|
+
* `/^[A-Za-z_][A-Za-z0-9_.]*$/`) refuses with "no symbol table is loaded"
|
|
107
|
+
* when no resolver is installed, or "not a known symbol" when a resolver IS
|
|
108
|
+
* installed but returns `undefined` -- never a parse/syntax error either
|
|
109
|
+
* way (D-04's explicit requirement).
|
|
110
|
+
*/
|
|
111
|
+
export function parseAddress(input: unknown, opts: { what?: string } = {}): number {
|
|
112
|
+
const what = opts.what ?? "address";
|
|
113
|
+
|
|
114
|
+
if (typeof input === "number") {
|
|
115
|
+
if (!inAddressRange(input)) {
|
|
116
|
+
throw new StockAddressError(`${what}: ${input} is out of range -- expected an integer 0..65535 ($0000-$ffff)`);
|
|
117
|
+
}
|
|
118
|
+
return input;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof input !== "string") {
|
|
122
|
+
throw new StockAddressError(`${what}: expected a number, a decimal string, a "$hex" string, or a "0x" string, got ${typeof input}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const trimmed = input.trim();
|
|
126
|
+
|
|
127
|
+
if (trimmed === "") {
|
|
128
|
+
throw new StockAddressError(`${what}: empty string is not a valid address`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (trimmed.startsWith("$")) {
|
|
132
|
+
const hexPart = trimmed.slice(1);
|
|
133
|
+
if (hexPart === "" || !/^[0-9a-fA-F]+$/.test(hexPart)) {
|
|
134
|
+
throw new StockAddressError(`${what}: "${trimmed}" is not a valid "$hex" address -- expected "$" followed by hex digits, e.g. "$D019"`);
|
|
135
|
+
}
|
|
136
|
+
const value = parseInt(hexPart, 16);
|
|
137
|
+
if (!inAddressRange(value)) {
|
|
138
|
+
throw new StockAddressError(`${what}: "${trimmed}" (0x${value.toString(16)}) is out of range -- expected 0..65535 ($0000-$ffff)`);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (/^0[xX]/.test(trimmed)) {
|
|
144
|
+
const hexPart = trimmed.slice(2);
|
|
145
|
+
if (hexPart === "" || !/^[0-9a-fA-F]+$/.test(hexPart)) {
|
|
146
|
+
throw new StockAddressError(`${what}: "${trimmed}" is not a valid "0x" address -- expected "0x" followed by hex digits, e.g. "0xD019"`);
|
|
147
|
+
}
|
|
148
|
+
const value = parseInt(hexPart, 16);
|
|
149
|
+
if (!inAddressRange(value)) {
|
|
150
|
+
throw new StockAddressError(`${what}: "${trimmed}" (0x${value.toString(16)}) is out of range -- expected 0..65535 ($0000-$ffff)`);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
156
|
+
const value = parseInt(trimmed, 10);
|
|
157
|
+
if (!inAddressRange(value)) {
|
|
158
|
+
throw new StockAddressError(`${what}: "${trimmed}" is out of range -- expected a decimal integer 0..65535`);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (SYMBOL_NAME_RE.test(trimmed)) {
|
|
164
|
+
if (!symbolResolver) {
|
|
165
|
+
throw new StockAddressError(
|
|
166
|
+
`${what}: "${trimmed}" looks like a symbol name, but no symbol table is loaded -- use a numeric address ` +
|
|
167
|
+
`(decimal, "$hex", or "0x...") instead`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const resolved = symbolResolver.resolve(trimmed);
|
|
171
|
+
if (resolved === undefined) {
|
|
172
|
+
throw new StockAddressError(`${what}: "${trimmed}" is not a known symbol`);
|
|
173
|
+
}
|
|
174
|
+
if (!inAddressRange(resolved)) {
|
|
175
|
+
throw new StockAddressError(`${what}: symbol "${trimmed}" resolved to ${resolved}, which is out of range 0..65535`);
|
|
176
|
+
}
|
|
177
|
+
return resolved;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new StockAddressError(`${what}: "${trimmed}" is not a valid address -- expected a decimal number, "$hex", "0x...", or a known symbol name`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parses a byte count through the same numeric forms as parseAddress() --
|
|
185
|
+
* decimal, "$hex", "0x..." -- never through the symbol path (a byte count
|
|
186
|
+
* is never symbolic). Refuses `0`, negatives, non-integers, and anything
|
|
187
|
+
* above `max` (default `0xffff`), so the `size`-style range check is not
|
|
188
|
+
* re-derived per family either.
|
|
189
|
+
*/
|
|
190
|
+
export function parseByteCount(input: unknown, opts: { max?: number; what?: string } = {}): number {
|
|
191
|
+
const max = opts.max ?? 0xffff;
|
|
192
|
+
const what = opts.what ?? "byte count";
|
|
193
|
+
|
|
194
|
+
let value: number;
|
|
195
|
+
if (typeof input === "number") {
|
|
196
|
+
if (!Number.isInteger(input)) {
|
|
197
|
+
throw new StockAddressError(`${what}: ${input} is not an integer`);
|
|
198
|
+
}
|
|
199
|
+
value = input;
|
|
200
|
+
} else if (typeof input === "string") {
|
|
201
|
+
const trimmed = input.trim();
|
|
202
|
+
if (/^\$[0-9a-fA-F]+$/.test(trimmed)) {
|
|
203
|
+
value = parseInt(trimmed.slice(1), 16);
|
|
204
|
+
} else if (/^0[xX][0-9a-fA-F]+$/.test(trimmed)) {
|
|
205
|
+
value = parseInt(trimmed.slice(2), 16);
|
|
206
|
+
} else if (/^[0-9]+$/.test(trimmed)) {
|
|
207
|
+
value = parseInt(trimmed, 10);
|
|
208
|
+
} else {
|
|
209
|
+
throw new StockAddressError(`${what}: "${trimmed}" is not a valid byte count -- expected a decimal number, "$hex", or "0x..."`);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
throw new StockAddressError(`${what}: expected a number or a numeric string, got ${typeof input}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (value <= 0 || value > max) {
|
|
216
|
+
throw new StockAddressError(`${what}: ${value} is out of range -- expected 1..${max}`);
|
|
217
|
+
}
|
|
218
|
+
return value;
|
|
219
|
+
}
|