@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.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. 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
- }