@henols/vice-mcp 0.2.1 → 0.2.3
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 +4 -3
- package/THIRD-PARTY-NOTICES.md +423 -25
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +3040 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +919 -0
- package/anno-export-asm.ts +2396 -0
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +150 -0
- package/anno-join.ts +480 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/anno-provenance-ledger.ts +472 -0
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +399 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +4002 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +3264 -0
- package/anno-types.ts +1909 -0
- package/backend-detect.mts +124 -312
- package/block-class.ts +201 -0
- package/build.ts +4 -2
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/hostpath.ts +1 -1
- package/incident-record.ts +23 -12
- package/install-resources.ts +30 -14
- package/memmap-lookup.ts +285 -0
- package/package.json +48 -23
- package/prg-image.ts +118 -0
- package/repo-root.ts +107 -8
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +373 -214
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +130 -12
- package/stock-address.ts +6 -6
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +8 -8
- package/stock-connect.ts +10 -6
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +200 -68
- package/stock-execution.ts +5 -3
- package/stock-handler.ts +1 -1
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +17 -12
- package/stock-recycle.ts +83 -2
- package/stock-registers.ts +54 -29
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +13 -13
- package/stock-timing.ts +2 -2
- package/stock-vicii.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/version.ts +1 -1
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +392 -2175
- package/vsf-slice.ts +640 -0
- package/capability-registry.ts +0 -388
- package/r2000-cli.ts +0 -1103
- package/r2000-d64.ts +0 -310
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/vice-probe.ts
DELETED
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// The deliberately-FRAGILE liveness probe -- the counterpart to
|
|
3
|
-
// tools/vice.mjs's resilient withReconnect() ladder, and the two must NEVER
|
|
4
|
-
// be merged. Where the seam retries a transport failure over a ~50s backoff
|
|
5
|
-
// budget because a call has to eventually succeed, this module answers one
|
|
6
|
-
// question fast: is anything actually listening and answering right now.
|
|
7
|
-
//
|
|
8
|
-
// WHAT WENT WRONG BEFORE (this module exists because of it): the host
|
|
9
|
-
// supervisor died, its epoch froze, and the FIRST thing anyone noticed was a
|
|
10
|
-
// call burning ~50s of reconnect backoff before failing -- acquire() had
|
|
11
|
-
// handed out a port based only on the registry CLAIMING an instance existed,
|
|
12
|
-
// never on anything actually answering there (.planning/STATE.md's HOST
|
|
13
|
-
// INSTABILITY / HARD BLOCKER entries, 2026-07-30). Probing four dead
|
|
14
|
-
// candidates through that ladder would take minutes just to report an empty
|
|
15
|
-
// pool -- unacceptable for a health check that has to run on every acquire()
|
|
16
|
-
// poll pass.
|
|
17
|
-
//
|
|
18
|
-
// WHY vice_ping AND ONLY vice_ping: it is the one call measured NON-pausing
|
|
19
|
-
// -- 986,693 cycles/s while ping-polling versus 991,569 fully quiet
|
|
20
|
-
// (.planning/STATE.md's pause-on-read finding, 2026-07-30). Every other
|
|
21
|
-
// state-reading vice_* call PAUSES the emulator and does not resume it,
|
|
22
|
-
// which would make probing itself destructive to the very instance being
|
|
23
|
-
// probed. Check that measurement against STATE.md directly rather than
|
|
24
|
-
// trusting this comment if it ever needs re-verifying.
|
|
25
|
-
//
|
|
26
|
-
// This module takes NO static dependency on tools/vice.mjs (the transport
|
|
27
|
-
// seam) -- importing it is exactly how the resilient retry path would leak
|
|
28
|
-
// into a probe. probeAll() below fans a single-shot request sequence out
|
|
29
|
-
// across several candidate ports at once, none of which the seam is (or
|
|
30
|
-
// should be) pointed at; the seam's module-level "active instance" state has
|
|
31
|
-
// no business being touched by a health check.
|
|
32
|
-
//
|
|
33
|
-
// The one exception (quick-260730-q4b, D-3): a side-effect-only import of
|
|
34
|
-
// repo-root.ts, below. This module is the ONE skill file that imports
|
|
35
|
-
// nothing else from the skill, so without this line the deploy-on-first-use
|
|
36
|
-
// resource check never fires when this probe is the entry point. This does
|
|
37
|
-
// NOT violate the no-dependencies stance above, which is specifically about
|
|
38
|
-
// never importing vice.mjs's resilient retry ladder into this deliberately-
|
|
39
|
-
// fragile probe -- repo-root.ts is a pure path resolver plus this one
|
|
40
|
-
// trigger, and pulls in nothing that speaks MCP.
|
|
41
|
-
import "./repo-root.ts";
|
|
42
|
-
|
|
43
|
-
/** The one and only tool this module will ever call. Hardcoded, not a
|
|
44
|
-
* parameter on any exported function -- that is the structural reason no
|
|
45
|
-
* probe caller can ever steer this module at a forbidden tool (T-p5x-03,
|
|
46
|
-
* D-7): there is no plumbing through which a tool name could arrive. */
|
|
47
|
-
export const PROBE_TOOL = "vice_ping";
|
|
48
|
-
|
|
49
|
-
/** ~1-2s, configurable (D-3). A probe is meant to answer fast; a caller that
|
|
50
|
-
* wants a more patient check (or a stricter one) can override per call. */
|
|
51
|
-
export const DEFAULT_PROBE_TIMEOUT_MS = Number(process.env.VICE_PROBE_TIMEOUT_MS || 1500);
|
|
52
|
-
|
|
53
|
-
/** Options accepted by probeInstance(): a target `url`/`port` pair and an
|
|
54
|
-
* optional per-call timeout override (defaults to DEFAULT_PROBE_TIMEOUT_MS). */
|
|
55
|
-
export interface ProbeInstanceOptions {
|
|
56
|
-
url: string;
|
|
57
|
-
port: number;
|
|
58
|
-
timeoutMs?: number;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** The verdict probeInstance() always resolves to -- never throws, per the
|
|
62
|
-
* function's own doc comment below. `reason` is null exactly when
|
|
63
|
-
* `alive` is true; `ping` carries the decoded vice_ping payload when one was
|
|
64
|
-
* recognisable, otherwise null (or the raw text when it didn't parse as
|
|
65
|
-
* JSON, matching the pre-existing behavior of the function this was ported
|
|
66
|
-
* from). */
|
|
67
|
-
export type ProbeResult = {
|
|
68
|
-
port: number;
|
|
69
|
-
url: string;
|
|
70
|
-
alive: boolean;
|
|
71
|
-
ms: number;
|
|
72
|
-
reason: string | null;
|
|
73
|
-
ping: unknown;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
/** A single probe target, as probeAll() consumes it -- the caller's own
|
|
77
|
-
* responsibility to have derived `url` from a validated integer `port`
|
|
78
|
-
* (T-p5x-01), never from a string read straight out of a registry file. */
|
|
79
|
-
export interface ProbeInstanceRef {
|
|
80
|
-
port: number;
|
|
81
|
-
url: string;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Options accepted by probeAll(): the same per-call timeout override
|
|
85
|
-
* probeInstance() takes, applied uniformly to every candidate in the fan-out. */
|
|
86
|
-
export interface ProbeAllOptions {
|
|
87
|
-
timeoutMs?: number;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The combined result of probeAll(): the plain per-instance results array,
|
|
91
|
-
* plus a `byPort` Map for O(1) lookup by port without re-scanning. */
|
|
92
|
-
export interface ProbeAllResult {
|
|
93
|
-
results: ProbeResult[];
|
|
94
|
-
byPort: Map<number, ProbeResult>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Parse an MCP HTTP response body exactly the two ways tools/vice.mjs's
|
|
99
|
-
* rpc() already does: an SSE-framed body's last `data:` line, or a plain
|
|
100
|
-
* JSON body. Deliberately duplicated here, not imported from the seam --
|
|
101
|
-
* see this file's header comment for why sharing that code would mean
|
|
102
|
-
* sharing the seam's module state too. Throws on anything unparseable; the
|
|
103
|
-
* caller turns that into an `alive:false` verdict, never lets it escape.
|
|
104
|
-
*/
|
|
105
|
-
function parseMcpBody(text: string, contentType: string): unknown {
|
|
106
|
-
if (contentType.includes("text/event-stream")) {
|
|
107
|
-
const dataLines = text
|
|
108
|
-
.split("\n")
|
|
109
|
-
.filter((l) => l.startsWith("data:"))
|
|
110
|
-
.map((l) => l.slice(5).trim())
|
|
111
|
-
.filter(Boolean);
|
|
112
|
-
if (!dataLines.length) {
|
|
113
|
-
throw new Error("no data: lines in SSE response");
|
|
114
|
-
}
|
|
115
|
-
return JSON.parse(dataLines[dataLines.length - 1]);
|
|
116
|
-
}
|
|
117
|
-
return JSON.parse(text);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
121
|
-
* an array. Used to narrow parseMcpBody()'s `unknown` result before reading
|
|
122
|
-
* its `.error`/`.result` fields, in the same style as vice-broker.mts's
|
|
123
|
-
* isPlainObject(). */
|
|
124
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
125
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* One single-shot MCP round trip sequence against `url`: the `initialize`
|
|
130
|
-
* handshake followed by a `tools/call` of PROBE_TOOL. A SINGLE
|
|
131
|
-
* `AbortSignal.timeout(timeoutMs)` is created once, before either request,
|
|
132
|
-
* and shared by both -- total wall time is bounded by `timeoutMs` no matter
|
|
133
|
-
* how many round trips the handshake costs. No retry, no backoff, no second
|
|
134
|
-
* attempt at anything: this is the deliberately-fragile counterpart to
|
|
135
|
-
* withReconnect(), and reusing that ladder here would silently reintroduce
|
|
136
|
-
* the ~50s-per-dead-candidate problem this module exists to avoid (D-3).
|
|
137
|
-
*
|
|
138
|
-
* NEVER THROWS. Every failure mode -- a rejected fetch, an abort, a non-2xx
|
|
139
|
-
* status, a JSON-RPC `error` member, or a 200 that doesn't decode to a
|
|
140
|
-
* recognisable ping result -- becomes `{ alive: false, reason }` instead, so
|
|
141
|
-
* a caller can always build a per-candidate rejection report without a
|
|
142
|
-
* try/catch of its own.
|
|
143
|
-
*
|
|
144
|
-
* Returns `{ port, url, alive, ms, reason, ping }`.
|
|
145
|
-
*/
|
|
146
|
-
export async function probeInstance({
|
|
147
|
-
url,
|
|
148
|
-
port,
|
|
149
|
-
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
150
|
-
}: ProbeInstanceOptions): Promise<ProbeResult> {
|
|
151
|
-
const startedAt = Date.now();
|
|
152
|
-
const elapsed = () => Date.now() - startedAt;
|
|
153
|
-
const signal = AbortSignal.timeout(timeoutMs);
|
|
154
|
-
|
|
155
|
-
const post = async (body: unknown) =>
|
|
156
|
-
fetch(url, {
|
|
157
|
-
method: "POST",
|
|
158
|
-
headers: {
|
|
159
|
-
"Content-Type": "application/json",
|
|
160
|
-
Accept: "application/json, text/event-stream",
|
|
161
|
-
},
|
|
162
|
-
body: JSON.stringify(body),
|
|
163
|
-
signal,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
const initRes = await post({
|
|
168
|
-
jsonrpc: "2.0",
|
|
169
|
-
id: 1,
|
|
170
|
-
method: "initialize",
|
|
171
|
-
params: {
|
|
172
|
-
protocolVersion: "2024-11-05",
|
|
173
|
-
capabilities: {},
|
|
174
|
-
clientInfo: { name: "vice-probe", version: "1.0" },
|
|
175
|
-
},
|
|
176
|
-
});
|
|
177
|
-
if (!initRes.ok) {
|
|
178
|
-
return { port, url, alive: false, ms: elapsed(), reason: `initialize returned HTTP ${initRes.status}`, ping: null };
|
|
179
|
-
}
|
|
180
|
-
let initPayload: unknown;
|
|
181
|
-
try {
|
|
182
|
-
initPayload = parseMcpBody(await initRes.text(), initRes.headers.get("content-type") || "");
|
|
183
|
-
} catch (e) {
|
|
184
|
-
return { port, url, alive: false, ms: elapsed(), reason: `initialize: unparseable response (${(e as Error).message})`, ping: null };
|
|
185
|
-
}
|
|
186
|
-
if (isPlainObject(initPayload) && initPayload.error) {
|
|
187
|
-
const initError = initPayload.error;
|
|
188
|
-
const initErrorMessage = isPlainObject(initError) && typeof initError.message === "string" ? initError.message : "unknown";
|
|
189
|
-
return {
|
|
190
|
-
port, url, alive: false, ms: elapsed(),
|
|
191
|
-
reason: `initialize RPC error: ${initErrorMessage}`,
|
|
192
|
-
ping: null,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const callRes = await post({
|
|
197
|
-
jsonrpc: "2.0",
|
|
198
|
-
id: 2,
|
|
199
|
-
method: "tools/call",
|
|
200
|
-
params: { name: PROBE_TOOL, arguments: {} },
|
|
201
|
-
});
|
|
202
|
-
if (!callRes.ok) {
|
|
203
|
-
return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL} returned HTTP ${callRes.status}`, ping: null };
|
|
204
|
-
}
|
|
205
|
-
let callPayload: unknown;
|
|
206
|
-
try {
|
|
207
|
-
callPayload = parseMcpBody(await callRes.text(), callRes.headers.get("content-type") || "");
|
|
208
|
-
} catch (e) {
|
|
209
|
-
return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL}: unparseable response (${(e as Error).message})`, ping: null };
|
|
210
|
-
}
|
|
211
|
-
if (isPlainObject(callPayload) && callPayload.error) {
|
|
212
|
-
const callError = callPayload.error;
|
|
213
|
-
const callErrorMessage = isPlainObject(callError) && typeof callError.message === "string" ? callError.message : "unknown";
|
|
214
|
-
return {
|
|
215
|
-
port, url, alive: false, ms: elapsed(),
|
|
216
|
-
reason: `${PROBE_TOOL} RPC error: ${callErrorMessage}`,
|
|
217
|
-
ping: null,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const result = isPlainObject(callPayload) ? callPayload.result : undefined;
|
|
222
|
-
const contentArray = isPlainObject(result) && Array.isArray(result.content) ? result.content : undefined;
|
|
223
|
-
const content = contentArray ? contentArray[0] : undefined;
|
|
224
|
-
if (!isPlainObject(content) || content.type !== "text") {
|
|
225
|
-
return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL}: unexpected tool result shape`, ping: null };
|
|
226
|
-
}
|
|
227
|
-
let ping: unknown;
|
|
228
|
-
try {
|
|
229
|
-
ping = JSON.parse(content.text as string);
|
|
230
|
-
} catch {
|
|
231
|
-
ping = content.text;
|
|
232
|
-
}
|
|
233
|
-
// A 200 that doesn't decode to a recognisable ping result (no "version"
|
|
234
|
-
// field) is something ELSE listening on that port -- not the same as
|
|
235
|
-
// VICE being up (T-p5x-04).
|
|
236
|
-
if (!isPlainObject(ping) || typeof ping.version === "undefined") {
|
|
237
|
-
return {
|
|
238
|
-
port, url, alive: false, ms: elapsed(),
|
|
239
|
-
reason: `something answered on this port but did not return a recognisable ping result (no "version" field) -- not VICE`,
|
|
240
|
-
ping: ping ?? null,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return { port, url, alive: true, ms: elapsed(), reason: null, ping };
|
|
245
|
-
} catch (e) {
|
|
246
|
-
const ms = elapsed();
|
|
247
|
-
const err = e as Error & { name?: string; cause?: unknown };
|
|
248
|
-
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
249
|
-
return { port, url, alive: false, ms, reason: `no response within ${timeoutMs}ms (timeout)`, ping: null };
|
|
250
|
-
}
|
|
251
|
-
// Surface the underlying cause code (ECONNREFUSED etc.) when fetch's
|
|
252
|
-
// undici implementation attaches one, rather than a generic message --
|
|
253
|
-
// "nothing is listening" and "something is wrong with the request" are
|
|
254
|
-
// different diagnoses and the reason string should say which.
|
|
255
|
-
const cause = err.cause;
|
|
256
|
-
const causeCode = isPlainObject(cause) && typeof cause.code === "string" ? cause.code : undefined;
|
|
257
|
-
return { port, url, alive: false, ms, reason: causeCode || err.message, ping: null };
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Probe every instance in `instances` concurrently -- N candidates cost one
|
|
263
|
-
* timeout, not N (D-3). Takes instance OBJECTS (`{ port, url }`), never raw
|
|
264
|
-
* registry entries: the caller is responsible for having derived `url` from
|
|
265
|
-
* a validated integer port (T-p5x-01), never from a string read out of a
|
|
266
|
-
* registry file. Returns both the plain results array and a `byPort` Map so
|
|
267
|
-
* callers can look a verdict up without re-scanning.
|
|
268
|
-
*/
|
|
269
|
-
export async function probeAll(
|
|
270
|
-
instances: ProbeInstanceRef[],
|
|
271
|
-
{ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS }: ProbeAllOptions = {}
|
|
272
|
-
): Promise<ProbeAllResult> {
|
|
273
|
-
const results = await Promise.all(
|
|
274
|
-
instances.map((inst) => probeInstance({ url: inst.url, port: inst.port, timeoutMs }))
|
|
275
|
-
);
|
|
276
|
-
const byPort = new Map(results.map((r) => [r.port, r]));
|
|
277
|
-
return { results, byPort };
|
|
278
|
-
}
|
package/vice-sync.ts
DELETED
|
@@ -1,336 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// The project's checkpoint-synchronisation primitives -- the third module in
|
|
3
|
-
// this skill, alongside tools/vice.mjs (the resilient transport seam) and
|
|
4
|
-
// vice-probe.mjs (the deliberately-fragile liveness probe). Those two must
|
|
5
|
-
// never be merged, for the reasons vice-probe.mjs's own header already
|
|
6
|
-
// records; synchronisation is a third such job -- different concern, own
|
|
7
|
-
// module, structurally isolated. Land here cold and know not to fold this
|
|
8
|
-
// into either of them: the seam retries transport failures until a call
|
|
9
|
-
// eventually succeeds, the probe answers one fast yes/no question, and this
|
|
10
|
-
// module decides WHERE in emulated time the machine stops and therefore what
|
|
11
|
-
// bytes become a captured artifact -- a different, load-bearing job from
|
|
12
|
-
// either.
|
|
13
|
-
//
|
|
14
|
-
// Three measurements shaped every function below. Re-verify them against
|
|
15
|
-
// .planning/STATE.md rather than trusting this comment if they ever need
|
|
16
|
-
// re-checking:
|
|
17
|
-
// - `vice_execution_run` is the call the host server dies on -- six
|
|
18
|
-
// outages in one session, the last three all on that call. The resume
|
|
19
|
-
// count is therefore the risk every wait here minimises.
|
|
20
|
-
// - `vice_ping` is the one call measured NON-pausing -- 986,693 cycles/s
|
|
21
|
-
// while ping-polling versus 991,569 fully quiet. Every other
|
|
22
|
-
// state-reading vice_* call pauses the emulator and does not resume it.
|
|
23
|
-
// - The machine is usually ALREADY paused when a checkpoint is armed
|
|
24
|
-
// (every checkpoint stop leaves it paused, and every state read pauses
|
|
25
|
-
// it), which is why every wait keys on the checkpoint's own hit count
|
|
26
|
-
// rather than on whether execution is paused.
|
|
27
|
-
//
|
|
28
|
-
// Three invariants a maintainer must not break:
|
|
29
|
-
// 1. Exactly one resume (`vice_execution_run`) per wait.
|
|
30
|
-
// 2. Never poll on whether execution is paused -- poll on the
|
|
31
|
-
// checkpoint's own `hit_count`.
|
|
32
|
-
// 3. Never delete a checkpoint VICE marked `temporary`.
|
|
33
|
-
//
|
|
34
|
-
// These three invariants are also precisely why 01.6.1-06 cannot cover
|
|
35
|
-
// readCheckpoint()/waitCheckpointHit()/runToCheckpoint()/reset()/
|
|
36
|
-
// screenshot() with a unit test: each is only meaningful against a real
|
|
37
|
-
// emulator's timing (a stub server answering fast and deterministically
|
|
38
|
-
// would prove nothing about a resume count or a hit_count race), and
|
|
39
|
-
// mcp__vice__* is this project's only permitted route to that emulator --
|
|
40
|
-
// a test process cannot open its own connection (CLAUDE.md's hard rule).
|
|
41
|
-
// vice-sync.test.ts records this as five named `todo` entries rather than
|
|
42
|
-
// faking it with a stub. Everything else below (addrNum, hex4, the two
|
|
43
|
-
// timing constants, the armedCheckpoints tracker) is pure or near-pure and
|
|
44
|
-
// IS covered for real.
|
|
45
|
-
//
|
|
46
|
-
// `tryHostPaths` (used by screenshot() below) comes from the sibling
|
|
47
|
-
// `devcontainer-host-path` skill -- and that is not a new dependency this
|
|
48
|
-
// module introduces. This module tree's own resource-deployment path already
|
|
49
|
-
// pulls it in: `vice.mjs` statically imports `repo-root.ts`, which statically
|
|
50
|
-
// imports `install-resources.ts`, which imports
|
|
51
|
-
// `../../skills/devcontainer-host-path/scripts/hostpath.mjs` -- a mandatory
|
|
52
|
-
// edge on every entry into this tree. screenshot() is simply its second
|
|
53
|
-
// consumer. A future maintainer should neither believe this module introduced
|
|
54
|
-
// that edge nor "fix" it by hand-rolling a second path translator.
|
|
55
|
-
//
|
|
56
|
-
// This module's OWN `repo-root.ts` import (below) is new as of 01.6.1-02
|
|
57
|
-
// (RESEARCH §3.4 Option B): hostpath.ts no longer resolves the workspace
|
|
58
|
-
// root itself, so every caller of tryHostPaths()/hostPath() now threads it
|
|
59
|
-
// through explicitly. vice-sync.mjs is NOT a member of the repo-root cycle
|
|
60
|
-
// (repo-root.ts -> install-resources.ts -> hostpath.ts -> repo-root.ts)
|
|
61
|
-
// -- it only calls repoRoot() lazily, inside screenshot(), well after every
|
|
62
|
-
// cycle member has already finished evaluating -- but this import is exactly
|
|
63
|
-
// the kind of fresh route load-order.test.ts's module-scope call-site guard
|
|
64
|
-
// (Part 3, added in 01.6.1-02) exists to police: a future top-level,
|
|
65
|
-
// unguarded `repoRoot()` call added here would rebuild the TDZ hazard by a
|
|
66
|
-
// new path even though the three-module cycle itself is gone.
|
|
67
|
-
import { mkdirSync } from "node:fs";
|
|
68
|
-
import { dirname } from "node:path";
|
|
69
|
-
|
|
70
|
-
import { call } from "./vice.ts";
|
|
71
|
-
import { tryHostPaths } from "./hostpath.ts";
|
|
72
|
-
import { repoRoot } from "./repo-root.ts";
|
|
73
|
-
|
|
74
|
-
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Normalise an address to a number, accepting either a number or a string in
|
|
78
|
-
* "$08B1" / "08B1" / "0x08B1" form. Addresses cross a JSON boundary (the
|
|
79
|
-
* registry stores them as "$08B1" strings for human readability) and a raw
|
|
80
|
-
* hex4() over a string silently produces "$$08B1", which VICE rejects with
|
|
81
|
-
* "invalid hex address" -- so every address entering a vice_* call goes
|
|
82
|
-
* through here first.
|
|
83
|
-
*/
|
|
84
|
-
export function addrNum(a: number | string): number {
|
|
85
|
-
if (typeof a === "number") return a;
|
|
86
|
-
if (typeof a === "string") {
|
|
87
|
-
const s = a.trim().replace(/^\$/, "").replace(/^0x/i, "");
|
|
88
|
-
const n = parseInt(s, 16);
|
|
89
|
-
if (Number.isFinite(n)) return n;
|
|
90
|
-
}
|
|
91
|
-
throw new Error(`addrNum: cannot interpret ${JSON.stringify(a)} as an address`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export const hex4 = (n: number | string): string => `$${addrNum(n).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
95
|
-
|
|
96
|
-
// Each poll cycle is: read state (which PAUSES the machine), resume, then let
|
|
97
|
-
// it run for one window. The window is not idle waiting -- it is the only
|
|
98
|
-
// interval in which the emulated CPU actually advances, so a short window
|
|
99
|
-
// starves the machine and the trigger appears to "never fire". A KERNAL cold
|
|
100
|
-
// boot plus a turbo-loader disk load needs tens of emulated seconds.
|
|
101
|
-
//
|
|
102
|
-
// Progressively longer run windows, in ms. Rationale, and it is not just about
|
|
103
|
-
// speed: a `stop:true` checkpoint halts the machine exactly at the trigger
|
|
104
|
-
// whether we notice 2 seconds later or 30, so POLLING FREQUENCY HAS NO EFFECT
|
|
105
|
-
// ON WHERE THE MACHINE STOPS. Polling rarely is therefore strictly better --
|
|
106
|
-
// identical determinism, an order of magnitude fewer monitor enter/exit
|
|
107
|
-
// transitions. That matters because the host server has dropped its connection
|
|
108
|
-
// five times in one session, always during a monitor transition
|
|
109
|
-
// (`vice_execution_run` or checkpoint work), so transition count is the one
|
|
110
|
-
// risk factor we control. This schedule spans ~150s of emulated running in 8
|
|
111
|
-
// round-trips instead of ~60.
|
|
112
|
-
//
|
|
113
|
-
// Typed as a readonly array (01.6.1-06): a later mutation (push/splice/index
|
|
114
|
-
// assignment) is now a compile error rather than a silent runtime change to
|
|
115
|
-
// a timing contract other code reasons about.
|
|
116
|
-
export const POLL_WINDOWS_MS: readonly number[] = [3000, 6000, 12000, 20000, 25000, 28000, 28000, 28000];
|
|
117
|
-
// How often to ask `vice_ping` whether the machine has stopped yet. Ping is
|
|
118
|
-
// free (it does not pause the machine), so this only costs a round-trip.
|
|
119
|
-
export const PING_INTERVAL_MS: number = 1000;
|
|
120
|
-
|
|
121
|
-
// NOTE: a `waitPaused()` helper used to live here, polling vice_ping until
|
|
122
|
-
// execution reported "paused". It is deliberately DELETED, not kept "just in
|
|
123
|
-
// case". It was wrong in a way that produced a silently-wrong capture point:
|
|
124
|
-
// the machine is normally ALREADY paused when we arm a checkpoint (every
|
|
125
|
-
// checkpoint stop leaves it paused, and every state read pauses it), so the
|
|
126
|
-
// poll returned instantly without any transition having occurred, and the
|
|
127
|
-
// caller then read hit_count 0 and either refused or captured from the wrong
|
|
128
|
-
// place. Wait on the checkpoint's own hit_count instead -- see
|
|
129
|
-
// waitCheckpointHit below. Do not reintroduce a paused-poll.
|
|
130
|
-
|
|
131
|
-
/** The shape of one entry from vice_checkpoint_list/vice_checkpoint_add --
|
|
132
|
-
* loosely typed (`start` accepts either form addrNum() itself accepts,
|
|
133
|
-
* unknown fields pass through) since this module only ever reads
|
|
134
|
-
* checkpoint_num/start/hit_count/temporary off it, never asserts a closed
|
|
135
|
-
* shape. */
|
|
136
|
-
export interface Checkpoint {
|
|
137
|
-
checkpoint_num: number;
|
|
138
|
-
start?: number | string;
|
|
139
|
-
hit_count?: number;
|
|
140
|
-
temporary?: boolean;
|
|
141
|
-
[key: string]: unknown;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Checkpoints this harness itself armed for its own reasons (a boot gate, the
|
|
145
|
-
// dump trigger), tracked here so assertSameMachine()'s checkpoint-fallback
|
|
146
|
-
// probe (D-3) has something to check when no supervisor epoch file exists --
|
|
147
|
-
// the ONLY identity signal available in that case. This costs no NEW
|
|
148
|
-
// checkpoints: arming a sentinel checkpoint purely for identity-probing was
|
|
149
|
-
// rejected because checkpoint work is itself one of the two leading crash
|
|
150
|
-
// suspects recorded in STATE.md's HAZARD CANDIDATE entry. Added on
|
|
151
|
-
// vice_checkpoint_add success, removed on successful vice_checkpoint_delete.
|
|
152
|
-
//
|
|
153
|
-
// A SINGLETON object, not a bare Set and not a factory: there is one machine
|
|
154
|
-
// per process, and both this module's runToCheckpoint() and
|
|
155
|
-
// tools/recover.mjs's capture() -- which deliberately hand-rolls its own
|
|
156
|
-
// arm/wait/delete so it can interleave the identity check and the held-key
|
|
157
|
-
// release between the wait and the delete -- must register ids in the same
|
|
158
|
-
// place. One source of truth, both callers writing through one door.
|
|
159
|
-
const armedCheckpointIds = new Set<number>();
|
|
160
|
-
|
|
161
|
-
export interface ArmedCheckpointTracker {
|
|
162
|
-
track(id: number): void;
|
|
163
|
-
untrack(id: number): void;
|
|
164
|
-
ids(): number[];
|
|
165
|
-
clear(): void;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export const armedCheckpoints: ArmedCheckpointTracker = {
|
|
169
|
-
track(id: number): void {
|
|
170
|
-
armedCheckpointIds.add(id);
|
|
171
|
-
},
|
|
172
|
-
untrack(id: number): void {
|
|
173
|
-
armedCheckpointIds.delete(id);
|
|
174
|
-
},
|
|
175
|
-
ids(): number[] {
|
|
176
|
-
return [...armedCheckpointIds];
|
|
177
|
-
},
|
|
178
|
-
clear(): void {
|
|
179
|
-
armedCheckpointIds.clear();
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Arm an exec checkpoint at `addr`, resume, and wait for the machine to stop
|
|
185
|
-
* ON THAT CHECKPOINT -- verified via its own hit_count, not inferred from the
|
|
186
|
-
* mere fact that execution paused. Returns the checkpoint id so the caller can
|
|
187
|
-
* delete it; leaving stale checkpoints armed would contaminate the next stage.
|
|
188
|
-
*
|
|
189
|
-
* This is the project's one synchronisation primitive. Every wait in this file
|
|
190
|
-
* is a checkpoint hit, never an elapsed duration -- a duration cannot be
|
|
191
|
-
* re-armed, and success criterion 1's byte-identical claim depends on the stop
|
|
192
|
-
* point being re-armable.
|
|
193
|
-
*
|
|
194
|
-
* NOT unit-tested (01.6.1-06): needs a real emulator's vice_checkpoint_list
|
|
195
|
-
* to exercise meaningfully; see vice-sync.test.ts's named todo entry.
|
|
196
|
-
*/
|
|
197
|
-
export async function readCheckpoint(cpId: number | null, addr: number): Promise<Checkpoint | undefined> {
|
|
198
|
-
const { checkpoints } = (await call("vice_checkpoint_list", {})) as { checkpoints: Checkpoint[] };
|
|
199
|
-
return (
|
|
200
|
-
checkpoints.find((c) => c.checkpoint_num === cpId) ||
|
|
201
|
-
checkpoints.find((c) => c.start !== undefined && addrNum(c.start) === addr)
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Wait for a checkpoint using exactly ONE resume.
|
|
207
|
-
*
|
|
208
|
-
* `vice_execution_run` is the call this host server dies on -- six outages in
|
|
209
|
-
* one session, the last three all on that call -- so the resume count is the
|
|
210
|
-
* risk we minimise. The lever is a measurement from the speed trials:
|
|
211
|
-
* `vice_ping` does NOT pause the machine (ping-polling sustained 986,693
|
|
212
|
-
* cycles/s against 991,569 for a completely quiet machine), whereas
|
|
213
|
-
* `vice_checkpoint_list` does. So we can watch progress with ping, for free,
|
|
214
|
-
* and resume only once instead of once per window -- an ~8x cut in the
|
|
215
|
-
* offending call.
|
|
216
|
-
*
|
|
217
|
-
* Order matters and is the fix for an earlier bug: check hit_count BEFORE
|
|
218
|
-
* resuming (the machine is often already stopped on the checkpoint, and blindly
|
|
219
|
-
* resuming would run straight past the dump point), then resume, then wait for
|
|
220
|
-
* `paused`, then CONFIRM via hit_count that the stop was actually this
|
|
221
|
-
* checkpoint rather than something else.
|
|
222
|
-
*
|
|
223
|
-
* NOT unit-tested (01.6.1-06): the exactly-one-resume and poll-on-hit_count
|
|
224
|
-
* invariants only mean something against a real emulator's timing; see
|
|
225
|
-
* vice-sync.test.ts's named todo entry.
|
|
226
|
-
*/
|
|
227
|
-
export async function waitCheckpointHit(cpId: number | null, addr: number, label: string): Promise<Checkpoint> {
|
|
228
|
-
// Already fired? Then we are standing on the trigger -- never resume past it.
|
|
229
|
-
const pre = await readCheckpoint(cpId, addr);
|
|
230
|
-
if (pre && (pre.hit_count ?? 0) >= 1) return pre;
|
|
231
|
-
|
|
232
|
-
await call("vice_execution_run", {}); // the single resume
|
|
233
|
-
const budgetMs = POLL_WINDOWS_MS.reduce((a, b) => a + b, 0);
|
|
234
|
-
const deadline = Date.now() + budgetMs;
|
|
235
|
-
while (Date.now() < deadline) {
|
|
236
|
-
await sleep(PING_INTERVAL_MS);
|
|
237
|
-
const p = (await call("vice_ping", {})) as { execution?: string };
|
|
238
|
-
if (p.execution !== "paused") continue;
|
|
239
|
-
const cp = await readCheckpoint(cpId, addr);
|
|
240
|
-
if (cp && (cp.hit_count ?? 0) >= 1) return cp;
|
|
241
|
-
// Paused for some other reason: resume and keep waiting. Rare, and we
|
|
242
|
-
// deliberately do not treat a bare pause as the trigger.
|
|
243
|
-
await call("vice_execution_run", {});
|
|
244
|
-
}
|
|
245
|
-
// Deadline passed -- one last read before giving up, in case the checkpoint
|
|
246
|
-
// fired between the final ping and now.
|
|
247
|
-
const last = await readCheckpoint(cpId, addr);
|
|
248
|
-
if (last && (last.hit_count ?? 0) >= 1) return last;
|
|
249
|
-
|
|
250
|
-
throw new Error(
|
|
251
|
-
`waitCheckpointHit(${label} ${hex4(addr)}): checkpoint never fired within ${budgetMs / 1000}s. ` +
|
|
252
|
-
`vice_run_until's cycles argument is documented as "not yet implemented" so there is no ` +
|
|
253
|
-
`server-side timeout backing this. Recovery is a HOST-SIDE restart, which this container ` +
|
|
254
|
-
`cannot perform -- run tools/vice-launcher.sh on the HOST; its on-demand broker launches a ` +
|
|
255
|
-
`boot-fresh instance, supervises it, and respawns a crashed one with backoff, logging the ` +
|
|
256
|
-
`crash for the still-open root-cause investigation (see .planning/STATE.md).`
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** NOT unit-tested (01.6.1-06): composes readCheckpoint()/waitCheckpointHit()
|
|
261
|
-
* against a real emulator; see vice-sync.test.ts's named todo entry. */
|
|
262
|
-
export async function runToCheckpoint(addr: number, label: string): Promise<{ id: number | null; hitCount?: number }> {
|
|
263
|
-
const added = (await call("vice_checkpoint_add", { start: hex4(addr), exec: true, stop: true })) as {
|
|
264
|
-
checkpoint_num?: number;
|
|
265
|
-
checkpoint?: { checkpoint_num?: number };
|
|
266
|
-
};
|
|
267
|
-
const id = added.checkpoint_num ?? added.checkpoint?.checkpoint_num ?? null;
|
|
268
|
-
if (id != null) armedCheckpoints.track(id);
|
|
269
|
-
// No resume here: waitCheckpointHit owns the single resume, so that the
|
|
270
|
-
// vice_execution_run count stays at exactly one per wait.
|
|
271
|
-
const cp = await waitCheckpointHit(id, addr, label);
|
|
272
|
-
if (id != null) {
|
|
273
|
-
await call("vice_checkpoint_delete", { checkpoint_num: id });
|
|
274
|
-
armedCheckpoints.untrack(id);
|
|
275
|
-
}
|
|
276
|
-
return { id, hitCount: cp.hit_count };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* The clean-slate ritual, and a step of `recover` -- not an optional
|
|
281
|
-
* courtesy. No bulk-clear checkpoint tool exists, so each returned id is
|
|
282
|
-
* enumerated and deleted individually.
|
|
283
|
-
*
|
|
284
|
-
* NOT unit-tested (01.6.1-06): the never-delete-a-temporary-checkpoint
|
|
285
|
-
* invariant needs a real emulator's own `temporary` checkpoint flag; see
|
|
286
|
-
* vice-sync.test.ts's named todo entry.
|
|
287
|
-
*/
|
|
288
|
-
export async function reset(): Promise<void> {
|
|
289
|
-
// Any checkpoint id tracked from a PRIOR run in this same process (e.g.
|
|
290
|
-
// reproduce()'s second recover() call) is no longer valid once we're about
|
|
291
|
-
// to delete every checkpoint the server knows about -- clear it here so a
|
|
292
|
-
// later assertSameMachine() probe never gets tripped up by a stale id.
|
|
293
|
-
armedCheckpoints.clear();
|
|
294
|
-
const { checkpoints } = (await call("vice_checkpoint_list", {})) as { checkpoints: Checkpoint[] };
|
|
295
|
-
for (const cp of checkpoints) {
|
|
296
|
-
// Never delete a checkpoint VICE marked `temporary`: those are created and
|
|
297
|
-
// auto-reaped by vice_run_until, so by the time we enumerate them the id
|
|
298
|
-
// may already be gone, and deleting a stale id is one of the two leading
|
|
299
|
-
// suspects for the host-server crashes recorded in STATE.md. Leave them to
|
|
300
|
-
// the hard reset, which clears them anyway.
|
|
301
|
-
if (cp.temporary) continue;
|
|
302
|
-
try {
|
|
303
|
-
await call("vice_checkpoint_delete", { checkpoint_num: cp.checkpoint_num });
|
|
304
|
-
} catch (e) {
|
|
305
|
-
console.error(`warn: checkpoint_delete ${cp.checkpoint_num} failed (continuing): ${(e as Error).message}`);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
for (const unit of [8, 9, 10, 11]) {
|
|
309
|
-
try {
|
|
310
|
-
await call("vice_disk_detach", { unit });
|
|
311
|
-
} catch (e) {
|
|
312
|
-
console.error(`warn: disk_detach unit ${unit} failed (continuing): ${(e as Error).message}`);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
await call("vice_machine_reset", { mode: "hard", run_after: false });
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* VICE writes screenshots itself, on the HOST -- so the path handed to
|
|
320
|
-
* vice_display_screenshot must be a host path, exactly like the one handed to
|
|
321
|
-
* vice_disk_attach. Passing the container path silently fails with
|
|
322
|
-
* "Failed to save screenshot".
|
|
323
|
-
*
|
|
324
|
-
* NOT unit-tested (01.6.1-06): needs a real emulator to prove the host-path
|
|
325
|
-
* translation actually lands a screenshot; see vice-sync.test.ts's named
|
|
326
|
-
* todo entry.
|
|
327
|
-
*/
|
|
328
|
-
export async function screenshot(containerPath: string): Promise<string> {
|
|
329
|
-
mkdirSync(dirname(containerPath), { recursive: true });
|
|
330
|
-
const { hostPath } = await tryHostPaths(
|
|
331
|
-
containerPath,
|
|
332
|
-
(p: string) => call("vice_display_screenshot", { path: p }),
|
|
333
|
-
{ workspaceRoot: repoRoot() }
|
|
334
|
-
);
|
|
335
|
-
return hostPath;
|
|
336
|
-
}
|