@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.
@@ -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()/discoverBandProcesses(): the unconditional
34
- // startup reap (criterion I, D-15) that reaches instances this broker
35
- // process has no in-memory record of, derived from the emulator port
36
- // band plus process identity rather than from a registry a restart just
37
- // lost.
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. A
81
- * live pid whose OWN argument string does not contain expectedIdentity is
82
- * REFUSED -- never signalled -- and returns "identity_refused", the one
83
- * outcome a caller must be able to tell apart from every other stage
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
- /** Real default: `ps -eo pid=,args=` -- every process on the host, pid plus
314
- * its full argument string. Never throws: an unreadable `ps` (e.g. no
315
- * processes visible under this container's pid namespace) yields an empty
316
- * list rather than aborting the reap. */
317
- function defaultListProcesses() {
318
- let raw;
319
- try {
320
- raw = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf8" });
321
- }
322
- catch {
323
- return [];
324
- }
325
- const out = [];
326
- for (const line of raw.split("\n")) {
327
- const trimmed = line.trimStart();
328
- if (trimmed === "")
329
- continue;
330
- const m = /^(\d+)\s+(.*)$/.exec(trimmed);
331
- if (!m)
332
- continue;
333
- const pid = Number(m[1]);
334
- if (!Number.isFinite(pid))
335
- continue;
336
- out.push({ pid, args: m[2] });
337
- }
338
- return out;
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). Runs on every broker
445
- * start, before the control listener accepts and before anything is
446
- * launched -- unconditional because a broker killed with SIGKILL never runs
447
- * a shutdown path, so "was the last shutdown clean" is unanswerable, and a
448
- * marker file recording that answer would itself be the class of file-based
449
- * liveness claim this phase retires (consults NO such file; the seam this
450
- * module offers is the process listing and the on-disk instance
451
- * directories, nothing else).
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
- * Enumerates host processes via the injected/real process-listing
454
- * dependency, selects the two-condition matches (discoverBandProcesses()
455
- * above), and kills each one identity-verified against the configured
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 >= basePort) {
486
- bumpEpochForInstanceDir(options, options.stateDir, port);
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 ${matched.length} process(es) in the emulator port band, terminated ${killed}`);
490
- return { found: matched.length, killed };
500
+ log(`vice-broker: startup reap found ${found} process(es) in the emulator port band, terminated ${killed}`);
501
+ return { found, killed };
491
502
  }