@henols/vice-mcp 0.1.12 → 0.2.1
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/THIRD-PARTY-NOTICES.md +23 -0
- package/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/package.json +26 -1
- package/r2000-acme-ident.ts +97 -0
- package/r2000-cli.ts +1103 -0
- package/r2000-confidence.ts +233 -0
- package/r2000-d64.ts +310 -0
- package/r2000-enum-gen.ts +574 -0
- package/r2000-launch.ts +357 -0
- package/r2000-mcp-client.ts +596 -0
- package/r2000-memmap-render.ts +531 -0
- package/r2000-project.ts +190 -0
- package/r2000-regbits-gen.ts +416 -0
- package/r2000-regbits.json +1370 -0
- package/r2000-symbols.ts +388 -0
- package/r2000-tools.ts +914 -0
- package/r2000-verify.ts +184 -0
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +455 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +195 -6
package/r2000-verify.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// r2000-verify.ts -- the ONE place that interprets regenerator2000's
|
|
3
|
+
// `--verify` output.
|
|
4
|
+
//
|
|
5
|
+
// WHY PARSING IS REQUIRED AT ALL (D-10, the concrete incident): with ACME
|
|
6
|
+
// absent from PATH and ca65 present, a real `regenerator2000 0.9.20 --verify`
|
|
7
|
+
// run on this host printed
|
|
8
|
+
//
|
|
9
|
+
// ✗ ACME — ACME not found in PATH (skipped)
|
|
10
|
+
// ✓ All roundtrip verifications passed.
|
|
11
|
+
// EXIT=0
|
|
12
|
+
//
|
|
13
|
+
// -- exit 0, and a summary line that reads as a full pass, while the one
|
|
14
|
+
// assembler this project actually cares about (`!cpu 6510`, ACME 0.97) never
|
|
15
|
+
// ran at all. Trusting the exit code here would let ACME be silently
|
|
16
|
+
// skipped and still report success. This is the WHAT-NOT-TO-DO for any
|
|
17
|
+
// future edit to this file: never derive `ok` from `status`, ever, no
|
|
18
|
+
// matter how tempting a bare zero-exit-status check looks. The verdict this
|
|
19
|
+
// module produces comes ONLY from parsing the per-assembler result lines
|
|
20
|
+
// and reading ACME's own line -- never from the process exit code, and
|
|
21
|
+
// never from the aggregate "All roundtrip verifications passed." summary
|
|
22
|
+
// line, which is itself the thing that lied in the transcript above.
|
|
23
|
+
//
|
|
24
|
+
// Both captured transcripts (the honest pass and this exact false-pass trap)
|
|
25
|
+
// are pinned verbatim as fixtures in r2000-verify.test.ts, so a future
|
|
26
|
+
// "simplification" back to an exit-code check fails a unit test immediately.
|
|
27
|
+
//
|
|
28
|
+
// WR-04 (10-REVIEW.md, fixed in plan 11-01): the verdict must also never
|
|
29
|
+
// trust just the FIRST ACME result line. `acmeVerdict()` used to select
|
|
30
|
+
// ACME's line with a bare array .find() over the first matching entry, so
|
|
31
|
+
// a transcript containing both a
|
|
32
|
+
// passing and a failing ACME line (a shape --verify has never printed as of
|
|
33
|
+
// 0.9.20, but one this defensive parser is explicitly meant to survive)
|
|
34
|
+
// reported `ok: true` from the first (passing) line while discarding the
|
|
35
|
+
// later failure -- exactly the "misleading success" this module exists to
|
|
36
|
+
// refuse. The fix requires UNANIMITY: every parsed ACME line must be `ok`,
|
|
37
|
+
// the first non-ok line (if any) drives the verdict, and if more than one
|
|
38
|
+
// ACME line is present after passing that check, the module refuses to
|
|
39
|
+
// guess which one is authoritative rather than picking one arbitrarily.
|
|
40
|
+
// Both the mixed-transcript case and the too-many-ACME-lines case are pinned
|
|
41
|
+
// verbatim as fixtures in r2000-verify.test.ts.
|
|
42
|
+
//
|
|
43
|
+
// Import nothing from `hostpath.ts`/`containerpath.ts` -- plan 10-01's
|
|
44
|
+
// absence assertion in `hostpath-consumers.test.ts` already names this file.
|
|
45
|
+
|
|
46
|
+
import { buildVerifyArgs, runR2000 } from "./r2000-launch.ts";
|
|
47
|
+
|
|
48
|
+
/** The three possible outcomes for a single per-assembler `--verify` result
|
|
49
|
+
* line. `"skipped"` and `"ok"` are DIFFERENT outcomes and must never be
|
|
50
|
+
* conflated -- a skipped assembler did not run, so it proves nothing, while
|
|
51
|
+
* an `ok` assembler was actually invoked and its output byte-diffed. */
|
|
52
|
+
export type AssemblerOutcome = "ok" | "skipped" | "failed";
|
|
53
|
+
|
|
54
|
+
export interface VerifyLine {
|
|
55
|
+
assembler: string;
|
|
56
|
+
outcome: AssemblerOutcome;
|
|
57
|
+
detail: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Matches exactly a per-assembler result line, e.g.:
|
|
61
|
+
// ✓ ACME — byte-identical (44 bytes)
|
|
62
|
+
// ✗ 64tass — 64tass not found in PATH (skipped)
|
|
63
|
+
// Tolerates an em-dash (U+2014), en-dash (U+2013) or plain hyphen as the
|
|
64
|
+
// separator, since only the ACME verdict is load-bearing here and the exact
|
|
65
|
+
// glyph regenerator2000 prints is an upstream formatting detail, not
|
|
66
|
+
// something this parser should be brittle against. Deliberately does NOT
|
|
67
|
+
// match the aggregate "✓ All roundtrip verifications passed." summary line
|
|
68
|
+
// (no separator token present there) or the "EXIT=N" line some transcripts
|
|
69
|
+
// carry -- both are excluded from the returned array by construction, not
|
|
70
|
+
// by a special-cased skip: they simply never match this shape.
|
|
71
|
+
const VERIFY_LINE_PATTERN = /^[✓✗]\s+(.+?)\s+[—–-]\s+(.+)$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses `regenerator2000 --verify`'s stdout into one `VerifyLine` per
|
|
75
|
+
* per-assembler result line. The aggregate `✓ All roundtrip verifications
|
|
76
|
+
* passed.` line is a summary, not an assembler line -- it never matches
|
|
77
|
+
* `VERIFY_LINE_PATTERN` (no `—`/`-` separator), so it is excluded from the
|
|
78
|
+
* result by construction rather than filtered out after the fact.
|
|
79
|
+
*/
|
|
80
|
+
export function parseVerifyOutput(stdout: string): VerifyLine[] {
|
|
81
|
+
const lines: VerifyLine[] = [];
|
|
82
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
83
|
+
const trimmed = rawLine.trim();
|
|
84
|
+
if (!trimmed) continue;
|
|
85
|
+
const match = trimmed.match(VERIFY_LINE_PATTERN);
|
|
86
|
+
if (!match) continue;
|
|
87
|
+
const [, assemblerRaw, detailRaw] = match;
|
|
88
|
+
const assembler = assemblerRaw!.trim();
|
|
89
|
+
const detail = detailRaw!.trim();
|
|
90
|
+
// A "(skipped)" suffix always means skipped, regardless of the leading
|
|
91
|
+
// glyph. Anything else takes its outcome from the leading glyph: a
|
|
92
|
+
// leading ✓ is "ok", any other non-skipped result line is "failed" --
|
|
93
|
+
// this project has never observed a real ACME/ca65 failure transcript,
|
|
94
|
+
// but a future one must not silently parse as "ok".
|
|
95
|
+
const outcome: AssemblerOutcome = /\(skipped\)\s*$/i.test(detail)
|
|
96
|
+
? "skipped"
|
|
97
|
+
: trimmed.startsWith("✓")
|
|
98
|
+
? "ok"
|
|
99
|
+
: "failed";
|
|
100
|
+
lines.push({ assembler, outcome, detail });
|
|
101
|
+
}
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Derives the ACME-specific verdict from a parsed line set. `ok` only when
|
|
107
|
+
* at least one ACME line exists, EVERY parsed ACME line has outcome `"ok"`
|
|
108
|
+
* (unanimity -- WR-04), AND exactly one ACME line is present. A missing
|
|
109
|
+
* ACME line, any ACME line with outcome `"skipped"` or `"failed"`, and more
|
|
110
|
+
* than one `"ok"` ACME line each return `ok: false` with a distinct,
|
|
111
|
+
* quotable reason -- never conflate "skipped" with "passed", never let a
|
|
112
|
+
* passing line hide a later failing one, and never fall back to the summary
|
|
113
|
+
* line (which this module never even parses as a VerifyLine, see
|
|
114
|
+
* `parseVerifyOutput`).
|
|
115
|
+
*/
|
|
116
|
+
export function acmeVerdict(lines: VerifyLine[]): { ok: boolean; reason: string } {
|
|
117
|
+
const acmeLines = lines.filter((l) => l.assembler.toLowerCase() === "acme");
|
|
118
|
+
|
|
119
|
+
if (acmeLines.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason:
|
|
123
|
+
"no ACME line found in --verify output -- ACME was never invoked at all, which is a failure, " +
|
|
124
|
+
"not an absence of evidence",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// First non-ok line wins -- so a passing ACME line earlier in the
|
|
129
|
+
// transcript can never hide a failing one later in it (WR-04).
|
|
130
|
+
const bad = acmeLines.find((l) => l.outcome !== "ok");
|
|
131
|
+
|
|
132
|
+
if (bad?.outcome === "skipped") {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason:
|
|
136
|
+
`ACME was skipped, not run -- "${bad.detail}". A skipped ACME is a failure, never a pass ` +
|
|
137
|
+
`(D-10): --verify can print "✓ All roundtrip verifications passed." and exit 0 even when ` +
|
|
138
|
+
`ACME never ran at all -- exactly the false pass observed live on this host with ACME absent ` +
|
|
139
|
+
`and ca65 present.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (bad?.outcome === "failed") {
|
|
144
|
+
return { ok: false, reason: `ACME reported a failure: "${bad.detail}"` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Every ACME line is ok at this point. Still refuse to guess which one is
|
|
148
|
+
// authoritative if more than one was printed (WR-04) -- unanimous is not
|
|
149
|
+
// the same as unambiguous.
|
|
150
|
+
if (acmeLines.length > 1) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: `--verify printed ${acmeLines.length} ACME result lines -- refusing to guess which one is the verdict`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { ok: true, reason: `ACME reported: ${acmeLines[0]!.detail}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface VerifyProjectResult {
|
|
161
|
+
ok: boolean;
|
|
162
|
+
reason: string;
|
|
163
|
+
lines: VerifyLine[];
|
|
164
|
+
status: number | null;
|
|
165
|
+
stdout: string;
|
|
166
|
+
stderr: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Runs `regenerator2000 --verify` against `projectPath` (via
|
|
171
|
+
* `buildVerifyArgs()`/`runR2000()`, so the `--vice` scan applies here too),
|
|
172
|
+
* parses stdout, and derives `ok` from `acmeVerdict()` -- deliberately NOT
|
|
173
|
+
* from `status`. Returns the raw status and streams so a caller can print
|
|
174
|
+
* them for diagnostics, but no code path in this function ever lets a zero
|
|
175
|
+
* exit status alone make `ok` true. A warning on stderr is never treated as
|
|
176
|
+
* a failure -- only the parsed ACME result line decides the verdict.
|
|
177
|
+
*/
|
|
178
|
+
export function verifyProject(projectPath: string): VerifyProjectResult {
|
|
179
|
+
const argv = buildVerifyArgs({ projectPath });
|
|
180
|
+
const { status, stdout, stderr } = runR2000(argv);
|
|
181
|
+
const lines = parseVerifyOutput(stdout);
|
|
182
|
+
const verdict = acmeVerdict(lines);
|
|
183
|
+
return { ok: verdict.ok, reason: verdict.reason, lines, status, stdout, stderr };
|
|
184
|
+
}
|
|
@@ -122,6 +122,19 @@ export function probeBackend(binPath, deps = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
return classifyHelpOutput(text);
|
|
124
124
|
}
|
|
125
|
+
/** The client-side capability-decision schema version stamped into every
|
|
126
|
+
* capability record this module writes (see BackendCacheRecord's own field
|
|
127
|
+
* comment). BUMP THIS whenever the client's capability probing or the
|
|
128
|
+
* decoding it depends on changes, so records decided by the older code are
|
|
129
|
+
* re-probed instead of trusted.
|
|
130
|
+
*
|
|
131
|
+
* History:
|
|
132
|
+
* 1 -- plan 02-08's original probe (implicit; never written to disk).
|
|
133
|
+
* 2 -- CR-01 fix: decode failures are no longer persisted at all, and the
|
|
134
|
+
* CPUHISTORY_GET body layout was corrected (07-12). Every record
|
|
135
|
+
* written before this constant existed lacks the field and is
|
|
136
|
+
* therefore stale, which is exactly the intent. */
|
|
137
|
+
export const CAPABILITY_SCHEMA_VERSION = 2;
|
|
125
138
|
function isPlainObject(value) {
|
|
126
139
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
140
|
}
|
|
@@ -168,6 +181,8 @@ function readCacheRecord(supervisorDir) {
|
|
|
168
181
|
record.versionQuad = parsed.versionQuad;
|
|
169
182
|
if (typeof parsed.cpuHistoryAvailable === "boolean")
|
|
170
183
|
record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
|
|
184
|
+
if (typeof parsed.capabilitySchema === "number")
|
|
185
|
+
record.capabilitySchema = parsed.capabilitySchema;
|
|
171
186
|
return record;
|
|
172
187
|
}
|
|
173
188
|
/** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
|
|
@@ -354,10 +369,19 @@ export function readCapabilityRecord(binPath, deps = {}) {
|
|
|
354
369
|
return null;
|
|
355
370
|
if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined)
|
|
356
371
|
return null;
|
|
357
|
-
const
|
|
372
|
+
const versionMismatch = deps.observedVersionQuad !== undefined &&
|
|
358
373
|
existing.versionQuad !== undefined &&
|
|
359
374
|
existing.versionQuad !== deps.observedVersionQuad;
|
|
360
|
-
|
|
375
|
+
// CR-01: a record decided by a different client-side capability schema is
|
|
376
|
+
// as untrustworthy as one decided against a different binary. Absent
|
|
377
|
+
// counts as a mismatch -- that is every record written before the field
|
|
378
|
+
// existed, i.e. every record a possibly-broken parser could have written.
|
|
379
|
+
const schemaMismatch = existing.capabilitySchema !== CAPABILITY_SCHEMA_VERSION;
|
|
380
|
+
const stale = versionMismatch || schemaMismatch;
|
|
381
|
+
const result = { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
|
|
382
|
+
if (existing.capabilitySchema !== undefined)
|
|
383
|
+
result.capabilitySchema = existing.capabilitySchema;
|
|
384
|
+
return result;
|
|
361
385
|
}
|
|
362
386
|
/** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
|
|
363
387
|
* verdict already on record for `binPath`'s resolved identity -- a no-op,
|
|
@@ -392,5 +416,9 @@ export function writeCapabilityRecord(binPath, capability, deps = {}) {
|
|
|
392
416
|
probedAt: existing.probedAt,
|
|
393
417
|
versionQuad: capability.versionQuad,
|
|
394
418
|
cpuHistoryAvailable: capability.cpuHistoryAvailable,
|
|
419
|
+
// CR-01: stamp WHICH client decided this, so a later parser change
|
|
420
|
+
// invalidates it. Never accept this from the caller -- it describes this
|
|
421
|
+
// module's own code, not anything the caller observed.
|
|
422
|
+
capabilitySchema: CAPABILITY_SCHEMA_VERSION,
|
|
395
423
|
});
|
|
396
424
|
}
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
// deliberately-killed instance, and writes the per-instance boot/crash log
|
|
27
27
|
// D-23 preserves at the exact path shape the retiring bash supervisor used.
|
|
28
28
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
29
|
-
import { mkdirSync, openSync, closeSync, existsSync } from "node:fs";
|
|
29
|
+
import { mkdirSync, mkdtempSync, openSync, closeSync, existsSync } from "node:fs";
|
|
30
30
|
import { join, basename } from "node:path";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
31
32
|
// Module-level: this file, not the caller, owns the single boolean --
|
|
32
33
|
// synchronous check, synchronous set, released in a finally, with no
|
|
33
34
|
// `await` between the check and the set.
|
|
@@ -124,7 +125,31 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
|
|
|
124
125
|
`reach it; the default of 127.0.0.1 is the safe posture for a host-native install, widen only ` +
|
|
125
126
|
`when the MCP server itself runs in a container that must reach the host emulator\n`);
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
+
// Audit item I-2 (§4.2, FINDING-C1): a broker-launched stock x64sc used
|
|
129
|
+
// to boot with Drive8Type=0 (NONE) -- nothing answers unit 8, so
|
|
130
|
+
// LOAD"*",8,1 fails ?DEVICE NOT PRESENT ERROR and the entry-point
|
|
131
|
+
// checkpoint never hits. No stock MCP tool can correct this after boot
|
|
132
|
+
// (the 38-tool stock manifest has zero resource-set names by design),
|
|
133
|
+
// so the fix must be a launch-time flag. `-default` MUST be the very
|
|
134
|
+
// first element: it is VICE's reset-to-compiled-in-defaults instruction,
|
|
135
|
+
// not an inert "these are the baselines" no-op, so any flag emitted
|
|
136
|
+
// before it (including -drive8type) is silently clobbered back to its
|
|
137
|
+
// compiled-in value. `-drive8type 1541` therefore has to come
|
|
138
|
+
// immediately after `-default`, and -- per CLAUDE.md's documented
|
|
139
|
+
// constraint -- `-default` also has to come before `-binarymonitor` or
|
|
140
|
+
// the monitor never binds and the subsequent connect hangs in the
|
|
141
|
+
// backlog looking exactly like a wedge. Confirmed sufficient live in
|
|
142
|
+
// Phase 8.1's standalone probe (08.1-WALKTHROUGH-EVIDENCE.md §4):
|
|
143
|
+
// `resourceget "Drive8Type"` moved 0 -> 1541 and a `load` over the text
|
|
144
|
+
// monitor succeeded immediately. Deliberately NOT setting
|
|
145
|
+
// -drive8truedrive / Drive8TrueEmulation here: this build's own default
|
|
146
|
+
// already reads Drive8TrueEmulation=1 (same probe), so 08.2-RESEARCH.md's
|
|
147
|
+
// primary recommendation is that only -drive8type needs adding.
|
|
148
|
+
// Assumption A3 in that doc's Assumptions Log (some other stock build
|
|
149
|
+
// might default Drive8TrueEmulation to 0) is read and deliberately not
|
|
150
|
+
// pre-emptively defended against here; plan 03's live test is what would
|
|
151
|
+
// surface it if that assumption is ever wrong on a different build.
|
|
152
|
+
const args = ["-default", "-drive8type", "1541", "-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`];
|
|
128
153
|
if (typeof remoteMonitorPort === "number") {
|
|
129
154
|
if (host !== "127.0.0.1" && !warnedRemoteMonitorBindWidened) {
|
|
130
155
|
warnedRemoteMonitorBindWidened = true;
|
|
@@ -154,7 +179,7 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
|
|
|
154
179
|
* spawning, so a bad configuration value is visible rather than silently
|
|
155
180
|
* mis-parsed, exactly like the bash launcher's own logging discipline. */
|
|
156
181
|
function spawnAndRecordInstance(reason, port, deps) {
|
|
157
|
-
const spawnFn = deps.spawn ?? ((cmd, args) => nodeSpawn(cmd, args));
|
|
182
|
+
const spawnFn = deps.spawn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
|
|
158
183
|
const now = deps.now ?? (() => Date.now());
|
|
159
184
|
const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
|
|
160
185
|
const backend = deps.backend ?? "fork";
|
|
@@ -165,8 +190,52 @@ function spawnAndRecordInstance(reason, port, deps) {
|
|
|
165
190
|
remoteMonitorPort: deps.remoteMonitorPort,
|
|
166
191
|
});
|
|
167
192
|
const log = deps.log ?? defaultLog;
|
|
168
|
-
|
|
169
|
-
|
|
193
|
+
// I-1 rider (audit §4.4, 08.2-02-PLAN.md Task 2): production stock
|
|
194
|
+
// launches used to set no scratch XDG_CONFIG_HOME and would read whatever
|
|
195
|
+
// vicerc the operator's own $HOME already carried -- shared with the
|
|
196
|
+
// operator's own VICE usage and with the fork build. For backend ===
|
|
197
|
+
// "stock" only, compute a fresh, isolated config dir with mkdtempSync
|
|
198
|
+
// (atomic creation, random suffix, 0700 permissions -- the primitive that
|
|
199
|
+
// makes a collision or a symlink-swap into the operator's real config
|
|
200
|
+
// unreachable) and pass it as a third options argument carrying `env`
|
|
201
|
+
// only. Never `shell: true`: the existing array-form spawn(viceBin,
|
|
202
|
+
// viceArgs) call avoids shell interpretation entirely and that property
|
|
203
|
+
// must survive this widening. For backend === "fork", spawnFn is called
|
|
204
|
+
// with NO third argument at all, so the fork path's observable behaviour
|
|
205
|
+
// stays bit-for-bit what it was (BACK-02 is a standing gate and the fork
|
|
206
|
+
// backend has been the sole production backend across all of v0.1.x).
|
|
207
|
+
//
|
|
208
|
+
// Scope boundary (do not remove this note): the production broker daemon
|
|
209
|
+
// always supplies its own deps.spawn / deps.spawnFactory, so the widened
|
|
210
|
+
// default wrapper above is dead code on the real launch paths. This
|
|
211
|
+
// function's job is only to COMPUTE the value at the one seam that should
|
|
212
|
+
// own it; the forwarding to nodeSpawn() happens at four further hops --
|
|
213
|
+
// makeLoggingSpawn() and maintainWarmFloorForRealBroker's inner
|
|
214
|
+
// stashingSpawn in vice-broker.mts, and withCrashSupervision()'s wrapper
|
|
215
|
+
// body and launchSupervised()'s defaultRealSpawn in this file. All four
|
|
216
|
+
// now forward the options argument (plan 08.2-06 closed them in this same
|
|
217
|
+
// phase, with a handleAcquire() composition test that omits
|
|
218
|
+
// buildColdSpawnFactory so an injected stub cannot fake the proof). If you
|
|
219
|
+
// add a fifth spawn hop, it must forward options too, or production stock
|
|
220
|
+
// launches silently lose their config isolation again.
|
|
221
|
+
//
|
|
222
|
+
// Scratch-dir lifetime: this function deliberately does NOT clean the
|
|
223
|
+
// directory up -- the spawned emulator process outlives this function's
|
|
224
|
+
// return and needs the directory for its whole lifetime. Per-launch
|
|
225
|
+
// scratch dirs therefore accumulate under the OS temp dir for the life of
|
|
226
|
+
// the host; this is a recorded trade-off, not an oversight. If reaping
|
|
227
|
+
// them is ever worth doing, the broker's own kill/recycle path is the
|
|
228
|
+
// component that would own it (it already knows when an instance's
|
|
229
|
+
// process has actually exited).
|
|
230
|
+
let spawnOptions;
|
|
231
|
+
let logLine = `vice-broker: launching ${viceBin} ${viceArgs.join(" ")}`;
|
|
232
|
+
if (backend === "stock") {
|
|
233
|
+
const scratchConfigDir = mkdtempSync(join(tmpdir(), "vice-broker-vicerc-"));
|
|
234
|
+
spawnOptions = { env: { ...process.env, XDG_CONFIG_HOME: scratchConfigDir } };
|
|
235
|
+
logLine += ` (XDG_CONFIG_HOME=${scratchConfigDir})`;
|
|
236
|
+
}
|
|
237
|
+
log(logLine);
|
|
238
|
+
const child = spawnOptions === undefined ? spawnFn(viceBin, viceArgs) : spawnFn(viceBin, viceArgs, spawnOptions);
|
|
170
239
|
const record = {
|
|
171
240
|
port,
|
|
172
241
|
url: `http://127.0.0.1:${port}/mcp`,
|
|
@@ -422,6 +491,14 @@ function binmonRequest(commandType, requestId) {
|
|
|
422
491
|
header[10] = commandType;
|
|
423
492
|
return header;
|
|
424
493
|
}
|
|
494
|
+
/** The wire's own "this is not a reply to any request I sent" sentinel
|
|
495
|
+
* (CLAUDE.md's Protocol constraint) -- REGISTER_INFO (0x31) arrives
|
|
496
|
+
* unsolicited at THIS id on every monitor open, and CHECKPOINT_INFO/STOPPED/
|
|
497
|
+
* RESUMED/JAM can too. A response-type byte alone is not enough to
|
|
498
|
+
* distinguish "an event that happens to share a type with a real reply" from
|
|
499
|
+
* an actual reply -- request-id is the only field the wire promises never
|
|
500
|
+
* collides between the two, which is exactly why demux must key on it. */
|
|
501
|
+
const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
|
|
425
502
|
/**
|
|
426
503
|
* WR-01: one PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
|
|
427
504
|
* reply -- STX, the expected api_version, response type 0x81, error code 0x00,
|
|
@@ -429,6 +506,23 @@ function binmonRequest(commandType, requestId) {
|
|
|
429
506
|
* here for exactly the reason probeReady()'s own comment gives for the HTTP
|
|
430
507
|
* route: a C64 can accept a connection before it has finished booting.
|
|
431
508
|
*
|
|
509
|
+
* quick task 260818-obc (live-discovered): a NEW binmon connection ALWAYS
|
|
510
|
+
* emits an unsolicited REGISTER_INFO (0x31) frame at request-id 0xffffffff
|
|
511
|
+
* the instant it opens (CLAUDE.md's own Protocol constraint) -- BEFORE this
|
|
512
|
+
* probe's own PING reply ever arrives. The naive "the first 12 bytes ARE the
|
|
513
|
+
* reply" read this code used to do treated that event frame's OWN response-
|
|
514
|
+
* type byte (0x31) as a malformed PING reply and answered `false` forever,
|
|
515
|
+
* live-reproduced against a real crash-respawned stock x64sc: the respawn
|
|
516
|
+
* never left "launching" because THIS probe could never see it as ready,
|
|
517
|
+
* even though the emulator was genuinely up and answering fine underneath.
|
|
518
|
+
* The fix walks frame boundaries using each frame's own body-length field and
|
|
519
|
+
* discards every frame whose request-id is the unsolicited sentinel (or
|
|
520
|
+
* simply is not this probe's own id) rather than assuming the first frame
|
|
521
|
+
* on the wire is the reply -- the same "demux by request-id, never by
|
|
522
|
+
* arrival order" discipline CLAUDE.md's Protocol constraint already requires
|
|
523
|
+
* of every OTHER binmon consumer in this tree (stock-protocol.ts's
|
|
524
|
+
* ViceMonitorClient chief among them).
|
|
525
|
+
*
|
|
432
526
|
* Then EXIT (0xaa), unconditionally, before closing -- because the PING ITSELF
|
|
433
527
|
* HALTS THE MACHINE. Any inbound byte does (docs/phase0-binmon-findings.md §4,
|
|
434
528
|
* and CR-02, which fixed the same omission in the connect handshake). A
|
|
@@ -470,31 +564,52 @@ async function defaultBinmonProbe(port, timeoutMs) {
|
|
|
470
564
|
});
|
|
471
565
|
socket.on("data", (chunk) => {
|
|
472
566
|
buffer = Buffer.concat([buffer, chunk]);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
buffer
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
567
|
+
// Walk complete frames off the front of the buffer -- never assume the
|
|
568
|
+
// first BINMON_RESPONSE_HEADER_LEN bytes on the wire are this probe's
|
|
569
|
+
// own reply (see this function's own header comment on why an
|
|
570
|
+
// unsolicited event frame can and does arrive first in practice).
|
|
571
|
+
for (;;) {
|
|
572
|
+
if (buffer.length < BINMON_RESPONSE_HEADER_LEN)
|
|
573
|
+
return; // wait for more data
|
|
574
|
+
const bodyLen = buffer.readUInt32LE(2);
|
|
575
|
+
const frameLen = BINMON_RESPONSE_HEADER_LEN + bodyLen;
|
|
576
|
+
if (buffer.length < frameLen)
|
|
577
|
+
return; // header seen, body still incoming
|
|
578
|
+
const responseType = buffer[6];
|
|
579
|
+
const errorCode = buffer[7];
|
|
580
|
+
const requestId = buffer.readUInt32LE(8);
|
|
581
|
+
const stxOk = buffer[0] === BINMON_STX && buffer[1] === BINMON_API_VERSION;
|
|
582
|
+
if (!stxOk) {
|
|
583
|
+
finish(false);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (requestId === BINMON_UNSOLICITED_REQUEST_ID || requestId !== BINMON_PROBE_REQUEST_ID) {
|
|
587
|
+
// Not a reply to anything this probe sent (an unsolicited event, or
|
|
588
|
+
// a stale reply to a previous probe's own request id) -- discard
|
|
589
|
+
// this one frame only and keep walking the rest of the buffer.
|
|
590
|
+
buffer = buffer.subarray(frameLen);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (responseType !== BINMON_CMD_PING || errorCode !== 0x00) {
|
|
594
|
+
finish(false);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Resume the machine this probe's own PING halted, then close GRACEFULLY:
|
|
598
|
+
// socket.end(data, cb) writes the EXIT and then sends FIN, so the bytes
|
|
599
|
+
// are delivered before the connection goes away. A bare write() followed
|
|
600
|
+
// by destroy() can discard them (destroy may RST), which would leave the
|
|
601
|
+
// instance "ready" and frozen -- the exact outcome the EXIT exists to
|
|
602
|
+
// prevent. The resume is best-effort in its OUTCOME, though: a failed
|
|
603
|
+
// resume must not turn a READY instance into a not-ready one, since the
|
|
604
|
+
// emulator demonstrably answered, which is what this function reports on.
|
|
605
|
+
try {
|
|
606
|
+
socket.end(binmonRequest(BINMON_CMD_EXIT, BINMON_PROBE_REQUEST_ID + 1), () => finish(true));
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
finish(true);
|
|
610
|
+
}
|
|
482
611
|
return;
|
|
483
612
|
}
|
|
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
613
|
});
|
|
499
614
|
});
|
|
500
615
|
}
|
|
@@ -849,8 +964,11 @@ async function handleExit(reason, port, deps) {
|
|
|
849
964
|
}
|
|
850
965
|
/** The single exit-listener installation point in the whole module tree.
|
|
851
966
|
* Wraps `baseSpawn` (a plain spawn function of the same shape
|
|
852
|
-
* `(command, args) => ChildProcess` every launch path already
|
|
853
|
-
* through
|
|
967
|
+
* `(command, args, options?) => ChildProcess` every launch path already
|
|
968
|
+
* threads through -- the third `options` argument is load-bearing: it
|
|
969
|
+
* carries the scratch XDG_CONFIG_HOME that isolates a stock launch from the
|
|
970
|
+
* operator's real vicerc, and this wrapper MUST forward it) so the returned
|
|
971
|
+
* spawn function, when called, attaches a
|
|
854
972
|
* one-shot "exit" listener that drives handleExit() above -- the SAME
|
|
855
973
|
* respawn/give-up/deliberate-teardown resolution launchSupervised()'s own
|
|
856
974
|
* relaunch path already uses. Returns the child object baseSpawn produced,
|
|
@@ -866,8 +984,13 @@ async function handleExit(reason, port, deps) {
|
|
|
866
984
|
* a second inline listener, is what keeps the "exactly one installation
|
|
867
985
|
* point" invariant a structural gate (broker-launch.test.ts) can hold. */
|
|
868
986
|
export function withCrashSupervision(reason, port, baseSpawn, deps) {
|
|
869
|
-
|
|
870
|
-
|
|
987
|
+
// I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
|
|
988
|
+
// in the BODY, not just the type -- this is the hop that matters most,
|
|
989
|
+
// because it wraps every real launch path (cold acquire, warm floor, and
|
|
990
|
+
// every respawn). A type-only widening would still silently drop a
|
|
991
|
+
// caller's options at this call site.
|
|
992
|
+
return (cmd, args, options) => {
|
|
993
|
+
const child = baseSpawn(cmd, args, options);
|
|
871
994
|
child.once("exit", () => {
|
|
872
995
|
void handleExit(reason, port, deps);
|
|
873
996
|
});
|
|
@@ -914,9 +1037,18 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
|
|
|
914
1037
|
const logFileName = `${basename(viceBin)}-${Date.now()}-e${epoch}.log`;
|
|
915
1038
|
const logPath = join(logDir, logFileName);
|
|
916
1039
|
const logRelPath = `logs/${logFileName}`;
|
|
917
|
-
|
|
1040
|
+
// I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
|
|
1041
|
+
// and MERGES it with the per-instance log stdio -- caller options
|
|
1042
|
+
// spread FIRST, `stdio` set LAST, so the per-instance log fd always
|
|
1043
|
+
// wins. Never the other order: a caller-supplied `stdio` would silently
|
|
1044
|
+
// redirect a crash-respawn's output away from the log file the epoch
|
|
1045
|
+
// record names, and the forensic per-instance log (D-23) would point at
|
|
1046
|
+
// a file that received nothing. Without this fix, a stock instance that
|
|
1047
|
+
// crashes and respawns comes back reading the operator's real `vicerc`
|
|
1048
|
+
// even though its original launch was isolated.
|
|
1049
|
+
const defaultRealSpawn = (cmd, args, options) => {
|
|
918
1050
|
const fd = openSync(logPath, "a");
|
|
919
|
-
return nodeSpawn(cmd, args, { stdio: ["ignore", fd, fd] });
|
|
1051
|
+
return nodeSpawn(cmd, args, { ...options, stdio: ["ignore", fd, fd] });
|
|
920
1052
|
};
|
|
921
1053
|
const baseSpawn = deps.spawnFactory ? deps.spawnFactory(port) : (deps.spawn ?? defaultRealSpawn);
|
|
922
1054
|
const wrappedSpawn = withCrashSupervision(reason, port, baseSpawn, deps);
|
|
@@ -184,14 +184,22 @@ function writeBrokerRecordFile(stateDir, record) {
|
|
|
184
184
|
* closure and the log's path relative to supervisorDir (the epoch
|
|
185
185
|
* record's own `log` field). Shared by both launch paths -- a cold
|
|
186
186
|
* acquire and warm-floor maintenance -- so there is exactly one place that
|
|
187
|
-
* opens a launch log fd.
|
|
187
|
+
* opens a launch log fd.
|
|
188
|
+
*
|
|
189
|
+
* I-1 rider (08.2-06-PLAN.md, Task 2): the returned `spawn` now also
|
|
190
|
+
* forwards a caller options object (audit item I-1), MERGING it into the
|
|
191
|
+
* object handed to nodeSpawn() -- caller options spread FIRST, `stdio` set
|
|
192
|
+
* LAST, so the launch log fd always wins over any caller-supplied `stdio`.
|
|
193
|
+
* Merging in the other order would silently redirect a launch's output
|
|
194
|
+
* away from the per-instance log file the epoch record names, breaking
|
|
195
|
+
* D-23's forensic logs while appearing to work. */
|
|
188
196
|
function makeLoggingSpawn(logDir) {
|
|
189
197
|
mkdirSync(logDir, { recursive: true });
|
|
190
198
|
const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
|
|
191
199
|
const logName = `${viceBinForLog}-${Date.now()}.log`;
|
|
192
200
|
const logFd = openSync(join(logDir, logName), "a");
|
|
193
201
|
return {
|
|
194
|
-
spawn: (cmd, cmdArgs) => nodeSpawn(cmd, cmdArgs, { stdio: ["ignore", logFd, logFd] }),
|
|
202
|
+
spawn: (cmd, cmdArgs, options) => nodeSpawn(cmd, cmdArgs, { ...options, stdio: ["ignore", logFd, logFd] }),
|
|
195
203
|
logRelPath: `logs/${logName}`,
|
|
196
204
|
};
|
|
197
205
|
}
|
|
@@ -684,8 +692,12 @@ function maintainWarmFloorForRealBroker(stateDir, state, backend) {
|
|
|
684
692
|
spawnFactory: (port) => {
|
|
685
693
|
const supervisorDir = join(stateDir, String(port));
|
|
686
694
|
const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
|
|
687
|
-
|
|
688
|
-
|
|
695
|
+
// I-1 rider (08.2-06-PLAN.md, Task 2): forwards a third options
|
|
696
|
+
// argument -- this is a SECOND, independent dropper on the
|
|
697
|
+
// warm-floor arm; fixing only makeLoggingSpawn above would leave
|
|
698
|
+
// this arm's own scratch XDG_CONFIG_HOME dropped right here.
|
|
699
|
+
const stashingSpawn = (cmd, args, options) => {
|
|
700
|
+
const child = spawn(cmd, args, options);
|
|
689
701
|
// Stash the log path where onLaunched (fired synchronously right
|
|
690
702
|
// after this returns, still within the SAME maintainWarmFloor()
|
|
691
703
|
// call -- at most one launch per call, per the serialised-warming
|
|
@@ -720,6 +732,15 @@ function maintainWarmFloorForRealBroker(stateDir, state, backend) {
|
|
|
720
732
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
721
733
|
});
|
|
722
734
|
}
|
|
735
|
+
/** Exported ONLY so a test can drive the warm-floor arm's REAL spawn
|
|
736
|
+
* composition (this function's own makeLoggingSpawn()+stashingSpawn+
|
|
737
|
+
* withCrashSupervision() closure above) through the built artifact, the
|
|
738
|
+
* same escape-hatch pattern `_superviseDepsFor` already establishes for the
|
|
739
|
+
* respawn composition -- see vice-broker-acquire.test.ts's I-1 composition
|
|
740
|
+
* tests (08.2-06-PLAN.md, Task 3), which call this directly with no spawn
|
|
741
|
+
* override so the warm floor's own independent `stashingSpawn` dropper
|
|
742
|
+
* cannot hide behind an injected stub. */
|
|
743
|
+
export const _maintainWarmFloorForRealBroker = maintainWarmFloorForRealBroker;
|
|
723
744
|
/** Releases a grant and identity-verified-kills its instance -- but ONLY
|
|
724
745
|
* when the port's CURRENT occupant is proven to be the SAME process this
|
|
725
746
|
* grant was actually issued for (its own recorded `pid`, set at grant time
|