@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
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// backend-detect.mts
|
|
2
|
+
//
|
|
3
|
+
// The ONE place that decides which VICE build a binary is, and the ONE
|
|
4
|
+
// reader of VICE_BACKEND anywhere in this tree (D-01). Everything else that
|
|
5
|
+
// needs to know "fork or stock" -- broker-launch.mts's buildViceArgs(),
|
|
6
|
+
// vice-broker.mts's startup wiring, and plan 02-08's later connect handshake
|
|
7
|
+
// -- calls resolvedBackend() below and threads its answer down, exactly like
|
|
8
|
+
// vice.ts's mcpHost() documents for its own container-versus-host question:
|
|
9
|
+
// a SECOND independent reader of the same signal is a bug waiting to
|
|
10
|
+
// happen the moment one copy is updated and the other is not (mcpHost()'s
|
|
11
|
+
// own "three inlined copies" incident is the exact regression this file
|
|
12
|
+
// exists to keep from recurring here).
|
|
13
|
+
//
|
|
14
|
+
// WHAT NOT TO DO:
|
|
15
|
+
// - Do not call resolvedBackend()/probeBackend() per acquire, per launch,
|
|
16
|
+
// or per connect. The broker resolves the backend exactly ONCE, at
|
|
17
|
+
// process startup (vice-broker.mts's run()), and passes the resolved
|
|
18
|
+
// value down through every real launch call site -- see this module's
|
|
19
|
+
// own module-level memo below, which exists as a second line of defence
|
|
20
|
+
// against an accidental extra call, not as the PRIMARY mechanism (that
|
|
21
|
+
// is the caller only ever invoking this once).
|
|
22
|
+
// - Do not call this from inside broker-launch.mts's `inFlight`
|
|
23
|
+
// single-owner launch guard. This is a possibly-blocking child-process
|
|
24
|
+
// spawn (probeBackend()'s --help probe); anything that can block inside
|
|
25
|
+
// that synchronous check-and-set window is the exact failure class the
|
|
26
|
+
// 2026-08-01 triple-launch outage came from (D-03/T-02-25).
|
|
27
|
+
// - Do not add a trial-launch fallback (launch the binary for real and
|
|
28
|
+
// watch what happens) as a second detection mechanism. The `--help`
|
|
29
|
+
// probe below runs entirely outside any launch-guarded critical section
|
|
30
|
+
// BY CONSTRUCTION -- a trial launch would not. It would also depend on
|
|
31
|
+
// RESEARCH.md's assumption A1 (stock's argument parser rejects an
|
|
32
|
+
// unknown flag rather than ignoring it), which is UNVERIFIED against a
|
|
33
|
+
// real stock binary; see docs/phase2-backend-probe-evidence.md.
|
|
34
|
+
//
|
|
35
|
+
// ENVIRONMENT CONSTRAINT (2026-08-13, explicit user scope override): no real
|
|
36
|
+
// stock or fork VICE binary is reachable from the environment this plan
|
|
37
|
+
// executed in, and the user's own ruling for this plan is "we can't do
|
|
38
|
+
// tests with deciding what vice is". Every test in backend-detect.test.ts
|
|
39
|
+
// therefore drives this module's OVERRIDE path, its on-disk CACHE lifecycle,
|
|
40
|
+
// and classifyHelpOutput()'s STRING-PARSING logic against fixture strings
|
|
41
|
+
// authored in the test file -- never a real spawned binary. The `--help`
|
|
42
|
+
// discriminator itself (does a real stock build's --help output actually
|
|
43
|
+
// contain "-binarymonitor" and omit "-mcpserver", the way classifyHelpOutput()
|
|
44
|
+
// below assumes) is recorded as an OPEN, not a VERIFIED, question in
|
|
45
|
+
// docs/phase2-backend-probe-evidence.md section 2 -- that document's verdict
|
|
46
|
+
// is deliberately left standing; nothing in this file's own tests attempts to
|
|
47
|
+
// resolve it, and no fixture string anywhere in this tree should ever be
|
|
48
|
+
// presented as real captured output from either build. See the follow-up
|
|
49
|
+
// todo tracked under .planning/todos/pending/ for what a real-hardware run
|
|
50
|
+
// must still confirm.
|
|
51
|
+
import { spawnSync } from "node:child_process";
|
|
52
|
+
import {
|
|
53
|
+
existsSync,
|
|
54
|
+
readFileSync,
|
|
55
|
+
writeFileSync,
|
|
56
|
+
chmodSync,
|
|
57
|
+
renameSync,
|
|
58
|
+
mkdirSync,
|
|
59
|
+
statSync,
|
|
60
|
+
} from "node:fs";
|
|
61
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
62
|
+
|
|
63
|
+
/** Phase 2 (BROK-01, D-12, D-01): the two shapes this whole tree ever
|
|
64
|
+
* launches or speaks to. Moved here (plan 02-07) from broker-launch.mts's
|
|
65
|
+
* own plan-02-03 definition -- broker-launch.mts now imports/re-exports
|
|
66
|
+
* this one, so the type keeps exactly one home. */
|
|
67
|
+
export type ViceBackend = "fork" | "stock";
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// classifyHelpOutput() -- pure string classification, no I/O at all.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Matches on the literal flag tokens D-02 names as the discriminator: the
|
|
74
|
+
* fork's `-mcpserver` flag versus stock's `-binarymonitor`-only surface.
|
|
75
|
+
* `"fork"` wins when BOTH tokens appear (the fork's own VICE tree is a 3.10
|
|
76
|
+
* checkout and accepts both flags) -- checked FIRST, deliberately, so a
|
|
77
|
+
* build that advertises both is classified by the flag that actually makes
|
|
78
|
+
* it the fork, not merely "also has stock's flag too". `"unknown"` when
|
|
79
|
+
* NEITHER token appears -- a real --help transcript that does not match
|
|
80
|
+
* either shape, a probe that spawned nothing at all (empty text), or
|
|
81
|
+
* anything else this function was never taught to recognise. Never throws;
|
|
82
|
+
* pure function of the text it is given. */
|
|
83
|
+
export function classifyHelpOutput(text: string): "fork" | "stock" | "unknown" {
|
|
84
|
+
const hasFork = text.includes("-mcpserver");
|
|
85
|
+
const hasStock = text.includes("-binarymonitor");
|
|
86
|
+
if (hasFork) return "fork";
|
|
87
|
+
if (hasStock) return "stock";
|
|
88
|
+
return "unknown";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// probeBackend() -- the --help probe. spawnSync only, argv array, shell:
|
|
93
|
+
// false, never a shell string and never string interpolation of binPath
|
|
94
|
+
// into a command line (T-02-03's mitigation). Bounded by a 5000ms timeout
|
|
95
|
+
// with kill-on-timeout so a hostile or hung binary cannot stall broker
|
|
96
|
+
// startup (T-02-25's second mitigation half).
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
100
|
+
|
|
101
|
+
/** `--help` first, falling back to `-help` then `-?` ONLY when a run exits
|
|
102
|
+
* non-zero with EMPTY combined output -- a run that exits non-zero but
|
|
103
|
+
* still printed something (some builds write usage to stderr and exit 1) is
|
|
104
|
+
* already usable and is not retried further. */
|
|
105
|
+
const HELP_FLAG_CANDIDATES: readonly string[] = ["--help", "-help", "-?"];
|
|
106
|
+
|
|
107
|
+
export interface SpawnHelpResult {
|
|
108
|
+
text: string;
|
|
109
|
+
exitedZero: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ProbeBackendDeps {
|
|
113
|
+
/** Runs ONE candidate flag against `binPath` and returns its combined
|
|
114
|
+
* stdout+stderr text (VICE writes usage to either, depending on build)
|
|
115
|
+
* plus whether the process exited zero. Injected so no test in this tree
|
|
116
|
+
* ever spawns a real binary -- this environment has none, and per this
|
|
117
|
+
* file's own environment-constraint note above, no test may execute one.
|
|
118
|
+
* Defaults to a real, argv-array, shell:false spawnSync call bounded by
|
|
119
|
+
* PROBE_TIMEOUT_MS with kill-on-timeout; never throws -- a spawn failure
|
|
120
|
+
* (ENOENT, EACCES, a real timeout) collapses to `{ text: "", exitedZero:
|
|
121
|
+
* false }`, which classifyHelpOutput() reads as "unknown" like any other
|
|
122
|
+
* unrecognised output. */
|
|
123
|
+
spawnHelp?: (binPath: string, flag: string) => SpawnHelpResult;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function defaultSpawnHelp(binPath: string, flag: string): SpawnHelpResult {
|
|
127
|
+
try {
|
|
128
|
+
const result = spawnSync(binPath, [flag], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
131
|
+
killSignal: "SIGKILL",
|
|
132
|
+
});
|
|
133
|
+
const text = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
134
|
+
return { text, exitedZero: result.status === 0 };
|
|
135
|
+
} catch {
|
|
136
|
+
return { text: "", exitedZero: false };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Runs the fallback ladder above against `binPath` and classifies whatever
|
|
141
|
+
* text the LAST attempted flag produced. Never throws -- every failure mode
|
|
142
|
+
* (spawn error, timeout, empty output, an exit code the caller does not
|
|
143
|
+
* recognise) flows through to classifyHelpOutput() as ordinary text, which
|
|
144
|
+
* itself never throws either. */
|
|
145
|
+
export function probeBackend(binPath: string, deps: ProbeBackendDeps = {}): "fork" | "stock" | "unknown" {
|
|
146
|
+
const spawnHelp = deps.spawnHelp ?? defaultSpawnHelp;
|
|
147
|
+
let text = "";
|
|
148
|
+
for (const flag of HELP_FLAG_CANDIDATES) {
|
|
149
|
+
const outcome = spawnHelp(binPath, flag);
|
|
150
|
+
text = outcome.text;
|
|
151
|
+
if (outcome.exitedZero || text.trim() !== "") break;
|
|
152
|
+
}
|
|
153
|
+
return classifyHelpOutput(text);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// The on-disk cache -- `join(supervisorDir, "backend.json")`. `supervisorDir`
|
|
158
|
+
// is ALWAYS an explicit string this module receives from its caller, never a
|
|
159
|
+
// default this module derives itself: the one true resolver for "where is
|
|
160
|
+
// .vice-supervisor" is repo-root.ts's own supervisorDir() (ARCHITECTURE.md's
|
|
161
|
+
// named "re-deriving a cross-cutting seam locally" anti-pattern -- this file
|
|
162
|
+
// must not become a second, silently-driftable copy of that resolution).
|
|
163
|
+
// A container-side caller passes repo-root.ts's supervisorDir() return value
|
|
164
|
+
// directly; vice-broker.mts's own host-side wiring passes its already-
|
|
165
|
+
// resolved args.stateDir, which IS that same directory (see vice-broker.mts's
|
|
166
|
+
// own parseArgs()). This file cannot import repo-root.ts's VALUE as a static
|
|
167
|
+
// import and still compile as a host-bound artifact: repo-root.ts (and its
|
|
168
|
+
// own dependency install-resources.ts) use `.ts`-extension imports that only
|
|
169
|
+
// resolve under Node's native type-stripping, unbuilt -- exactly the mode a
|
|
170
|
+
// bare host running this module's COMPILED resources/backend-detect.mjs
|
|
171
|
+
// cannot rely on (this project's own standing constraint: "the host side
|
|
172
|
+
// cannot rely on Node's type-stripping the same way"). Passing the resolved
|
|
173
|
+
// string in, rather than importing the resolver, is what keeps this file
|
|
174
|
+
// importable UNBUILT from a container-side .ts (exactly like
|
|
175
|
+
// container-guard.mts's own precedent) AND compilable into resources/ for the
|
|
176
|
+
// host, from the SAME source, with no `#ifdef`-style split.
|
|
177
|
+
//
|
|
178
|
+
// When `supervisorDir` is omitted entirely, every cache read/write below is a
|
|
179
|
+
// no-op (a miss on read, silently skipped on write) -- this module still
|
|
180
|
+
// answers correctly (probe-and-memoise-in-process only), it just never
|
|
181
|
+
// persists an answer across process restarts. This is a graceful
|
|
182
|
+
// degradation, not an error: a caller that has not yet resolved a
|
|
183
|
+
// supervisor directory (or genuinely has none) gets a working, if
|
|
184
|
+
// unpersisted, answer rather than a thrown exception or a guessed path.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface BackendCacheRecord {
|
|
188
|
+
version: 1;
|
|
189
|
+
resolvedPath: string;
|
|
190
|
+
mtimeMs: number;
|
|
191
|
+
sizeBytes: number;
|
|
192
|
+
backend: ViceBackend;
|
|
193
|
+
probedAt: string;
|
|
194
|
+
/** BACK-04: filled in later, by a connect handshake (plan 02-08), never by
|
|
195
|
+
* this file's own probe -- the `--help` probe cannot observe a version
|
|
196
|
+
* quad; only a live VICE_INFO reply over an established connection can. */
|
|
197
|
+
versionQuad?: string;
|
|
198
|
+
cpuHistoryAvailable?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
202
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cachePathFor(supervisorDir: string): string {
|
|
206
|
+
return join(supervisorDir, "backend.json");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Reads and narrows the cache file at the boundary with isPlainObject()-
|
|
210
|
+
* style checks, never a cast -- absent, unreadable, unparseable, or
|
|
211
|
+
* wrong-shaped (missing/mistyped required fields) all collapse to `null`,
|
|
212
|
+
* treated identically as a cache MISS, never as an error this function
|
|
213
|
+
* surfaces to its caller. */
|
|
214
|
+
function readCacheRecord(supervisorDir: string): BackendCacheRecord | null {
|
|
215
|
+
let raw: string;
|
|
216
|
+
try {
|
|
217
|
+
raw = readFileSync(cachePathFor(supervisorDir), "utf8");
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
let parsed: unknown;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (!isPlainObject(parsed)) return null;
|
|
228
|
+
if (
|
|
229
|
+
typeof parsed.resolvedPath !== "string" ||
|
|
230
|
+
typeof parsed.mtimeMs !== "number" ||
|
|
231
|
+
typeof parsed.sizeBytes !== "number" ||
|
|
232
|
+
(parsed.backend !== "fork" && parsed.backend !== "stock")
|
|
233
|
+
) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const record: BackendCacheRecord = {
|
|
237
|
+
version: 1,
|
|
238
|
+
resolvedPath: parsed.resolvedPath,
|
|
239
|
+
mtimeMs: parsed.mtimeMs,
|
|
240
|
+
sizeBytes: parsed.sizeBytes,
|
|
241
|
+
backend: parsed.backend,
|
|
242
|
+
probedAt: typeof parsed.probedAt === "string" ? parsed.probedAt : "",
|
|
243
|
+
};
|
|
244
|
+
if (typeof parsed.versionQuad === "string") record.versionQuad = parsed.versionQuad;
|
|
245
|
+
if (typeof parsed.cpuHistoryAvailable === "boolean") record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
|
|
246
|
+
return record;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
|
|
250
|
+
* discipline refresh-manifest.ts's writeManifestAtomic() and vice-broker.mts's
|
|
251
|
+
* writeBrokerRecordFile() both already use -- a crash mid-write can only ever
|
|
252
|
+
* leave a stray tmp sibling behind, never a truncated or empty file at the
|
|
253
|
+
* real cache path that a later read would wrongly accept. */
|
|
254
|
+
function writeCacheRecordAtomic(supervisorDir: string, record: BackendCacheRecord): void {
|
|
255
|
+
mkdirSync(supervisorDir, { recursive: true });
|
|
256
|
+
const finalPath = cachePathFor(supervisorDir);
|
|
257
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
258
|
+
writeFileSync(tmpPath, "");
|
|
259
|
+
chmodSync(tmpPath, 0o600);
|
|
260
|
+
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
|
|
261
|
+
renameSync(tmpPath, finalPath);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Binary identity -- resolve a possibly-bare command name (e.g. "x64sc") to
|
|
266
|
+
// an absolute path (for cache KEYING and stat only -- probeBackend() above
|
|
267
|
+
// still spawns the ORIGINAL, unresolved binPath/viceBin string, letting the
|
|
268
|
+
// OS's own PATH search resolve it exactly like a real invocation would), and
|
|
269
|
+
// stat it for mtimeMs/sizeBytes -- the D-03 planner decision's cache key
|
|
270
|
+
// half: `{ resolvedPath, mtimeMs, sizeBytes }`, which catches a binary
|
|
271
|
+
// replaced in place (an `apt upgrade`, a manual `cp`) without hashing a
|
|
272
|
+
// multi-megabyte file on every broker start.
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export interface BinaryIdentity {
|
|
276
|
+
mtimeMs: number;
|
|
277
|
+
sizeBytes: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function defaultResolveBinPath(bin: string, env: NodeJS.ProcessEnv): string | null {
|
|
281
|
+
if (bin.includes("/")) {
|
|
282
|
+
const abs = resolvePath(bin);
|
|
283
|
+
return existsSync(abs) ? abs : null;
|
|
284
|
+
}
|
|
285
|
+
const pathEnv = env.PATH ?? "";
|
|
286
|
+
for (const dir of pathEnv.split(":")) {
|
|
287
|
+
if (!dir) continue;
|
|
288
|
+
const candidate = join(dir, bin);
|
|
289
|
+
if (existsSync(candidate)) return candidate;
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function defaultStat(resolvedPath: string): BinaryIdentity | null {
|
|
295
|
+
try {
|
|
296
|
+
const st = statSync(resolvedPath);
|
|
297
|
+
return { mtimeMs: st.mtimeMs, sizeBytes: st.size };
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function defaultLog(line: string): void {
|
|
304
|
+
process.stderr.write(`${line}\n`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// resolvedBackend() -- the public entry point.
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
export type ResolvedBackendSource = "override" | "cache" | "probe" | "indeterminate";
|
|
312
|
+
|
|
313
|
+
export interface ResolvedBackendResult {
|
|
314
|
+
backend: ViceBackend;
|
|
315
|
+
source: ResolvedBackendSource;
|
|
316
|
+
/**
|
|
317
|
+
* WR-05: the ABSOLUTE path this binary resolved to when it could be resolved,
|
|
318
|
+
* falling back to the configured name otherwise. It used to be `viceBin`
|
|
319
|
+
* unconditionally -- the raw `VICE_BIN`/`"x64sc"` string -- even though this
|
|
320
|
+
* function already computes `resolvedPath` internally for cache keying. Two
|
|
321
|
+
* consumers called that a resolved path in their own doc comments
|
|
322
|
+
* (StockDispatchDeps.resolvedBinaryPath, and BACK-03's `vice_ping` answer),
|
|
323
|
+
* so `vice_ping` on stock reported `"x64sc"` -- a name that, inside a
|
|
324
|
+
* container, resolves to nothing at all.
|
|
325
|
+
*
|
|
326
|
+
* Deliberately NOT what probeBackend() spawns: that still receives the
|
|
327
|
+
* ORIGINAL, unresolved string, so the OS's own PATH search happens exactly
|
|
328
|
+
* as it would for a real invocation. This field is what a HUMAN or an agent
|
|
329
|
+
* is shown, and "which file did you actually mean" is the question it has to
|
|
330
|
+
* answer.
|
|
331
|
+
*/
|
|
332
|
+
binPath: string;
|
|
333
|
+
/** WR-05: `true` only when `binPath` above is a real resolved absolute path;
|
|
334
|
+
* `false` when resolution failed and it fell back to the configured name.
|
|
335
|
+
* Carried explicitly so a consumer rendering it to an agent can say which of
|
|
336
|
+
* the two it has, instead of a reader having to guess from whether the string
|
|
337
|
+
* happens to contain a slash. */
|
|
338
|
+
binPathResolved: boolean;
|
|
339
|
+
note?: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface ResolvedBackendDeps {
|
|
343
|
+
env?: NodeJS.ProcessEnv;
|
|
344
|
+
/** Which binary to detect against -- defaults to VICE_BIN or "x64sc",
|
|
345
|
+
* matching broker-launch.mts's own spawnAndRecordInstance() default
|
|
346
|
+
* exactly (one broker, one binary, one verdict -- D-04). */
|
|
347
|
+
viceBin?: string;
|
|
348
|
+
/** See this module's own header comment on the cache section above --
|
|
349
|
+
* NEVER defaulted here. Omitted entirely disables the on-disk cache
|
|
350
|
+
* (probe-and-memoise-in-process only, never persisted). */
|
|
351
|
+
supervisorDir?: string;
|
|
352
|
+
resolveBinPath?: (bin: string, env: NodeJS.ProcessEnv) => string | null;
|
|
353
|
+
stat?: (resolvedPath: string) => BinaryIdentity | null;
|
|
354
|
+
probe?: (binPath: string) => "fork" | "stock" | "unknown";
|
|
355
|
+
now?: () => number;
|
|
356
|
+
log?: (line: string) => void;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Memoised answer for the probe/cache path ONLY -- the override path
|
|
360
|
+
// (VICE_BACKEND set) is always answered fresh, on every call, straight from
|
|
361
|
+
// the environment, and never touches this memo (an explicit override can
|
|
362
|
+
// legitimately differ from call to call within, e.g., a test process driving
|
|
363
|
+
// many scenarios; the detected-backend answer for a fixed binary cannot).
|
|
364
|
+
// This is what makes resolvedBackend() answer "once per long-running
|
|
365
|
+
// process" for the case that actually spawns something, while never
|
|
366
|
+
// requiring a caller to somehow signal "this is a fresh scenario" the way
|
|
367
|
+
// container-guard.mts's isInsideContainer() asks callers to pass explicit
|
|
368
|
+
// deps to bypass ITS OWN memo -- here, the override/no-override distinction
|
|
369
|
+
// already IS that signal.
|
|
370
|
+
let memoisedResult: ResolvedBackendResult | null = null;
|
|
371
|
+
|
|
372
|
+
// D-06: gates the "detected backend X for binary Y" stderr note so a
|
|
373
|
+
// long-running broker (or a test suite driving resolvedBackend() many times)
|
|
374
|
+
// emits it at most once per process -- repo-root.ts's warnedEnvOutsideFrom/
|
|
375
|
+
// warnedNoMarkerFound pattern, reused here verbatim.
|
|
376
|
+
let warnedBackendUnset = false;
|
|
377
|
+
|
|
378
|
+
function emitDetectedNote(result: ResolvedBackendResult, viceBin: string, log: (line: string) => void): void {
|
|
379
|
+
if (warnedBackendUnset) return;
|
|
380
|
+
warnedBackendUnset = true;
|
|
381
|
+
log(
|
|
382
|
+
`vice-broker: detected backend "${result.backend}" for ${viceBin} (source: ${result.source}) -- ` +
|
|
383
|
+
`set VICE_BACKEND=stock or VICE_BACKEND=fork to override this detection explicitly`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Test-only escape hatch: clears the in-process memo and the D-06
|
|
388
|
+
* one-time-note gate. Never called by any real production code path --
|
|
389
|
+
* vice-broker.mts calls resolvedBackend() exactly once per real process
|
|
390
|
+
* lifetime and has no reason to ever reset it; this exists solely so
|
|
391
|
+
* backend-detect.test.ts can drive many distinct scenarios (cache hit, cache
|
|
392
|
+
* miss, indeterminate, ...) in one shared test process without one
|
|
393
|
+
* scenario's memoised answer contaminating the next -- mirroring
|
|
394
|
+
* broker-launch.test.ts's own discipline of restoring module-level state
|
|
395
|
+
* between test cases, made explicit here rather than left to careful test
|
|
396
|
+
* ordering, since this module's memo (unlike buildViceArgs()'s one-time
|
|
397
|
+
* note) has no natural "always widens the same way" ordering to exploit. */
|
|
398
|
+
export function resetResolvedBackendForTests(): void {
|
|
399
|
+
memoisedResult = null;
|
|
400
|
+
warnedBackendUnset = false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Honours VICE_BACKEND FIRST, returning immediately without spawning
|
|
404
|
+
* anything when it names `stock` or `fork` (BACK-01: one optional config
|
|
405
|
+
* value switches backends, no code edit). Otherwise consults the on-disk
|
|
406
|
+
* cache (when `supervisorDir` is given and the binary's current
|
|
407
|
+
* `{ resolvedPath, mtimeMs, sizeBytes }` all match the stored record); on a
|
|
408
|
+
* miss, probes via probeBackend() and writes the cache. Memoises the
|
|
409
|
+
* probe/cache answer in a module-level variable so a long-running process
|
|
410
|
+
* resolves once (see the memo's own comment above for what "once" means
|
|
411
|
+
* here). Never throws: a probe that classifies "unknown" (including a
|
|
412
|
+
* spawn failure or a timeout, both of which probeBackend() already reduces
|
|
413
|
+
* to "unknown") returns a defined `{ backend: "fork", source:
|
|
414
|
+
* "indeterminate", ... }` outcome instead -- "fork" because that is the
|
|
415
|
+
* pre-Phase-2 behaviour every existing install already has, so an
|
|
416
|
+
* undetectable binary degrades to what already worked rather than to
|
|
417
|
+
* nothing. */
|
|
418
|
+
/** WR-05: the ONE place `binPath`/`binPathResolved` are derived, so the four
|
|
419
|
+
* return paths below cannot disagree about what "the binary" means. A resolved
|
|
420
|
+
* absolute path when there is one; the configured name, flagged as unresolved,
|
|
421
|
+
* when there is not. */
|
|
422
|
+
function binPathFields(resolvedPath: string | null, viceBin: string): { binPath: string; binPathResolved: boolean } {
|
|
423
|
+
return resolvedPath !== null ? { binPath: resolvedPath, binPathResolved: true } : { binPath: viceBin, binPathResolved: false };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function resolvedBackend(deps: ResolvedBackendDeps = {}): ResolvedBackendResult {
|
|
427
|
+
const env = deps.env ?? process.env;
|
|
428
|
+
const viceBin = deps.viceBin ?? env.VICE_BIN ?? "x64sc";
|
|
429
|
+
// A direct read of the real environment on the right of this ternary
|
|
430
|
+
// (rather than the generic `env` local above) is deliberate: this file is
|
|
431
|
+
// grep-gated, tree-wide, as the ONE place that ever names this variable
|
|
432
|
+
// directly against the real environment -- `deps.env` (the test-injection
|
|
433
|
+
// seam) still takes precedence when supplied, exactly like every other
|
|
434
|
+
// field on this options object.
|
|
435
|
+
const override = deps.env ? deps.env.VICE_BACKEND : process.env.VICE_BACKEND;
|
|
436
|
+
const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
|
|
437
|
+
|
|
438
|
+
if (override === "stock" || override === "fork") {
|
|
439
|
+
// WR-05: an explicit backend override still resolves the PATH, so
|
|
440
|
+
// `vice_ping` reports a real file rather than the bare name. This is a
|
|
441
|
+
// filesystem lookup only -- existsSync per PATH entry -- and NEVER a spawn,
|
|
442
|
+
// so the override path keeps its "answered fresh, straight from the
|
|
443
|
+
// environment, spawns nothing" property.
|
|
444
|
+
return { backend: override, source: "override", ...binPathFields(resolveBinPath(viceBin, env), viceBin) };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (memoisedResult !== null) return memoisedResult;
|
|
448
|
+
|
|
449
|
+
const log = deps.log ?? defaultLog;
|
|
450
|
+
const stat = deps.stat ?? defaultStat;
|
|
451
|
+
const probe = deps.probe ?? ((bin: string) => probeBackend(bin));
|
|
452
|
+
const now = deps.now ?? ((): number => Date.now());
|
|
453
|
+
|
|
454
|
+
const resolvedPath = resolveBinPath(viceBin, env);
|
|
455
|
+
const identity = resolvedPath ? stat(resolvedPath) : null;
|
|
456
|
+
const cacheEligible = resolvedPath !== null && identity !== null && typeof deps.supervisorDir === "string";
|
|
457
|
+
|
|
458
|
+
if (cacheEligible) {
|
|
459
|
+
const cached = readCacheRecord(deps.supervisorDir as string);
|
|
460
|
+
if (
|
|
461
|
+
cached &&
|
|
462
|
+
cached.resolvedPath === resolvedPath &&
|
|
463
|
+
cached.mtimeMs === identity!.mtimeMs &&
|
|
464
|
+
cached.sizeBytes === identity!.sizeBytes
|
|
465
|
+
) {
|
|
466
|
+
const result: ResolvedBackendResult = { backend: cached.backend, source: "cache", ...binPathFields(resolvedPath, viceBin) };
|
|
467
|
+
memoisedResult = result;
|
|
468
|
+
emitDetectedNote(result, viceBin, log);
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const verdict = probe(viceBin);
|
|
474
|
+
|
|
475
|
+
if (verdict === "unknown") {
|
|
476
|
+
const note =
|
|
477
|
+
`vice-broker: could not determine whether ${viceBin} is the stock or fork VICE build -- ` +
|
|
478
|
+
`its --help output matched neither the -mcpserver nor the -binarymonitor discriminator. ` +
|
|
479
|
+
`Set VICE_BACKEND=stock or VICE_BACKEND=fork explicitly.`;
|
|
480
|
+
log(note);
|
|
481
|
+
const result: ResolvedBackendResult = { backend: "fork", source: "indeterminate", ...binPathFields(resolvedPath, viceBin), note };
|
|
482
|
+
memoisedResult = result;
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (cacheEligible) {
|
|
487
|
+
writeCacheRecordAtomic(deps.supervisorDir as string, {
|
|
488
|
+
version: 1,
|
|
489
|
+
resolvedPath: resolvedPath as string,
|
|
490
|
+
mtimeMs: identity!.mtimeMs,
|
|
491
|
+
sizeBytes: identity!.sizeBytes,
|
|
492
|
+
backend: verdict,
|
|
493
|
+
probedAt: new Date(now()).toISOString(),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const result: ResolvedBackendResult = { backend: verdict, source: "probe", ...binPathFields(resolvedPath, viceBin) };
|
|
498
|
+
memoisedResult = result;
|
|
499
|
+
emitDetectedNote(result, viceBin, log);
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// BACK-04: the capability record. Same cache file, same identity match --
|
|
505
|
+
// filled in by plan 02-08's connect handshake, once per binary, never once
|
|
506
|
+
// per connect. This file's own probe never populates these fields (a
|
|
507
|
+
// `--help` transcript cannot carry a version quad); it only ever reads or
|
|
508
|
+
// updates them against a backend verdict this file already wrote.
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
export interface CapabilityRecordResult {
|
|
512
|
+
versionQuad?: string;
|
|
513
|
+
cpuHistoryAvailable?: boolean;
|
|
514
|
+
/** True only when `observedVersionQuad` was given AND differs from the
|
|
515
|
+
* stored value -- the caller has just seen a DIFFERENT VICE build than
|
|
516
|
+
* whatever wrote this record (the binary was swapped since the last
|
|
517
|
+
* capability determination), so the stored answer cannot be trusted and
|
|
518
|
+
* must be re-determined rather than reused. */
|
|
519
|
+
stale: boolean;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export interface CapabilityDeps {
|
|
523
|
+
env?: NodeJS.ProcessEnv;
|
|
524
|
+
supervisorDir?: string;
|
|
525
|
+
resolveBinPath?: (bin: string, env: NodeJS.ProcessEnv) => string | null;
|
|
526
|
+
stat?: (resolvedPath: string) => BinaryIdentity | null;
|
|
527
|
+
/** A version quad the caller just observed live (over VICE_INFO on an
|
|
528
|
+
* established connection) -- compared against whatever this cache
|
|
529
|
+
* currently has on record for the SAME resolved binary. Omitted entirely
|
|
530
|
+
* skips the staleness comparison outright (the returned `stale` is always
|
|
531
|
+
* `false` when this is omitted). */
|
|
532
|
+
observedVersionQuad?: string;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Reads whatever capability answers (BACK-04) are on record for `binPath`
|
|
536
|
+
* -- `null` when there is no cache at all, the binary cannot be resolved, the
|
|
537
|
+
* record on file names a DIFFERENT resolved binary, or nothing has been
|
|
538
|
+
* recorded for this binary yet. Never throws. */
|
|
539
|
+
export function readCapabilityRecord(binPath: string, deps: CapabilityDeps = {}): CapabilityRecordResult | null {
|
|
540
|
+
if (typeof deps.supervisorDir !== "string") return null;
|
|
541
|
+
const env = deps.env ?? process.env;
|
|
542
|
+
const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
|
|
543
|
+
const resolvedPath = resolveBinPath(binPath, env);
|
|
544
|
+
if (!resolvedPath) return null;
|
|
545
|
+
|
|
546
|
+
const existing = readCacheRecord(deps.supervisorDir);
|
|
547
|
+
if (!existing || existing.resolvedPath !== resolvedPath) return null;
|
|
548
|
+
if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined) return null;
|
|
549
|
+
|
|
550
|
+
const stale =
|
|
551
|
+
deps.observedVersionQuad !== undefined &&
|
|
552
|
+
existing.versionQuad !== undefined &&
|
|
553
|
+
existing.versionQuad !== deps.observedVersionQuad;
|
|
554
|
+
|
|
555
|
+
return { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
|
|
559
|
+
* verdict already on record for `binPath`'s resolved identity -- a no-op,
|
|
560
|
+
* never a throw, when there is no such matching record yet (no supervisorDir
|
|
561
|
+
* given, the binary cannot be resolved or stat'd, or the cache names a
|
|
562
|
+
* different binary or has no verdict at all). This function never invents a
|
|
563
|
+
* backend verdict of its own: it can only EXTEND a record resolvedBackend()
|
|
564
|
+
* already wrote, since a `--help` probe has no way to observe a version
|
|
565
|
+
* quad and this function must not silently fabricate the field it did not
|
|
566
|
+
* observe either. */
|
|
567
|
+
export function writeCapabilityRecord(
|
|
568
|
+
binPath: string,
|
|
569
|
+
capability: { versionQuad: string; cpuHistoryAvailable: boolean },
|
|
570
|
+
deps: CapabilityDeps = {},
|
|
571
|
+
): void {
|
|
572
|
+
if (typeof deps.supervisorDir !== "string") return;
|
|
573
|
+
const env = deps.env ?? process.env;
|
|
574
|
+
const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
|
|
575
|
+
const stat = deps.stat ?? defaultStat;
|
|
576
|
+
|
|
577
|
+
const resolvedPath = resolveBinPath(binPath, env);
|
|
578
|
+
if (!resolvedPath) return;
|
|
579
|
+
const identity = stat(resolvedPath);
|
|
580
|
+
if (!identity) return;
|
|
581
|
+
|
|
582
|
+
const existing = readCacheRecord(deps.supervisorDir);
|
|
583
|
+
if (!existing || existing.resolvedPath !== resolvedPath) return;
|
|
584
|
+
|
|
585
|
+
writeCacheRecordAtomic(deps.supervisorDir, {
|
|
586
|
+
version: 1,
|
|
587
|
+
resolvedPath,
|
|
588
|
+
mtimeMs: identity.mtimeMs,
|
|
589
|
+
sizeBytes: identity.sizeBytes,
|
|
590
|
+
backend: existing.backend,
|
|
591
|
+
probedAt: existing.probedAt,
|
|
592
|
+
versionQuad: capability.versionQuad,
|
|
593
|
+
cpuHistoryAvailable: capability.cpuHistoryAvailable,
|
|
594
|
+
});
|
|
595
|
+
}
|