@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
|
@@ -30,11 +30,13 @@
|
|
|
30
30
|
// unconditionally (kill-never-recycle). The uncatchable signals (SIGKILL,
|
|
31
31
|
// SIGSTOP) are deliberately unhandled -- see registerShutdownHandlers()'s
|
|
32
32
|
// own comment.
|
|
33
|
-
// - reapOrphanedInstances()
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
33
|
+
// - reapOrphanedInstances(): the unconditional startup reap (criterion I,
|
|
34
|
+
// D-15) that reaches instances this broker process has no in-memory
|
|
35
|
+
// record of, derived from the emulator port band plus this broker's OWN
|
|
36
|
+
// on-disk allocation record (epoch.json) -- never a host process
|
|
37
|
+
// listing or a scan of another process's argv (02-03-PLAN.md/D-14/D-15;
|
|
38
|
+
// see reapOrphanedInstances()'s own header comment for the incident
|
|
39
|
+
// this revision closes).
|
|
38
40
|
import { execFileSync } from "node:child_process";
|
|
39
41
|
import { readFileSync, readdirSync } from "node:fs";
|
|
40
42
|
import { join } from "node:path";
|
|
@@ -77,10 +79,11 @@ function defaultLog(line) {
|
|
|
77
79
|
/** Implements the discipline exactly as signal_recorded_pid()/
|
|
78
80
|
* signal_vice_child_pid() do. An empty/null/non-positive pid, or a pid
|
|
79
81
|
* already gone, returns "already_exited" without ever signalling -- "the
|
|
80
|
-
* machine being gone is the goal", per the bash version's own comment.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* machine being gone is the goal", per the bash version's own comment. An
|
|
83
|
+
* EMPTY expectedIdentity is REFUSED outright (CR-04 -- see the guard's own
|
|
84
|
+
* comment below), as is a live pid whose OWN argument string does not contain
|
|
85
|
+
* expectedIdentity: both return "identity_refused" without ever signalling,
|
|
86
|
+
* the one outcome a caller must be able to tell apart from every other stage
|
|
84
87
|
* (possible pid reuse). Only a genuine identity match proceeds: SIGTERM,
|
|
85
88
|
* poll every 200ms up to killWaitS (default VICE_BROKER_KILL_WAIT_S / 5),
|
|
86
89
|
* SIGKILL on a survivor. */
|
|
@@ -96,6 +99,22 @@ export async function verifiedKill({ pid, expectedIdentity, deps = {} }) {
|
|
|
96
99
|
if (!isAlive(pid)) {
|
|
97
100
|
return "already_exited";
|
|
98
101
|
}
|
|
102
|
+
// CR-04 (code review 2026-08-13): an EMPTY expectedIdentity REFUSES, it
|
|
103
|
+
// never permits. `"".includes` is vacuously satisfied by every process's
|
|
104
|
+
// argv, so the guard below was unconditionally true for the empty string --
|
|
105
|
+
// which disabled it entirely and let the caller SIGTERM (then SIGKILL)
|
|
106
|
+
// whatever process happens to own a recorded pid today. Pids in epoch.json
|
|
107
|
+
// outlive reboots and are freely reused, and bumpEpochForInstanceDir() below
|
|
108
|
+
// itself writes back `vice_bin: ""` while preserving an existing `pid`, so
|
|
109
|
+
// the broker could manufacture exactly that record and act on it at the next
|
|
110
|
+
// start. That is the same class of incident (two unrelated processes killed
|
|
111
|
+
// on a developer's host) this section's own header comment claims to have
|
|
112
|
+
// closed. No identity is not a match; it is the absence of evidence, and
|
|
113
|
+
// this function's whole purpose is to refuse without evidence.
|
|
114
|
+
if (expectedIdentity === "") {
|
|
115
|
+
process.stderr.write(`vice-broker: refusing to signal pid ${pid} -- no expected identity was recorded for it, so pid reuse cannot be ruled out\n`);
|
|
116
|
+
return "identity_refused";
|
|
117
|
+
}
|
|
99
118
|
const args = readProcessArgs(pid);
|
|
100
119
|
if (!args.includes(expectedIdentity)) {
|
|
101
120
|
process.stderr.write(`vice-broker: refusing to signal pid ${pid} -- ps reports "${args.trim()}", which does not match expected identity "${expectedIdentity}" (possible pid reuse)\n`);
|
|
@@ -310,49 +329,32 @@ export function startupBanner() {
|
|
|
310
329
|
}
|
|
311
330
|
return lines.join("\n");
|
|
312
331
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
/** True iff `args` contains a bare numeric token whose value is >= basePort.
|
|
341
|
-
* This is a substring/token scan over the process's own argument string --
|
|
342
|
-
* the same class of untrusted-but-locally-observed check this module's
|
|
343
|
-
* identity check already performs -- not a parse of any particular VICE
|
|
344
|
-
* flag shape, so it holds regardless of whether the port arrived via
|
|
345
|
-
* `-mcpserverport N` or a raw VICE_ARGS override naming the port some other
|
|
346
|
-
* way. */
|
|
347
|
-
function argsNamePortAtOrAbove(args, basePort) {
|
|
348
|
-
const matches = args.match(/\d+/g);
|
|
349
|
-
if (!matches)
|
|
350
|
-
return false;
|
|
351
|
-
return matches.some((token) => {
|
|
352
|
-
const n = Number(token);
|
|
353
|
-
return Number.isFinite(n) && n >= basePort;
|
|
354
|
-
});
|
|
355
|
-
}
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Startup reap: unconditional, file-free... but no longer PROCESS-TABLE-free
|
|
334
|
+
// (criterion I, D-15, as revised by 02-03-PLAN.md/D-14/D-15).
|
|
335
|
+
//
|
|
336
|
+
// 02-03-PLAN.md (BROK-03) retires this section's entire former identity
|
|
337
|
+
// mechanism -- the two functions it lived in are gone from this tree
|
|
338
|
+
// outright, not merely unused -- which used to select kill targets by
|
|
339
|
+
// scanning EVERY host process's own argument string for a plain substring
|
|
340
|
+
// match on the configured emulator binary path, gated only by "some bare
|
|
341
|
+
// integer token >= basePort appears somewhere in that same string" -- folded
|
|
342
|
+
// todo
|
|
343
|
+
// `.planning/todos/pending/2026-08-12-broker-orphan-reap-substring-identity-match.md`,
|
|
344
|
+
// observed killing two unrelated orchestrator shell processes on a
|
|
345
|
+
// developer's host (a long scratchpad path supplied the qualifying integer;
|
|
346
|
+
// a short VICE_BIN like `/bin/sleep` supplied the substring). D-15 replaces
|
|
347
|
+
// that heuristic with the broker's OWN allocation record: this reap now
|
|
348
|
+
// enumerates the instance directories under `stateDir` (which THIS broker,
|
|
349
|
+
// or a same-machine predecessor, created) and kills only the pid each
|
|
350
|
+
// directory's own epoch.json actually recorded launching. A host process
|
|
351
|
+
// this broker never allocated a port for -- however it is named, however
|
|
352
|
+
// many integers its argv happens to contain -- is out of scope BY
|
|
353
|
+
// CONSTRUCTION and is never even enumerated, let alone considered a
|
|
354
|
+
// candidate: that is the whole point, and a future "smarter heuristic"
|
|
355
|
+
// reintroducing any form of process-table scanning here is the exact
|
|
356
|
+
// regression this section exists to prevent.
|
|
357
|
+
// ============================================================================
|
|
356
358
|
function resolveBasePortForReap(override) {
|
|
357
359
|
if (typeof override === "number")
|
|
358
360
|
return override;
|
|
@@ -362,25 +364,6 @@ function resolveBasePortForReap(override) {
|
|
|
362
364
|
const n = Number(raw);
|
|
363
365
|
return Number.isFinite(n) ? n : 6600;
|
|
364
366
|
}
|
|
365
|
-
function resolveViceBinForReap(override) {
|
|
366
|
-
return override ?? process.env.VICE_BIN ?? "x64sc";
|
|
367
|
-
}
|
|
368
|
-
/** Two-condition selection (T-01.6.2-25/-26): a process qualifies ONLY when
|
|
369
|
-
* its own argument string BOTH names the configured emulator binary AND
|
|
370
|
-
* names a port at or above the allocation band's base. A process matching
|
|
371
|
-
* only one condition is left alone -- this is the whole point: the
|
|
372
|
-
* 6510-6599 band below the base is reserved by convention for an emulator a
|
|
373
|
-
* human launched for their own work (D-18), and reaping one of those would
|
|
374
|
-
* be exactly the squatting problem that band separation exists to prevent;
|
|
375
|
-
* conversely an unrelated process that merely happens to mention a
|
|
376
|
-
* matching-looking port is never a target either. */
|
|
377
|
-
export async function discoverBandProcesses(options = {}) {
|
|
378
|
-
const listProcesses = options.listProcesses ?? defaultListProcesses;
|
|
379
|
-
const viceBin = resolveViceBinForReap(options.viceBin);
|
|
380
|
-
const basePort = resolveBasePortForReap(options.basePort);
|
|
381
|
-
const entries = await listProcesses();
|
|
382
|
-
return entries.filter((entry) => entry.args.includes(viceBin) && argsNamePortAtOrAbove(entry.args, basePort));
|
|
383
|
-
}
|
|
384
367
|
function defaultListInstanceDirs(stateDir) {
|
|
385
368
|
let names;
|
|
386
369
|
try {
|
|
@@ -441,23 +424,39 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
441
424
|
};
|
|
442
425
|
deps.writeEpochRecord({ supervisorDir, record });
|
|
443
426
|
}
|
|
444
|
-
/** The unconditional startup reap (criterion I, D-15
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* a
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
427
|
+
/** The unconditional startup reap (criterion I, D-15, kill-target identity
|
|
428
|
+
* revised by 02-03-PLAN.md/D-14/D-15). Runs on every broker start, before the
|
|
429
|
+
* control listener accepts and before anything is launched -- unconditional
|
|
430
|
+
* because a broker killed with SIGKILL never runs a shutdown path, so "was
|
|
431
|
+
* the last shutdown clean" is unanswerable, and a marker file recording that
|
|
432
|
+
* answer would itself be the class of file-based liveness claim this phase
|
|
433
|
+
* retires.
|
|
434
|
+
*
|
|
435
|
+
* Enumerates the on-disk instance directories under `stateDir` in the
|
|
436
|
+
* allocation band (`port >= basePort` -- the 6510-6599 range below it stays
|
|
437
|
+
* reserved by convention for an emulator a human launched for their own
|
|
438
|
+
* work, D-18), and for each one reads its OWN `epoch.json` -- never a host
|
|
439
|
+
* process listing, never an argv scan. A directory whose record is absent,
|
|
440
|
+
* unparseable, or carries no finite positive `pid` contributes nothing to
|
|
441
|
+
* `found`/`killed` and is skipped by the kill half entirely, but the epoch
|
|
442
|
+
* bump below still runs for it: a registry-free restart must still void
|
|
443
|
+
* every in-band instance directory it finds, including one it has no usable
|
|
444
|
+
* pid for, which is the exact case this seed
|
|
445
|
+
* (.planning/seeds/broker-restart-reaps-and-voids.md) flags -- the void has
|
|
446
|
+
* to reach instances a registry-free restart never heard of. A record that
|
|
447
|
+
* DOES carry a usable pid AND a non-empty `vice_bin` is killed via
|
|
448
|
+
* verifiedKill() with `expectedIdentity` set to THAT record's own `vice_bin`
|
|
449
|
+
* -- never a globally resolved binary name -- so a live pid whose own argv
|
|
450
|
+
* does not match what THIS broker itself recorded launching there is refused
|
|
451
|
+
* (`identity_refused`), exactly like every other verifiedKill() call site in
|
|
452
|
+
* this module. A record carrying a usable pid but NO `vice_bin` is NOT a kill
|
|
453
|
+
* candidate at all (CR-04): it contributes nothing to `found`/`killed`, the
|
|
454
|
+
* kill dep is never invoked, and only the epoch bump runs -- an unidentifiable
|
|
455
|
+
* pid is refused, never killed on the strength of the pid alone.
|
|
452
456
|
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* emulator binary. Then bumps the epoch of EVERY instance directory under
|
|
457
|
-
* `stateDir` whose port falls in the band -- including directories this
|
|
458
|
-
* broker process has no in-memory record of, which is the exact case this
|
|
459
|
-
* seed (.planning/seeds/broker-restart-reaps-and-voids.md) flags: the void
|
|
460
|
-
* has to reach instances a registry-free restart never heard of.
|
|
457
|
+
* Then bumps the epoch of EVERY instance directory under `stateDir` whose
|
|
458
|
+
* port falls in the band, exactly as before this revision -- including
|
|
459
|
+
* directories this broker process has no in-memory record of.
|
|
461
460
|
*
|
|
462
461
|
* Logs one line naming the count found and the count killed, including the
|
|
463
462
|
* zero case -- both the 2026-08-01 and 2026-08-02 incidents were diagnosed
|
|
@@ -466,26 +465,38 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
466
465
|
export async function reapOrphanedInstances(options) {
|
|
467
466
|
const kill = options.kill ?? verifiedKill;
|
|
468
467
|
const log = options.log ?? defaultLog;
|
|
469
|
-
const viceBin = resolveViceBinForReap(options.viceBin);
|
|
470
468
|
const basePort = resolveBasePortForReap(options.basePort);
|
|
471
469
|
const listInstanceDirs = options.listInstanceDirs ?? defaultListInstanceDirs;
|
|
472
|
-
const matched = await discoverBandProcesses({
|
|
473
|
-
listProcesses: options.listProcesses,
|
|
474
|
-
viceBin,
|
|
475
|
-
basePort,
|
|
476
|
-
});
|
|
477
|
-
let killed = 0;
|
|
478
|
-
for (const entry of matched) {
|
|
479
|
-
const stage = await kill({ pid: entry.pid, expectedIdentity: viceBin });
|
|
480
|
-
if (stage === "sigterm" || stage === "sigkill")
|
|
481
|
-
killed++;
|
|
482
|
-
}
|
|
483
470
|
const ports = listInstanceDirs(options.stateDir);
|
|
471
|
+
let found = 0;
|
|
472
|
+
let killed = 0;
|
|
484
473
|
for (const port of ports) {
|
|
485
|
-
if (port
|
|
486
|
-
|
|
474
|
+
if (port < basePort)
|
|
475
|
+
continue;
|
|
476
|
+
const epochFields = readExistingEpochFieldsMaybe(options.epochPathFor(options.stateDir, port));
|
|
477
|
+
const pid = epochFields?.pid;
|
|
478
|
+
if (typeof pid === "number" && Number.isFinite(pid) && pid > 0) {
|
|
479
|
+
const expectedIdentity = typeof epochFields?.vice_bin === "string" ? epochFields.vice_bin : "";
|
|
480
|
+
if (expectedIdentity === "") {
|
|
481
|
+
// CR-04: a record with a usable pid but NO recorded identity is not a
|
|
482
|
+
// kill candidate at all -- it is not counted in `found` and the kill
|
|
483
|
+
// dep is never invoked. verifiedKill() refuses an empty identity too
|
|
484
|
+
// (second layer, deliberately: removing either leaves the other
|
|
485
|
+
// standing), but the reap must not even ASK, so an injected kill
|
|
486
|
+
// recorder stays empty for this case. The epoch bump below still runs:
|
|
487
|
+
// a registry-free restart must void every in-band instance directory
|
|
488
|
+
// it finds, including one it has no usable identity for.
|
|
489
|
+
log(`vice-broker: startup reap -- port ${port} records pid ${pid} but no vice_bin, so pid reuse cannot be ruled out; kill skipped, epoch still voided`);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
found++;
|
|
493
|
+
const stage = await kill({ pid, expectedIdentity });
|
|
494
|
+
if (stage === "sigterm" || stage === "sigkill")
|
|
495
|
+
killed++;
|
|
496
|
+
}
|
|
487
497
|
}
|
|
498
|
+
bumpEpochForInstanceDir(options, options.stateDir, port);
|
|
488
499
|
}
|
|
489
|
-
log(`vice-broker: startup reap found ${
|
|
490
|
-
return { found
|
|
500
|
+
log(`vice-broker: startup reap found ${found} process(es) in the emulator port band, terminated ${killed}`);
|
|
501
|
+
return { found, killed };
|
|
491
502
|
}
|