@henols/vice-mcp 0.1.11 → 0.2.0
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/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/disasm-decoder.ts +28 -4
- package/package.json +12 -1
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +431 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +49 -6
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-run-until.ts
|
|
3
|
+
//
|
|
4
|
+
// `vice_run_until` for the stock backend (TIME-02/TIME-03): arms a TEMPORARY,
|
|
5
|
+
// stopping exec checkpoint at the requested address, resumes the machine
|
|
6
|
+
// exactly once, waits event-driven for THAT checkpoint's own CHECKPOINT_INFO,
|
|
7
|
+
// and takes a different, correct cleanup action on each of three paths --
|
|
8
|
+
// hit, timeout, machine-restarted-mid-wait. Implements D-02: an optional,
|
|
9
|
+
// stock-only `timeout_ms` argument defaulting to 30000, the same default
|
|
10
|
+
// VICE_MCP_TIMEOUT_MS already uses, so one number governs both layers.
|
|
11
|
+
//
|
|
12
|
+
// WHY THIS FILE EXISTS: the wedge-triage skill documents `vice_run_until` as
|
|
13
|
+
// having NO working timeout on stock today -- a call against an address that
|
|
14
|
+
// never executes is indistinguishable from a genuine wedge. This module is
|
|
15
|
+
// what bounds that wait and tells the two apart, and is one of the last two
|
|
16
|
+
// skill-called tools missing on the stock backend.
|
|
17
|
+
//
|
|
18
|
+
// WHAT NOT TO DO:
|
|
19
|
+
// - Never wrap the three cleanup paths (hit / timeout / restarted) in one
|
|
20
|
+
// undifferentiated `finally { delete }` -- that is this design space's
|
|
21
|
+
// documented first-draft mistake (Pitfall 4). Each path takes its OWN,
|
|
22
|
+
// distinct action, and only the timeout path ever issues a delete.
|
|
23
|
+
// - Never call registerTraceCheckpoint() here -- that guard exists for
|
|
24
|
+
// `stop:false` trace checkpoints (stock-checkpoints.ts), and the
|
|
25
|
+
// checkpoint this file arms always stops.
|
|
26
|
+
// - Never send a second resume for one wait -- exactly one resume per
|
|
27
|
+
// call, matching vice-sync.ts's own "exactly one resume per wait"
|
|
28
|
+
// invariant, ported here in its stock-native (event-driven, not
|
|
29
|
+
// polling) form.
|
|
30
|
+
// - Never invent a second wire-error converter -- an arming failure goes
|
|
31
|
+
// through convertWireError() directly (the established per-handler
|
|
32
|
+
// convention every sibling family module already follows); a failure
|
|
33
|
+
// surfacing from the resume/wait step is left to propagate uncaught, so
|
|
34
|
+
// the ONE existing converter seam (withStockSession's own
|
|
35
|
+
// convertHandshakeError/convertWireError) produces the answer, not a
|
|
36
|
+
// second one written in this file.
|
|
37
|
+
import {
|
|
38
|
+
CommandType,
|
|
39
|
+
CheckpointOperation,
|
|
40
|
+
checkpointSetBody,
|
|
41
|
+
cpNumBody,
|
|
42
|
+
ErrorCode,
|
|
43
|
+
StockProtocolError,
|
|
44
|
+
type ParsedCheckpointInfoResponse,
|
|
45
|
+
type ResolvedResponse,
|
|
46
|
+
type ViceMonitorClient,
|
|
47
|
+
} from "./stock-protocol.ts";
|
|
48
|
+
import { parseAddress } from "./stock-address.ts";
|
|
49
|
+
import { stockAnswer, isErrorText, convertWireError, type StockSessionHandler } from "./stock-handler.ts";
|
|
50
|
+
import { readProgramCounter } from "./stock-timing.ts";
|
|
51
|
+
import { runStateFor } from "./stock-runstate.ts";
|
|
52
|
+
|
|
53
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
54
|
+
* an array. Matches this module tree's own isPlainObject() convention
|
|
55
|
+
* (vice.ts:310-316); redeclared privately here, not imported, per the
|
|
56
|
+
* established per-module convention (see stock-checkpoints.ts's own copy). */
|
|
57
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describeError(err: unknown): string {
|
|
62
|
+
return err instanceof Error ? err.message : String(err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** D-02: the stock-only `timeout_ms` argument's default, in milliseconds.
|
|
66
|
+
* Matches VICE_MCP_TIMEOUT_MS's own 30000 default (vice.ts) so one number
|
|
67
|
+
* governs both the RPC transport layer and this tool's own wait. */
|
|
68
|
+
export const RUN_UNTIL_DEFAULT_TIMEOUT_MS = 30000;
|
|
69
|
+
|
|
70
|
+
/** A present `timeout_ms` above this ceiling is CLAMPED (not refused) to
|
|
71
|
+
* this value, and the answer carries `timeoutClamped: true` -- the caller
|
|
72
|
+
* asked for a deadline this module will not honour past 10 minutes, but the
|
|
73
|
+
* request itself is not malformed the way a non-finite or non-positive
|
|
74
|
+
* value is. */
|
|
75
|
+
export const RUN_UNTIL_MAX_TIMEOUT_MS = 600000;
|
|
76
|
+
|
|
77
|
+
/** Narrows an emitted `event` item to a CHECKPOINT_INFO event -- checked on
|
|
78
|
+
* the parsed item's own `.type` discriminant, never on response type alone
|
|
79
|
+
* (CHECKPOINT_INFO (0x11) shares a response type with a legitimate command
|
|
80
|
+
* reply). The same predicate shape stock-checkpoints.ts's own
|
|
81
|
+
* isCheckpointInfoEvent() uses, copied rather than imported -- each family
|
|
82
|
+
* module keeps its own private copy, matching this tree's established
|
|
83
|
+
* per-module convention. */
|
|
84
|
+
function isCheckpointInfoEvent(item: unknown): item is ParsedCheckpointInfoResponse {
|
|
85
|
+
return isPlainObject(item) && item.type === "checkpoint_info" && isPlainObject(item.checkpoint);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type WaitOutcome = { status: "hit"; hitCount: number } | { status: "timeout" };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Installs ONE `event` listener narrowed on the parsed event's own `.type`
|
|
92
|
+
* discriminant, THEN the specific checkpoint id, sends the resume exactly
|
|
93
|
+
* once, and races that against a single timeout deadline and the client's
|
|
94
|
+
* own `close` signal. The listener is installed BEFORE the resume is sent,
|
|
95
|
+
* so a checkpoint that fires immediately after cannot be missed in the gap
|
|
96
|
+
* between "sent" and "listening".
|
|
97
|
+
*
|
|
98
|
+
* Removes every listener and clears the timer in a `finally` on EVERY path
|
|
99
|
+
* -- resolve, timeout, and rejection -- so a long session never accumulates
|
|
100
|
+
* listeners (T-07-09).
|
|
101
|
+
*
|
|
102
|
+
* A `close` event mid-wait settles the wait as a timeout rather than
|
|
103
|
+
* sitting until the deadline: there is nothing left to wait ON once the
|
|
104
|
+
* socket is gone, and the caller's own timeout-path cleanup attempt will
|
|
105
|
+
* discover the dead connection on its own delete call rather than this
|
|
106
|
+
* function guessing at it.
|
|
107
|
+
*
|
|
108
|
+
* Any rejection from the resume send itself (a MachineRestartedError, or
|
|
109
|
+
* any other error) propagates OUT of this function uncaught -- see
|
|
110
|
+
* handleRunUntil's own comment, below, on why no delete is attempted for
|
|
111
|
+
* that path.
|
|
112
|
+
*/
|
|
113
|
+
async function waitForCheckpointHit(client: ViceMonitorClient, checkpointId: number, timeoutMs: number): Promise<WaitOutcome> {
|
|
114
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
115
|
+
let onEvent: ((item: unknown) => void) | undefined;
|
|
116
|
+
let onClose: (() => void) | undefined;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return await new Promise<WaitOutcome>((resolve, reject) => {
|
|
120
|
+
onEvent = (item: unknown) => {
|
|
121
|
+
if (!isCheckpointInfoEvent(item)) return;
|
|
122
|
+
if (item.checkpoint.id !== checkpointId) return;
|
|
123
|
+
resolve({ status: "hit", hitCount: item.checkpoint.hitCount });
|
|
124
|
+
};
|
|
125
|
+
onClose = () => {
|
|
126
|
+
resolve({ status: "timeout" });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
client.on("event", onEvent);
|
|
130
|
+
client.on("close", onClose);
|
|
131
|
+
timer = setTimeout(() => resolve({ status: "timeout" }), timeoutMs);
|
|
132
|
+
|
|
133
|
+
// The one resume for this wait -- installed listener above fires
|
|
134
|
+
// BEFORE this send() call so a checkpoint hit racing the reply cannot
|
|
135
|
+
// slip through the gap.
|
|
136
|
+
client.send(CommandType.Exit).catch(reject);
|
|
137
|
+
});
|
|
138
|
+
} finally {
|
|
139
|
+
if (onEvent) client.off("event", onEvent);
|
|
140
|
+
if (onClose) client.off("close", onClose);
|
|
141
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const handleRunUntil: StockSessionHandler = async (args, session, _deps) => {
|
|
146
|
+
if (!isPlainObject(args)) {
|
|
147
|
+
return isErrorText("vice_run_until: arguments must be an object");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// WR-18 (07-REVIEW.md), part 1: refuse unexpected keys BY NAME, exactly as
|
|
151
|
+
// the sibling handler added in this same phase does (handleCyclesStopwatch,
|
|
152
|
+
// stock-timing.ts). Accepting them silently means a typo -- `timeoutMs` for
|
|
153
|
+
// `timeout_ms`, `addr` for `address` -- runs with the DEFAULT bound and
|
|
154
|
+
// reports a confident answer, and the caller has no way to tell.
|
|
155
|
+
const RUN_UNTIL_KEYS = ["address", "cycles", "timeout_ms"];
|
|
156
|
+
const unexpectedKeys = Object.keys(args).filter((key) => !RUN_UNTIL_KEYS.includes(key));
|
|
157
|
+
if (unexpectedKeys.length > 0) {
|
|
158
|
+
return isErrorText(
|
|
159
|
+
`vice_run_until: unexpected argument(s): ${unexpectedKeys.join(", ")} -- this tool takes only ${RUN_UNTIL_KEYS.join(", ")}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// WR-18, part 2: `cycles` is refused WHENEVER it is present, not only when
|
|
164
|
+
// `address` is absent.
|
|
165
|
+
//
|
|
166
|
+
// Before this, the "cycles-only mode not yet implemented" refusal was
|
|
167
|
+
// reachable only on the no-address path, so `{ address: "$c000", cycles: 5000 }`
|
|
168
|
+
// silently DROPPED the cycle bound and answered `reached: true` -- a caller
|
|
169
|
+
// who asked for "run to this address but give up after 5000 cycles" got an
|
|
170
|
+
// unbounded-by-cycles run reported as a success. Refusing is the honest
|
|
171
|
+
// answer: this backend has no cycles-bounded execution at all (TIME-03), and
|
|
172
|
+
// the fork never shipped one either.
|
|
173
|
+
if (args.cycles !== undefined) {
|
|
174
|
+
// The fork's own refusal wording (mcp_tools_debug.c:772), matched
|
|
175
|
+
// verbatim rather than inventing a cycles-bounded execution the fork
|
|
176
|
+
// never shipped -- TIME-03's own requirement. Extended with the
|
|
177
|
+
// address-present case, which the fork's wording does not cover because
|
|
178
|
+
// the fork never silently dropped the bound the way this handler did.
|
|
179
|
+
return isErrorText(
|
|
180
|
+
args.address === undefined
|
|
181
|
+
? "vice_run_until: cycles-only mode not yet implemented; provide an address"
|
|
182
|
+
: "vice_run_until: cycles-only mode not yet implemented; \"cycles\" is not supported alongside \"address\" either -- it would be " +
|
|
183
|
+
"silently ignored, so it is refused rather than dropped. Remove \"cycles\" and bound the wait with \"timeout_ms\" instead.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (args.address === undefined) {
|
|
188
|
+
return isErrorText("vice_run_until: address is required");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let address: number;
|
|
192
|
+
try {
|
|
193
|
+
address = parseAddress(args.address, { what: "vice_run_until address" });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return isErrorText(`vice_run_until: ${describeError(err)}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// D-02: timeout_ms validation. A non-finite, non-numeric, or non-positive
|
|
199
|
+
// value is REFUSED naming the offending value and the valid range -- never
|
|
200
|
+
// silently coerced to 0 (an instant spurious timeout) and never to the
|
|
201
|
+
// default. Fractional values truncate with Math.trunc AFTER the finiteness
|
|
202
|
+
// check, matching clampCpuHistoryCount()'s (stock-connect.ts) own
|
|
203
|
+
// discipline. A value above the ceiling is CLAMPED, not refused, and the
|
|
204
|
+
// answer says so via `timeoutClamped: true`.
|
|
205
|
+
let timeoutMs = RUN_UNTIL_DEFAULT_TIMEOUT_MS;
|
|
206
|
+
let timeoutClamped = false;
|
|
207
|
+
if (args.timeout_ms !== undefined) {
|
|
208
|
+
const raw = args.timeout_ms;
|
|
209
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
210
|
+
return isErrorText(`vice_run_until: timeout_ms must be a finite number of milliseconds, got ${JSON.stringify(raw)}`);
|
|
211
|
+
}
|
|
212
|
+
const truncated = Math.trunc(raw);
|
|
213
|
+
if (truncated <= 0) {
|
|
214
|
+
return isErrorText(
|
|
215
|
+
`vice_run_until: timeout_ms must be > 0, got ${JSON.stringify(raw)} -- expected an integer in 1..${RUN_UNTIL_MAX_TIMEOUT_MS}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (truncated > RUN_UNTIL_MAX_TIMEOUT_MS) {
|
|
219
|
+
timeoutMs = RUN_UNTIL_MAX_TIMEOUT_MS;
|
|
220
|
+
timeoutClamped = true;
|
|
221
|
+
} else {
|
|
222
|
+
timeoutMs = truncated;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Arm a temporary, stopping exec checkpoint at `address`. This is this
|
|
227
|
+
// codebase's first caller in this tree to pass the temporary flag as
|
|
228
|
+
// true: VICE itself auto-deletes a temporary checkpoint the instant it
|
|
229
|
+
// fires (mon_breakpoint.c:605-607), unlike every other caller in this
|
|
230
|
+
// tree (handleCheckpointAdd, stock-checkpoints.ts, always passes it
|
|
231
|
+
// false) -- this divergence is deliberate, not an oversight.
|
|
232
|
+
const body = checkpointSetBody({
|
|
233
|
+
start: address,
|
|
234
|
+
end: address,
|
|
235
|
+
stop: true,
|
|
236
|
+
enabled: true,
|
|
237
|
+
operation: CheckpointOperation.Exec,
|
|
238
|
+
temporary: true,
|
|
239
|
+
memspace: 0x00,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
let response: ResolvedResponse;
|
|
243
|
+
try {
|
|
244
|
+
response = await session.client.send(CommandType.CheckpointSet, body);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return convertWireError("vice_run_until", err);
|
|
247
|
+
}
|
|
248
|
+
if (response.type !== "checkpoint_info") {
|
|
249
|
+
return isErrorText(`vice_run_until: unexpected reply type "${response.type}" from CHECKPOINT_SET`);
|
|
250
|
+
}
|
|
251
|
+
const checkpointId = response.checkpoint.id;
|
|
252
|
+
|
|
253
|
+
// No try/catch around the wait itself: a MachineRestartedError (or any
|
|
254
|
+
// other failure) surfacing from the resume/wait step propagates straight
|
|
255
|
+
// out of this handler, uncaught. Attempting a delete here would be wrong
|
|
256
|
+
// on every one of those failure causes -- when the machine has restarted,
|
|
257
|
+
// the instance and every checkpoint on it are already gone, so there is
|
|
258
|
+
// nothing to clean up, and the standard restarted wording is produced by
|
|
259
|
+
// the one existing convertHandshakeError()/convertWireError() seam
|
|
260
|
+
// (stock-handler.ts / withStockSession), not a second converter written
|
|
261
|
+
// here.
|
|
262
|
+
const outcome = await waitForCheckpointHit(session.client, checkpointId, timeoutMs);
|
|
263
|
+
|
|
264
|
+
if (outcome.status === "hit") {
|
|
265
|
+
// Hit: VICE already deleted the temporary checkpoint itself
|
|
266
|
+
// (mon_breakpoint.c:605-607) -- issuing CHECKPOINT_DELETE here would
|
|
267
|
+
// target an object that no longer exists.
|
|
268
|
+
//
|
|
269
|
+
// machineHalted (07-14/WR-02): the checkpoint that just fired STOPPED
|
|
270
|
+
// the machine (it was armed with stop:true) -- on stock, any inbound
|
|
271
|
+
// byte halts the machine (CLAUDE.md, monitor_binary.c:281) and nothing
|
|
272
|
+
// in this handler resumes it. Emitted unconditionally, never only when
|
|
273
|
+
// true, so an absent field can never be read as "not halted" (the exact
|
|
274
|
+
// ambiguity WR-02 is about).
|
|
275
|
+
const payload: Record<string, unknown> = {
|
|
276
|
+
requested: "run_until",
|
|
277
|
+
reached: true,
|
|
278
|
+
address,
|
|
279
|
+
checkpointId,
|
|
280
|
+
hitCount: outcome.hitCount,
|
|
281
|
+
timeoutMs,
|
|
282
|
+
machineHalted: true,
|
|
283
|
+
machineHaltedNote:
|
|
284
|
+
"the checkpoint at the requested address stopped the emulated machine when it fired, and nothing here resumed it -- " +
|
|
285
|
+
"this is expected, not a wedge. Call vice_execution_run to resume.",
|
|
286
|
+
};
|
|
287
|
+
if (timeoutClamped) payload.timeoutClamped = true;
|
|
288
|
+
return stockAnswer(session.client, payload);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Timeout: the checkpoint never fired within timeoutMs. Delete it exactly
|
|
292
|
+
// once -- ObjectMissing (the hit landed between the deadline firing and
|
|
293
|
+
// this delete) is tolerated as benign; any other wire error is recorded on
|
|
294
|
+
// the answer, never thrown, so the caller still gets a bounded result.
|
|
295
|
+
let cleanup: "deleted" | "already_gone" | "delete_failed" = "deleted";
|
|
296
|
+
let cleanupError: string | undefined;
|
|
297
|
+
try {
|
|
298
|
+
await session.client.send(CommandType.CheckpointDelete, cpNumBody(checkpointId));
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err instanceof StockProtocolError && err.errorCode === ErrorCode.ObjectMissing) {
|
|
301
|
+
cleanup = "already_gone";
|
|
302
|
+
} else {
|
|
303
|
+
cleanup = "delete_failed";
|
|
304
|
+
cleanupError = convertWireError("vice_run_until", err).content[0]!.text;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// machineHalted (07-14/WR-02, corrected by 07-REVIEW.md WR-01).
|
|
309
|
+
//
|
|
310
|
+
// 07-14 hardcoded `true` here for all three cleanup branches, reasoning
|
|
311
|
+
// that the CHECKPOINT_DELETE above is itself an inbound byte and so halted
|
|
312
|
+
// the machine. That holds for "deleted" and "already_gone" -- both mean
|
|
313
|
+
// the delete travelled over the wire and was answered -- but NOT for
|
|
314
|
+
// "delete_failed", which is reachable precisely when the socket is already
|
|
315
|
+
// gone: waitForCheckpointHit()'s own `close` handler settles the wait as
|
|
316
|
+
// `{ status: "timeout" }`, the delete then rejects with
|
|
317
|
+
// StockConnectionClosedError, and a hardcoded `true` claims a halted
|
|
318
|
+
// machine over a dead connection while telling the caller to send
|
|
319
|
+
// vice_execution_run down it. stockAnswer() stamps runState into this same
|
|
320
|
+
// object, so that answer could read {"machineHalted": true, "runState":
|
|
321
|
+
// "running"} -- self-contradictory in one JSON body.
|
|
322
|
+
//
|
|
323
|
+
// So derive it, from the same seam stock-diagnose.ts's deriveMachinePaused()
|
|
324
|
+
// uses, and keep the note honest per branch. This is the rule
|
|
325
|
+
// stock-diagnose.ts:642-656 states normatively: a hand-passed state flag
|
|
326
|
+
// drifts from reality the moment a call site changes. Do not reintroduce a
|
|
327
|
+
// literal here.
|
|
328
|
+
const deleteWasAnswered = cleanup !== "delete_failed";
|
|
329
|
+
const machineHalted = deleteWasAnswered && session.client.connected ? true : runStateFor(session.client) === "stopped";
|
|
330
|
+
const machineHaltedNote = machineHalted
|
|
331
|
+
? "the cleanup CHECKPOINT_DELETE sent after the timeout halted the emulated machine (on stock, any inbound byte does), and " +
|
|
332
|
+
"nothing here resumed it -- this is expected, not a wedge. Call vice_execution_run to resume."
|
|
333
|
+
: "the machine's run state could NOT be established: the cleanup CHECKPOINT_DELETE did not complete (see cleanupError) " +
|
|
334
|
+
"and/or the connection is gone, so nothing here can claim the machine is halted. Call vice_diagnose before acting -- " +
|
|
335
|
+
"in particular do not assume vice_execution_run will reach this instance.";
|
|
336
|
+
|
|
337
|
+
const payload: Record<string, unknown> = {
|
|
338
|
+
requested: "run_until",
|
|
339
|
+
timedOut: true,
|
|
340
|
+
address,
|
|
341
|
+
timeoutMs,
|
|
342
|
+
cleanup,
|
|
343
|
+
machineHalted,
|
|
344
|
+
machineHaltedNote,
|
|
345
|
+
explanation:
|
|
346
|
+
"an address that never executes within the timeout window is, from the caller's side, indistinguishable from " +
|
|
347
|
+
"a genuinely wedged emulator -- see vice-wedge-triage/SKILL.md. This bounded answer means the address itself " +
|
|
348
|
+
"did not execute in time, not that the connection is unresponsive. Whether the machine is now stopped -- and " +
|
|
349
|
+
"therefore whether vice_execution_run is the right next call -- is reported by machineHalted and " +
|
|
350
|
+
"machineHaltedNote; read those rather than assuming either way.",
|
|
351
|
+
};
|
|
352
|
+
if (timeoutClamped) payload.timeoutClamped = true;
|
|
353
|
+
if (cleanupError !== undefined) payload.cleanupError = cleanupError;
|
|
354
|
+
|
|
355
|
+
if (cleanup === "already_gone") {
|
|
356
|
+
// WR-01: an ObjectMissing on this delete means the temporary checkpoint
|
|
357
|
+
// was already gone before the delete arrived -- VICE only does that the
|
|
358
|
+
// instant the checkpoint fires (mon_breakpoint.c:605-607), so the
|
|
359
|
+
// address almost certainly WAS reached, between the deadline expiring
|
|
360
|
+
// and this cleanup delete being sent. Never assert reached:false on
|
|
361
|
+
// this branch; resolve it from the program counter instead, or declare
|
|
362
|
+
// it unresolved -- never fabricate a PC value.
|
|
363
|
+
try {
|
|
364
|
+
const pc = await readProgramCounter(session);
|
|
365
|
+
if (pc === address) {
|
|
366
|
+
payload.reached = true;
|
|
367
|
+
payload.raceResolved = "pc_at_address";
|
|
368
|
+
payload.pcAtCleanup = pc;
|
|
369
|
+
payload.raceNote =
|
|
370
|
+
"the temporary checkpoint fired between the deadline expiring and the cleanup delete being sent -- the program " +
|
|
371
|
+
"counter still at the requested address confirms the address executed.";
|
|
372
|
+
} else {
|
|
373
|
+
payload.reached = false;
|
|
374
|
+
payload.raceResolved = "pc_elsewhere";
|
|
375
|
+
payload.pcAtCleanup = pc;
|
|
376
|
+
payload.raceNote =
|
|
377
|
+
`the temporary checkpoint was already gone before the cleanup delete arrived, but the program counter is at ` +
|
|
378
|
+
`0x${pc.toString(16)}, not the requested 0x${address.toString(16)} -- the race is resolved against a hit.`;
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// The one path where this tool genuinely does not know: never emit
|
|
382
|
+
// `reached` and `reachedUnknown` together, and never emit
|
|
383
|
+
// `reached: false` here -- that would assert a falsehood exactly as
|
|
384
|
+
// confidently as the defect this plan closes.
|
|
385
|
+
payload.reachedUnknown = true;
|
|
386
|
+
payload.raceResolved = "unresolved";
|
|
387
|
+
payload.pcReadError = convertWireError("vice_run_until", err).content[0]!.text;
|
|
388
|
+
payload.raceNote =
|
|
389
|
+
"the temporary checkpoint was already gone before the cleanup delete arrived (it likely fired), but the program " +
|
|
390
|
+
"counter could not be read to confirm it -- read the program counter yourself (vice_registers_get) to settle it.";
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
// "deleted" and "delete_failed": the checkpoint provably still existed
|
|
394
|
+
// at cleanup time (or its state is reported separately via
|
|
395
|
+
// cleanupError), so no race resolution is warranted.
|
|
396
|
+
payload.reached = false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return stockAnswer(session.client, payload);
|
|
400
|
+
};
|
package/stock-runstate.ts
CHANGED
|
@@ -41,6 +41,16 @@ export type RunState = "running" | "stopped" | "unknown";
|
|
|
41
41
|
|
|
42
42
|
export interface RunStateTracker {
|
|
43
43
|
get(): RunState;
|
|
44
|
+
/** WR-04: true once a JAM (0x61) event has been seen on this client's wire,
|
|
45
|
+
* and never reset -- a jam is a latching fact about this instance, not a
|
|
46
|
+
* transient state. Kept SEPARATE from `get()` on purpose: a jam and a
|
|
47
|
+
* plain STOPPED both leave the run state "stopped", but only one of them
|
|
48
|
+
* means the CPU will never execute another instruction, and only one of
|
|
49
|
+
* them is recovered by a reset rather than a recycle. Collapsing the two
|
|
50
|
+
* (which this file used to do) threw the distinction away at its only
|
|
51
|
+
* consumer, after stock-protocol.ts went to the trouble of parsing JAM's
|
|
52
|
+
* zero-length body without fabricating a PC. */
|
|
53
|
+
jamObserved(): boolean;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
let trackers = new WeakMap<ViceMonitorClient, RunStateTracker>();
|
|
@@ -68,12 +78,23 @@ export function attachRunStateTracker(client: ViceMonitorClient): RunStateTracke
|
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
let state: RunState = "unknown";
|
|
81
|
+
// WR-04: latched, never cleared -- not even by a subsequent RESUMED. A CPU
|
|
82
|
+
// that has jammed stays a machine whose evidence must mention the jam; the
|
|
83
|
+
// recovery is vice_machine_reset, and nothing on this wire can undo the
|
|
84
|
+
// fact that a jam happened on this instance.
|
|
85
|
+
let jamSeen = false;
|
|
71
86
|
|
|
72
87
|
client.on("event", (item: ParsedResponse | StockProtocolError | StockFramingError) => {
|
|
73
88
|
if (!hasParsedType(item)) {
|
|
74
89
|
return;
|
|
75
90
|
}
|
|
76
|
-
if (item.type === "
|
|
91
|
+
if (item.type === "jam") {
|
|
92
|
+
// A jam halts the CPU, so the run state is "stopped" as before -- but
|
|
93
|
+
// the jam itself is recorded separately rather than being collapsed
|
|
94
|
+
// into it and lost.
|
|
95
|
+
jamSeen = true;
|
|
96
|
+
state = "stopped";
|
|
97
|
+
} else if (item.type === "stopped") {
|
|
77
98
|
state = "stopped";
|
|
78
99
|
} else if (item.type === "resumed") {
|
|
79
100
|
state = "running";
|
|
@@ -82,7 +103,7 @@ export function attachRunStateTracker(client: ViceMonitorClient): RunStateTracke
|
|
|
82
103
|
// state untouched.
|
|
83
104
|
});
|
|
84
105
|
|
|
85
|
-
const tracker: RunStateTracker = { get: () => state };
|
|
106
|
+
const tracker: RunStateTracker = { get: () => state, jamObserved: () => jamSeen };
|
|
86
107
|
trackers.set(client, tracker);
|
|
87
108
|
return tracker;
|
|
88
109
|
}
|
|
@@ -96,6 +117,29 @@ export function runStateFor(client: ViceMonitorClient): RunState {
|
|
|
96
117
|
return tracker ? tracker.get() : "unknown";
|
|
97
118
|
}
|
|
98
119
|
|
|
120
|
+
/** WR-04: whether a JAM (0x61) has been observed on `client`'s wire -- `false`
|
|
121
|
+
* when nothing is attached, never a throw, same contract as runStateFor().
|
|
122
|
+
*
|
|
123
|
+
* WHY THIS IS SEPARATE FROM runStateFor(): the two jamaction settings produce
|
|
124
|
+
* opposite-looking symptoms from the same underlying dead CPU, and neither is
|
|
125
|
+
* distinguishable from the run state alone.
|
|
126
|
+
* - `-jamaction 2` (Monitor): the machine stops, both liveness brackets
|
|
127
|
+
* read zero advance, and vice_diagnose answers `wedged` -- whose
|
|
128
|
+
* documented response is `vice_recycle`, i.e. destroy the instance, when
|
|
129
|
+
* a vice_machine_reset recovers a jam. Same shape as the
|
|
130
|
+
* `checkpoint_trap` hazard the wedge-triage SKILL already warns about.
|
|
131
|
+
* - default jamaction (continue): the emulator keeps burning cycles
|
|
132
|
+
* refetching the same opcode, so BOTH brackets advance and vice_diagnose
|
|
133
|
+
* answers `live` -- for a machine that will never execute another
|
|
134
|
+
* instruction.
|
|
135
|
+
* The JAM frame that settles it arrived on the wire in both cases. This is
|
|
136
|
+
* how it stops being discarded. It is reported as EVIDENCE on the existing
|
|
137
|
+
* verdicts, never as a sixth verdict (D-03). */
|
|
138
|
+
export function jamObservedFor(client: ViceMonitorClient): boolean {
|
|
139
|
+
const tracker = trackers.get(client);
|
|
140
|
+
return tracker ? tracker.jamObserved() : false;
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
/** Test-only: replaces the module-level WeakMap with a fresh one, matching
|
|
100
144
|
* clearHeldStockSession()'s role in stock-dispatch.test.ts's beforeEach()
|
|
101
145
|
* convention. */
|