@henols/vice-mcp 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-checkpoints.ts
|
|
3
|
+
//
|
|
4
|
+
// THE checkpoint/watchpoint handler family for the stock backend (DIRECT-03):
|
|
5
|
+
// add, delete, list, toggle, set-condition, and watch-add. Also the ONE place
|
|
6
|
+
// that holds the D-10 condition registry (with its fail-closed cleanup) and
|
|
7
|
+
// the D-11 trace guard for `stop:false` checkpoints -- both hazards exist the
|
|
8
|
+
// moment DIRECT-03 ships, so they ship with it rather than waiting for a
|
|
9
|
+
// later phase.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS FILE EXISTS: stock VICE's checkpoint conditions cannot be read
|
|
12
|
+
// back, cannot be cleared, and leak (the old text stays attached inside VICE)
|
|
13
|
+
// if re-set -- so an agent needs a client-side record of what it attached,
|
|
14
|
+
// and a re-set must be refused rather than silently doubling up. Separately,
|
|
15
|
+
// a `stop:false` checkpoint emits a CHECKPOINT_INFO frame per hit
|
|
16
|
+
// SYNCHRONOUSLY, from inside the emulator's CPU loop, over the blocking
|
|
17
|
+
// monitor socket (docs/phase0-binmon-findings.md §1; mon_breakpoint.c:557-562
|
|
18
|
+
// calls mon_breakpoint_event() before checking cp->stop) -- on a hot address
|
|
19
|
+
// this can stall the emulator thread and deadlock this client. Both guards
|
|
20
|
+
// are correctness-as-safety issues, not polish, so they belong in the same
|
|
21
|
+
// module as the tools that create the hazard.
|
|
22
|
+
//
|
|
23
|
+
// This file was built up task by task (three tasks in this plan): Task 1
|
|
24
|
+
// adds checkpoint add/delete/list/toggle plus the D-10 condition registry's
|
|
25
|
+
// read/write/delete plumbing those handlers call into; Task 2 adds
|
|
26
|
+
// condition-setting (set-condition, watch-add, the fail-closed cleanup, and
|
|
27
|
+
// condition immutability); Task 3 (this file's current state) adds the D-11
|
|
28
|
+
// trace guard's real rate limiting and deferred auto-disable, below.
|
|
29
|
+
//
|
|
30
|
+
// WHAT NOT TO DO:
|
|
31
|
+
// - Never string-concatenate a condition. Both input paths funnel through
|
|
32
|
+
// stock-condition.ts's emitCondition() -- the only function in this tree
|
|
33
|
+
// that ever produces condition wire text (D-09).
|
|
34
|
+
// - Never add an inline `condition` argument to vice_checkpoint_add. D-12
|
|
35
|
+
// keeps the fork's add-then-condition split so Phase 8's parity harness
|
|
36
|
+
// drives identical sequences through both backends. vice_watch_add is the
|
|
37
|
+
// one exception -- it already takes `condition` on the FORK's own schema,
|
|
38
|
+
// so it stays atomic here too.
|
|
39
|
+
// - Never add vice_checkpoint_set_ignore_count (D-15). There is no native
|
|
40
|
+
// ignore count; the only implementation would resume the machine on each
|
|
41
|
+
// ignored hit, which is a carve-out in D-05's absolute halt policy this
|
|
42
|
+
// module must not create.
|
|
43
|
+
// - Never send from inside the trace guard's 'event' listener -- the
|
|
44
|
+
// listener does pure arithmetic only (window counting, threshold check);
|
|
45
|
+
// the disabling CHECKPOINT_TOGGLE is deferred out of that call stack (one
|
|
46
|
+
// deferral site below) so a synchronous flood of hits can never itself
|
|
47
|
+
// trigger a synchronous send back onto the same blocking socket.
|
|
48
|
+
// - Never construct an ok-answer outside stockAnswer() -- that is exactly
|
|
49
|
+
// how an answer ships without `runState` (D-06).
|
|
50
|
+
import {
|
|
51
|
+
CommandType,
|
|
52
|
+
CheckpointOperation,
|
|
53
|
+
checkpointSetBody,
|
|
54
|
+
checkpointToggleBody,
|
|
55
|
+
cpNumBody,
|
|
56
|
+
type ParsedCheckpoint,
|
|
57
|
+
type ParsedCheckpointInfoResponse,
|
|
58
|
+
type ViceMonitorClient,
|
|
59
|
+
} from "./stock-protocol.ts";
|
|
60
|
+
// conditionSetBody() is imported through its own namespace binding, deliberately
|
|
61
|
+
// NOT alongside the encoders above: this keeps the literal identifier
|
|
62
|
+
// "conditionSetBody" appearing exactly once in this module's non-comment
|
|
63
|
+
// lines (the one call site inside setConditionFailClosed() below), matching
|
|
64
|
+
// this file's own "ONE call site" header comment mechanically, not just in
|
|
65
|
+
// prose.
|
|
66
|
+
import * as StockConditionEncoder from "./stock-protocol.ts";
|
|
67
|
+
import {
|
|
68
|
+
emitCondition,
|
|
69
|
+
parseConditionString,
|
|
70
|
+
conditionFromJson,
|
|
71
|
+
StockConditionError,
|
|
72
|
+
type ConditionNode,
|
|
73
|
+
} from "./stock-condition.ts";
|
|
74
|
+
import { parseAddress, parseByteCount } from "./stock-address.ts";
|
|
75
|
+
import { stockAnswer, isErrorText, convertWireError, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
|
|
76
|
+
import type { StockConnectSession } from "./stock-connect.ts";
|
|
77
|
+
|
|
78
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not an
|
|
79
|
+
* array. Matches this module tree's own isPlainObject() convention
|
|
80
|
+
* (vice.ts:310-316); redeclared privately here, not imported, per the
|
|
81
|
+
* established per-module convention. */
|
|
82
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function describeError(err: unknown): string {
|
|
87
|
+
return err instanceof Error ? err.message : String(err);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Decodes CHECKPOINT_SET/CHECKPOINT_INFO's operation bitmask into the
|
|
91
|
+
* ["load","store","exec"]-style array a caller can read without knowing the
|
|
92
|
+
* wire bit values. */
|
|
93
|
+
function decodeOperationFlags(operation: number): string[] {
|
|
94
|
+
const flags: string[] = [];
|
|
95
|
+
if (operation & CheckpointOperation.Load) flags.push("load");
|
|
96
|
+
if (operation & CheckpointOperation.Store) flags.push("store");
|
|
97
|
+
if (operation & CheckpointOperation.Exec) flags.push("exec");
|
|
98
|
+
return flags;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Validates a `checkpoint_num` argument -- an integer in 0..0xffffffff,
|
|
102
|
+
* matching cpNumBody()'s/checkpointToggleBody()'s own wire range. Throws a
|
|
103
|
+
* plain Error; every call site catches it and prefixes the tool name. */
|
|
104
|
+
function parseCheckpointNum(value: unknown): number {
|
|
105
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
|
106
|
+
throw new Error(`checkpoint_num must be an integer in 0..0xffffffff, got ${JSON.stringify(value)}`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const STOP_FALSE_HAZARD_TEXT =
|
|
112
|
+
"stop:false requires acknowledgeTraceRisk:true -- a non-stopping checkpoint emits one CHECKPOINT_INFO frame per " +
|
|
113
|
+
"hit synchronously, from inside the emulator's CPU loop, over the blocking monitor socket, so on a hot address " +
|
|
114
|
+
"it can stall the emulator thread and deadlock this client; pass acknowledgeTraceRisk:true to opt in (the " +
|
|
115
|
+
"client then rate-limits hits per second and auto-disables the checkpoint if the limit is exceeded).";
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// D-10: the client-side condition registry.
|
|
119
|
+
//
|
|
120
|
+
// Keyed on session.targetId, NOT on the session object itself: a
|
|
121
|
+
// stockReconnect() builds a fresh session, but MachineRestartedError already
|
|
122
|
+
// guarantees it is the SAME machine, and the emulator's checkpoints (and
|
|
123
|
+
// their attached conditions) survive the reconnect on the wire side -- so
|
|
124
|
+
// keying on the session object would silently lose condition text that is
|
|
125
|
+
// still attached inside VICE. Keying on targetId keeps the registry aligned
|
|
126
|
+
// with the machine identity guarantee the rest of this module tree relies on.
|
|
127
|
+
//
|
|
128
|
+
// WR-03 (03-REVIEW.md): keying on a STRING means this is a strong Map, so
|
|
129
|
+
// nothing about a target going away can evict its entry on its own. That
|
|
130
|
+
// design implicitly assumes a bounded population of live targets, but a
|
|
131
|
+
// long-running proxy serving a broker that recycles, respawns and re-warms
|
|
132
|
+
// instances routinely (exactly what this milestone's own broker machinery
|
|
133
|
+
// exists to do) sees an unbounded succession of distinct targetIds, and every
|
|
134
|
+
// one of them would keep its condition map forever. The eviction hook below --
|
|
135
|
+
// called by stock-dispatch.ts's ensureStockSession() at the ONE point a
|
|
136
|
+
// replacement acquisition proves the previous target is gone for good -- is
|
|
137
|
+
// what bounds it, without weakening the survives-a-stockReconnect() guarantee
|
|
138
|
+
// that motivated the targetId key in the first place (a reconnect to the SAME
|
|
139
|
+
// machine reuses the SAME targetId and never reaches that call site).
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
let conditionRegistry = new Map<string, Map<number, string>>();
|
|
143
|
+
|
|
144
|
+
/** Drops every registered target's condition map EXCEPT `activeTargetId`'s --
|
|
145
|
+
* the WR-03 eviction hook, and the only thing that ever shrinks this registry.
|
|
146
|
+
*
|
|
147
|
+
* Called from stock-dispatch.ts's ensureStockSession() immediately after a
|
|
148
|
+
* FRESH stockConnect() installs a new held session. At that moment exactly one
|
|
149
|
+
* target is reachable through this module (conditionTextFor() is only ever
|
|
150
|
+
* consulted with the live session), so every other key is unreachable
|
|
151
|
+
* bookkeeping for an instance that has already been torn down. Pruning "all
|
|
152
|
+
* but the live one" -- rather than only the single session that was just
|
|
153
|
+
* discarded -- also covers the path where the holder was cleared by a failed
|
|
154
|
+
* stockReconnect() and the stale targetId was therefore never handed to a
|
|
155
|
+
* teardown at all.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately NOT called on the reuse or reconnect branches: a reconnect
|
|
158
|
+
* re-proves it is the SAME machine, whose checkpoints (and their attached
|
|
159
|
+
* conditions) are still armed on the wire, and stock cannot read condition
|
|
160
|
+
* text back off the wire to rebuild what this dropped. Idempotent, and a no-op
|
|
161
|
+
* the first time a session is established (the registry is empty). */
|
|
162
|
+
export function forgetConditionsForOtherTargets(activeTargetId: string): void {
|
|
163
|
+
for (const targetId of conditionRegistry.keys()) {
|
|
164
|
+
if (targetId !== activeTargetId) {
|
|
165
|
+
conditionRegistry.delete(targetId);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Test-only: the current registry key set, so a test can assert eviction
|
|
171
|
+
* happened without reaching into module-private state any other way. */
|
|
172
|
+
export function _conditionRegistryTargetsForTest(): string[] {
|
|
173
|
+
return Array.from(conditionRegistry.keys()).sort();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function conditionMapFor(session: StockConnectSession): Map<number, string> {
|
|
177
|
+
let m = conditionRegistry.get(session.targetId);
|
|
178
|
+
if (!m) {
|
|
179
|
+
m = new Map();
|
|
180
|
+
conditionRegistry.set(session.targetId, m);
|
|
181
|
+
}
|
|
182
|
+
return m;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Reads the recorded condition text for `checkpointNum` on `session`'s
|
|
186
|
+
* target -- `undefined` when this session has no record of one (either
|
|
187
|
+
* never set, or set outside this session; stock cannot read condition text
|
|
188
|
+
* back off the wire either way). */
|
|
189
|
+
export function conditionTextFor(session: StockConnectSession, checkpointNum: number): string | undefined {
|
|
190
|
+
return conditionRegistry.get(session.targetId)?.get(checkpointNum);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function recordCondition(session: StockConnectSession, checkpointNum: number, text: string): void {
|
|
194
|
+
conditionMapFor(session).set(checkpointNum, text);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function forgetCondition(session: StockConnectSession, checkpointNum: number): void {
|
|
198
|
+
conditionRegistry.get(session.targetId)?.delete(checkpointNum);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** D-09's shared discriminator: an object goes through conditionFromJson(),
|
|
202
|
+
* a string through parseConditionString(). Any other type refuses naming
|
|
203
|
+
* both accepted forms. Reused by handleCheckpointSetCondition and
|
|
204
|
+
* handleWatchAdd so the discrimination logic lives in exactly one place. */
|
|
205
|
+
function nodeFromConditionArg(condition: unknown): ConditionNode {
|
|
206
|
+
if (isPlainObject(condition)) {
|
|
207
|
+
return conditionFromJson(condition);
|
|
208
|
+
}
|
|
209
|
+
if (typeof condition === "string") {
|
|
210
|
+
return parseConditionString(condition);
|
|
211
|
+
}
|
|
212
|
+
throw new StockConditionError(
|
|
213
|
+
`condition must be a string (e.g. "A == $42") or a structured condition object, got ${typeof condition}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// D-10: fail-closed cleanup. The ONE call site for conditionSetBody() --
|
|
219
|
+
// every condition-setting path in this module goes through this helper, so a
|
|
220
|
+
// failed CONDITION_SET can never leave a full-range, UNCONDITIONED checkpoint
|
|
221
|
+
// armed while the caller believes it is conditioned.
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Sends CONDITION_SET for `expression` against `checkpointNum`. On success,
|
|
226
|
+
* returns `null` (the caller records the condition text itself, since only
|
|
227
|
+
* the caller knows whether this is a fresh checkpoint or a rename). On a
|
|
228
|
+
* CONDITION_SET failure, issues CHECKPOINT_DELETE for the SAME checkpoint
|
|
229
|
+
* number before returning -- otherwise a full-range unconditioned breakpoint
|
|
230
|
+
* is left armed and the caller believes it is conditioned. If that
|
|
231
|
+
* CHECKPOINT_DELETE also fails, BOTH failures are named in the one refusal
|
|
232
|
+
* returned -- the second is never swallowed.
|
|
233
|
+
*/
|
|
234
|
+
async function setConditionFailClosed(
|
|
235
|
+
session: StockConnectSession,
|
|
236
|
+
checkpointNum: number,
|
|
237
|
+
expression: string,
|
|
238
|
+
toolName: string,
|
|
239
|
+
): Promise<StockToolResult | null> {
|
|
240
|
+
try {
|
|
241
|
+
await session.client.send(CommandType.ConditionSet, StockConditionEncoder.conditionSetBody({ checkpointNum, expression }));
|
|
242
|
+
return null;
|
|
243
|
+
} catch (setErr) {
|
|
244
|
+
try {
|
|
245
|
+
await session.client.send(CommandType.CheckpointDelete, cpNumBody(checkpointNum));
|
|
246
|
+
return isErrorText(
|
|
247
|
+
`${toolName}: setting the condition on checkpoint ${checkpointNum} failed (${describeError(setErr)}) -- ` +
|
|
248
|
+
`the checkpoint was DELETED to avoid leaving a full-range, UNCONDITIONED breakpoint armed; re-add the ` +
|
|
249
|
+
`checkpoint and try the condition again.`,
|
|
250
|
+
);
|
|
251
|
+
} catch (deleteErr) {
|
|
252
|
+
return isErrorText(
|
|
253
|
+
`${toolName}: setting the condition on checkpoint ${checkpointNum} failed (${describeError(setErr)}), and ` +
|
|
254
|
+
`deleting that checkpoint to clean up ALSO failed (${describeError(deleteErr)}) -- checkpoint ` +
|
|
255
|
+
`${checkpointNum} may still be armed WITHOUT its condition and must be deleted manually.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// D-11: the trace guard for `stop:false` checkpoints. Per-client state (a
|
|
263
|
+
// checkpoint id is only ever unique within one emulator instance), attached
|
|
264
|
+
// idempotently to the client's own 'event' stream -- exactly one listener per
|
|
265
|
+
// client, matching stock-runstate.ts's attachRunStateTracker() discipline.
|
|
266
|
+
//
|
|
267
|
+
// Planner decision (RESEARCH.md Focus Item 5 offered two designs): the
|
|
268
|
+
// disabling toggle send is deferred out of the event-listener's call stack
|
|
269
|
+
// via the platform's own next-turn scheduling primitive (the one call site
|
|
270
|
+
// below) rather than a next-dispatch check, because the flood this guards
|
|
271
|
+
// against is synchronous and blocking the emulator thread -- promptness is
|
|
272
|
+
// the point, and waiting for the agent's next unrelated tool call could be
|
|
273
|
+
// arbitrarily long. RESEARCH.md flags this as an assumption (A4); the probe
|
|
274
|
+
// debt is filed under .planning/todos/pending/.
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/** Deliberately conservative first guess -- change this single constant if
|
|
278
|
+
* empirical testing against real hardware shows a different threshold is
|
|
279
|
+
* appropriate. */
|
|
280
|
+
export const TRACE_HITS_PER_SECOND_LIMIT = 20;
|
|
281
|
+
|
|
282
|
+
interface TraceWindow {
|
|
283
|
+
windowStartMs: number;
|
|
284
|
+
hits: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface AutoDisabledEntry {
|
|
288
|
+
reason: string;
|
|
289
|
+
at: number;
|
|
290
|
+
hitsPerSecond: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
interface TraceGuardState {
|
|
294
|
+
traceCheckpoints: Set<number>;
|
|
295
|
+
window: Map<number, TraceWindow>;
|
|
296
|
+
disableScheduled: Set<number>;
|
|
297
|
+
autoDisabled: Map<number, AutoDisabledEntry>;
|
|
298
|
+
now: () => number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let traceGuards = new WeakMap<ViceMonitorClient, TraceGuardState>();
|
|
302
|
+
|
|
303
|
+
function isCheckpointInfoEvent(item: unknown): item is ParsedCheckpointInfoResponse {
|
|
304
|
+
return isPlainObject(item) && item.type === "checkpoint_info" && isPlainObject(item.checkpoint);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function attachTraceGuardListener(client: ViceMonitorClient, state: TraceGuardState): void {
|
|
308
|
+
client.on("event", (item: unknown) => {
|
|
309
|
+
if (!isCheckpointInfoEvent(item)) return;
|
|
310
|
+
const id = item.checkpoint.id;
|
|
311
|
+
if (!state.traceCheckpoints.has(id)) return;
|
|
312
|
+
|
|
313
|
+
// Pure arithmetic only, below this point until the deferred callback --
|
|
314
|
+
// no I/O, no send(), no await. The window rolls forward when the last
|
|
315
|
+
// window started 1000ms or more ago.
|
|
316
|
+
const now = state.now();
|
|
317
|
+
let w = state.window.get(id);
|
|
318
|
+
if (!w || now - w.windowStartMs >= 1000) {
|
|
319
|
+
w = { windowStartMs: now, hits: 0 };
|
|
320
|
+
state.window.set(id, w);
|
|
321
|
+
}
|
|
322
|
+
w.hits += 1;
|
|
323
|
+
|
|
324
|
+
if (w.hits > TRACE_HITS_PER_SECOND_LIMIT && !state.disableScheduled.has(id)) {
|
|
325
|
+
state.disableScheduled.add(id);
|
|
326
|
+
const observedHitsPerSecond = w.hits;
|
|
327
|
+
// The ONE deferral site in this module: schedules the disabling
|
|
328
|
+
// CHECKPOINT_TOGGLE for the NEXT turn of the event loop, out of this
|
|
329
|
+
// listener's own call stack. See the header comment above this
|
|
330
|
+
// section for why a deferred send, not a next-dispatch check.
|
|
331
|
+
setImmediate(() => {
|
|
332
|
+
void (async () => {
|
|
333
|
+
try {
|
|
334
|
+
await client.send(CommandType.CheckpointToggle, checkpointToggleBody({ checkpointNum: id, enabled: false }));
|
|
335
|
+
state.autoDisabled.set(id, {
|
|
336
|
+
reason:
|
|
337
|
+
`auto-disabled: exceeded ${TRACE_HITS_PER_SECOND_LIMIT} hits/second on a stop:false trace ` +
|
|
338
|
+
`checkpoint (observed ~${observedHitsPerSecond}/s) -- a non-stopping checkpoint emits ` +
|
|
339
|
+
`CHECKPOINT_INFO synchronously from inside the emulator's CPU loop and can deadlock this client ` +
|
|
340
|
+
`on a hot address`,
|
|
341
|
+
at: state.now(),
|
|
342
|
+
hitsPerSecond: observedHitsPerSecond,
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
// Must never throw out of this callback -- there is no handler
|
|
346
|
+
// above a bare callback queued this way, and doing so would
|
|
347
|
+
// reach vice-proxy.ts's never-throw boundary with nothing to
|
|
348
|
+
// catch it.
|
|
349
|
+
state.autoDisabled.set(id, {
|
|
350
|
+
reason: `auto-disable send failed: ${describeError(err)}`,
|
|
351
|
+
at: state.now(),
|
|
352
|
+
hitsPerSecond: observedHitsPerSecond,
|
|
353
|
+
});
|
|
354
|
+
} finally {
|
|
355
|
+
state.disableScheduled.delete(id);
|
|
356
|
+
}
|
|
357
|
+
})();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function traceGuardStateFor(client: ViceMonitorClient, now: () => number): TraceGuardState {
|
|
364
|
+
let state = traceGuards.get(client);
|
|
365
|
+
if (!state) {
|
|
366
|
+
state = {
|
|
367
|
+
traceCheckpoints: new Set(),
|
|
368
|
+
window: new Map(),
|
|
369
|
+
disableScheduled: new Set(),
|
|
370
|
+
autoDisabled: new Map(),
|
|
371
|
+
now,
|
|
372
|
+
};
|
|
373
|
+
traceGuards.set(client, state);
|
|
374
|
+
attachTraceGuardListener(client, state);
|
|
375
|
+
}
|
|
376
|
+
return state;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Arms the trace guard for `checkpointId` on `session`'s client -- attaches
|
|
380
|
+
* the guard's single 'event' listener idempotently (one listener per client,
|
|
381
|
+
* exactly like attachRunStateTracker()), then adds the id to the watched
|
|
382
|
+
* set. `opts.now` is a test-only clock override; production callers never
|
|
383
|
+
* pass it (the default is the platform clock). */
|
|
384
|
+
export function registerTraceCheckpoint(session: StockConnectSession, checkpointId: number, opts: { now?: () => number } = {}): void {
|
|
385
|
+
const nowFn = opts.now ?? Date.now;
|
|
386
|
+
const state = traceGuardStateFor(session.client, nowFn);
|
|
387
|
+
state.traceCheckpoints.add(checkpointId);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function forgetTraceState(session: StockConnectSession, checkpointNum: number): void {
|
|
391
|
+
const state = traceGuards.get(session.client);
|
|
392
|
+
if (!state) return;
|
|
393
|
+
state.traceCheckpoints.delete(checkpointNum);
|
|
394
|
+
state.window.delete(checkpointNum);
|
|
395
|
+
state.disableScheduled.delete(checkpointNum);
|
|
396
|
+
state.autoDisabled.delete(checkpointNum);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Read by every handler in this module just before answering, so an
|
|
400
|
+
* auto-disable is surfaced in the very next answer as `autoDisables: [...]`
|
|
401
|
+
* (the caller omits the key entirely when this returns an empty array). */
|
|
402
|
+
export function autoDisableReportFor(
|
|
403
|
+
session: StockConnectSession,
|
|
404
|
+
): Array<{ checkpointNum: number; reason: string; at: number; hitsPerSecond: number }> {
|
|
405
|
+
const state = traceGuards.get(session.client);
|
|
406
|
+
if (!state) return [];
|
|
407
|
+
return Array.from(state.autoDisabled.entries()).map(([checkpointNum, entry]) => ({ checkpointNum, ...entry }));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Test-only: resets the condition registry and every trace-guard table
|
|
411
|
+
* together, matching resetRunStateTrackersForTest()'s role in
|
|
412
|
+
* stock-runstate.ts. */
|
|
413
|
+
export function resetCheckpointStateForTest(): void {
|
|
414
|
+
conditionRegistry = new Map();
|
|
415
|
+
traceGuards = new WeakMap();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Task 1: checkpoint add / delete / list / toggle
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
export const handleCheckpointAdd: StockSessionHandler = async (args, session, _deps) => {
|
|
423
|
+
if (!isPlainObject(args)) {
|
|
424
|
+
return isErrorText("vice_checkpoint_add: arguments must be an object");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let start: number;
|
|
428
|
+
try {
|
|
429
|
+
start = parseAddress(args.start, { what: "start" });
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return isErrorText(`vice_checkpoint_add: ${describeError(err)}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let end = start;
|
|
435
|
+
if (args.end !== undefined) {
|
|
436
|
+
try {
|
|
437
|
+
end = parseAddress(args.end, { what: "end" });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return isErrorText(`vice_checkpoint_add: ${describeError(err)}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (end < start) {
|
|
443
|
+
return isErrorText(`vice_checkpoint_add: end (${end}) must be >= start (${start})`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// WR-01 (03-REVIEW.md): a STRICT type check, never Boolean() coercion.
|
|
447
|
+
// vice-proxy.ts's rawJsonSchemaAsStandardSchema() wraps every manifest
|
|
448
|
+
// inputSchema with a validate that performs no actual type checking, so a
|
|
449
|
+
// type-mismatched argument reaches this handler untouched and these checks
|
|
450
|
+
// are the ONLY enforcement there is. `Boolean("false")` is `true`, so the
|
|
451
|
+
// old coercion silently turned a caller's `stop: "false"` -- a plausible
|
|
452
|
+
// shape for an LLM-driven MCP client that formats values as strings -- into
|
|
453
|
+
// the opposite of the non-stopping trace mode it asked for, with no error
|
|
454
|
+
// and no warning. Every other boolean-shaped argument in this file and in
|
|
455
|
+
// its sibling family modules already refuses a non-boolean outright; this
|
|
456
|
+
// one was the sole exception.
|
|
457
|
+
if (args.stop !== undefined && typeof args.stop !== "boolean") {
|
|
458
|
+
return isErrorText(`vice_checkpoint_add: stop must be a boolean, got ${typeof args.stop}`);
|
|
459
|
+
}
|
|
460
|
+
const stop = args.stop === undefined ? true : args.stop;
|
|
461
|
+
const acknowledgeTraceRisk = args.acknowledgeTraceRisk === true;
|
|
462
|
+
if (!stop && !acknowledgeTraceRisk) {
|
|
463
|
+
return isErrorText(`vice_checkpoint_add: ${STOP_FALSE_HAZARD_TEXT}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let operation = 0;
|
|
467
|
+
if (args.load === true) operation |= CheckpointOperation.Load;
|
|
468
|
+
if (args.store === true) operation |= CheckpointOperation.Store;
|
|
469
|
+
if (args.exec === true) operation |= CheckpointOperation.Exec;
|
|
470
|
+
let operationDefaulted = false;
|
|
471
|
+
if (operation === 0) {
|
|
472
|
+
operation = CheckpointOperation.Exec;
|
|
473
|
+
operationDefaulted = true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// temporary is ALWAYS false in Phase 3 -- the fork exposes no such
|
|
477
|
+
// argument, and vice-sync.ts's "never delete a VICE-marked temporary
|
|
478
|
+
// checkpoint" invariant is a fork-side concern this module never touches.
|
|
479
|
+
const body = checkpointSetBody({ start, end, stop, enabled: true, operation, temporary: false });
|
|
480
|
+
|
|
481
|
+
let response;
|
|
482
|
+
try {
|
|
483
|
+
response = await session.client.send(CommandType.CheckpointSet, body);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return convertWireError("vice_checkpoint_add", err);
|
|
486
|
+
}
|
|
487
|
+
if (response.type !== "checkpoint_info") {
|
|
488
|
+
return isErrorText(`vice_checkpoint_add: unexpected reply type "${response.type}" from CHECKPOINT_SET`);
|
|
489
|
+
}
|
|
490
|
+
const checkpoint: ParsedCheckpoint = response.checkpoint;
|
|
491
|
+
|
|
492
|
+
if (!stop) {
|
|
493
|
+
registerTraceCheckpoint(session, checkpoint.id);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const payload: Record<string, unknown> = {
|
|
497
|
+
id: checkpoint.id,
|
|
498
|
+
start: checkpoint.start,
|
|
499
|
+
end: checkpoint.end,
|
|
500
|
+
stop: checkpoint.stopWhenHit,
|
|
501
|
+
enabled: checkpoint.enabled,
|
|
502
|
+
operation: { value: checkpoint.operation, flags: decodeOperationFlags(checkpoint.operation), defaulted: operationDefaulted },
|
|
503
|
+
temporary: checkpoint.temporary,
|
|
504
|
+
hitCount: checkpoint.hitCount,
|
|
505
|
+
ignoreCount: checkpoint.ignoreCount,
|
|
506
|
+
hasCondition: checkpoint.hasCondition,
|
|
507
|
+
traceMode: !checkpoint.stopWhenHit,
|
|
508
|
+
};
|
|
509
|
+
const autoDisables = autoDisableReportFor(session);
|
|
510
|
+
if (autoDisables.length > 0) payload.autoDisables = autoDisables;
|
|
511
|
+
return stockAnswer(session.client, payload);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
export const handleCheckpointDelete: StockSessionHandler = async (args, session, _deps) => {
|
|
515
|
+
if (!isPlainObject(args)) {
|
|
516
|
+
return isErrorText("vice_checkpoint_delete: arguments must be an object");
|
|
517
|
+
}
|
|
518
|
+
let checkpointNum: number;
|
|
519
|
+
try {
|
|
520
|
+
checkpointNum = parseCheckpointNum(args.checkpoint_num);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
return isErrorText(`vice_checkpoint_delete: ${describeError(err)}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
await session.client.send(CommandType.CheckpointDelete, cpNumBody(checkpointNum));
|
|
527
|
+
} catch (err) {
|
|
528
|
+
return convertWireError("vice_checkpoint_delete", err);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Remove the checkpoint's entry from the D-10 registry and the trace
|
|
532
|
+
// guard's tables so a re-used id cannot inherit stale state.
|
|
533
|
+
forgetCondition(session, checkpointNum);
|
|
534
|
+
forgetTraceState(session, checkpointNum);
|
|
535
|
+
|
|
536
|
+
const payload: Record<string, unknown> = { checkpointNum, deleted: true };
|
|
537
|
+
const autoDisables = autoDisableReportFor(session);
|
|
538
|
+
if (autoDisables.length > 0) payload.autoDisables = autoDisables;
|
|
539
|
+
return stockAnswer(session.client, payload);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export const handleCheckpointList: StockSessionHandler = async (_args, session, _deps) => {
|
|
543
|
+
let response;
|
|
544
|
+
try {
|
|
545
|
+
response = await session.client.send(CommandType.CheckpointList);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
return convertWireError("vice_checkpoint_list", err);
|
|
548
|
+
}
|
|
549
|
+
if (response.type !== "checkpoint_list") {
|
|
550
|
+
return isErrorText(`vice_checkpoint_list: unexpected reply type "${response.type}" from CHECKPOINT_LIST`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const relatedCheckpoints = response.related.filter(
|
|
554
|
+
(r): r is ParsedCheckpointInfoResponse => r.type === "checkpoint_info",
|
|
555
|
+
);
|
|
556
|
+
const totalReported = response.total;
|
|
557
|
+
const entriesReceived = relatedCheckpoints.length;
|
|
558
|
+
|
|
559
|
+
const traceState = traceGuards.get(session.client);
|
|
560
|
+
|
|
561
|
+
const checkpoints = relatedCheckpoints.map((entry) => {
|
|
562
|
+
const cp = entry.checkpoint;
|
|
563
|
+
const recordedText = conditionTextFor(session, cp.id);
|
|
564
|
+
const out: Record<string, unknown> = {
|
|
565
|
+
id: cp.id,
|
|
566
|
+
start: cp.start,
|
|
567
|
+
end: cp.end,
|
|
568
|
+
stop: cp.stopWhenHit,
|
|
569
|
+
enabled: cp.enabled,
|
|
570
|
+
operation: { value: cp.operation, flags: decodeOperationFlags(cp.operation) },
|
|
571
|
+
temporary: cp.temporary,
|
|
572
|
+
hitCount: cp.hitCount,
|
|
573
|
+
ignoreCount: cp.ignoreCount,
|
|
574
|
+
hasCondition: cp.hasCondition,
|
|
575
|
+
traceMode: !cp.stopWhenHit,
|
|
576
|
+
};
|
|
577
|
+
if (cp.hasCondition) {
|
|
578
|
+
if (recordedText !== undefined) {
|
|
579
|
+
out.condition = recordedText;
|
|
580
|
+
out.conditionTextKnown = true;
|
|
581
|
+
} else {
|
|
582
|
+
out.condition = null;
|
|
583
|
+
out.conditionTextKnown = false;
|
|
584
|
+
out.conditionNote =
|
|
585
|
+
"a condition is attached on the wire but was set outside this session -- stock VICE cannot read " +
|
|
586
|
+
"condition text back";
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
out.condition = recordedText ?? null;
|
|
590
|
+
out.conditionTextKnown = recordedText !== undefined;
|
|
591
|
+
}
|
|
592
|
+
const autoDisabled = traceState?.autoDisabled.get(cp.id);
|
|
593
|
+
if (autoDisabled) {
|
|
594
|
+
out.autoDisabled = { reason: autoDisabled.reason, at: autoDisabled.at, hitsPerSecond: autoDisabled.hitsPerSecond };
|
|
595
|
+
}
|
|
596
|
+
return out;
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const payload: Record<string, unknown> = { checkpoints, totalReported, entriesReceived };
|
|
600
|
+
const autoDisables = autoDisableReportFor(session);
|
|
601
|
+
if (autoDisables.length > 0) payload.autoDisables = autoDisables;
|
|
602
|
+
return stockAnswer(session.client, payload);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
export const handleCheckpointToggle: StockSessionHandler = async (args, session, _deps) => {
|
|
606
|
+
if (!isPlainObject(args)) {
|
|
607
|
+
return isErrorText("vice_checkpoint_toggle: arguments must be an object");
|
|
608
|
+
}
|
|
609
|
+
let checkpointNum: number;
|
|
610
|
+
try {
|
|
611
|
+
checkpointNum = parseCheckpointNum(args.checkpoint_num);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
return isErrorText(`vice_checkpoint_toggle: ${describeError(err)}`);
|
|
614
|
+
}
|
|
615
|
+
if (typeof args.enabled !== "boolean") {
|
|
616
|
+
return isErrorText("vice_checkpoint_toggle: enabled must be a boolean");
|
|
617
|
+
}
|
|
618
|
+
const enabled = args.enabled;
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
await session.client.send(CommandType.CheckpointToggle, checkpointToggleBody({ checkpointNum, enabled }));
|
|
622
|
+
} catch (err) {
|
|
623
|
+
return convertWireError("vice_checkpoint_toggle", err);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let autoDisableCleared = false;
|
|
627
|
+
if (enabled) {
|
|
628
|
+
const state = traceGuards.get(session.client);
|
|
629
|
+
if (state?.autoDisabled.has(checkpointNum)) {
|
|
630
|
+
state.autoDisabled.delete(checkpointNum);
|
|
631
|
+
autoDisableCleared = true;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const payload: Record<string, unknown> = { checkpointNum, enabled };
|
|
636
|
+
if (autoDisableCleared) payload.autoDisableCleared = true;
|
|
637
|
+
const autoDisables = autoDisableReportFor(session);
|
|
638
|
+
if (autoDisables.length > 0) payload.autoDisables = autoDisables;
|
|
639
|
+
return stockAnswer(session.client, payload);
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Task 2: set-condition and watch-add
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
export const handleCheckpointSetCondition: StockSessionHandler = async (args, session, _deps) => {
|
|
647
|
+
if (!isPlainObject(args)) {
|
|
648
|
+
return isErrorText("vice_checkpoint_set_condition: arguments must be an object");
|
|
649
|
+
}
|
|
650
|
+
let checkpointNum: number;
|
|
651
|
+
try {
|
|
652
|
+
checkpointNum = parseCheckpointNum(args.checkpoint_num);
|
|
653
|
+
} catch (err) {
|
|
654
|
+
return isErrorText(`vice_checkpoint_set_condition: ${describeError(err)}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// D-10: conditions are immutable once set -- stock cannot clear or replace
|
|
658
|
+
// one, and a re-set would leak the old text inside VICE.
|
|
659
|
+
const existing = conditionTextFor(session, checkpointNum);
|
|
660
|
+
if (existing !== undefined) {
|
|
661
|
+
return isErrorText(
|
|
662
|
+
`vice_checkpoint_set_condition: checkpoint ${checkpointNum} already has a condition set ("${existing}") -- ` +
|
|
663
|
+
`stock VICE cannot clear or replace a condition once attached (re-setting it would leak the old condition ` +
|
|
664
|
+
`inside VICE); delete this checkpoint and re-add it with the new condition instead.`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let node: ConditionNode;
|
|
669
|
+
let expression: string;
|
|
670
|
+
try {
|
|
671
|
+
node = nodeFromConditionArg(args.condition);
|
|
672
|
+
expression = emitCondition(node);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
// A StockConditionError is returned as its own refusal text verbatim --
|
|
675
|
+
// never re-worded, and never a fallback to sending the raw input.
|
|
676
|
+
if (err instanceof StockConditionError) {
|
|
677
|
+
return isErrorText(err.message);
|
|
678
|
+
}
|
|
679
|
+
return isErrorText(`vice_checkpoint_set_condition: ${describeError(err)}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const failure = await setConditionFailClosed(session, checkpointNum, expression, "vice_checkpoint_set_condition");
|
|
683
|
+
if (failure) return failure;
|
|
684
|
+
|
|
685
|
+
recordCondition(session, checkpointNum, expression);
|
|
686
|
+
|
|
687
|
+
const payload: Record<string, unknown> = { checkpointNum, condition: expression, immutable: true };
|
|
688
|
+
const autoDisables = autoDisableReportFor(session);
|
|
689
|
+
if (autoDisables.length > 0) payload.autoDisables = autoDisables;
|
|
690
|
+
return stockAnswer(session.client, payload);
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
export const handleWatchAdd: StockSessionHandler = async (args, session, _deps) => {
|
|
694
|
+
if (!isPlainObject(args)) {
|
|
695
|
+
return isErrorText("vice_watch_add: arguments must be an object");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let address: number;
|
|
699
|
+
try {
|
|
700
|
+
address = parseAddress(args.address, { what: "address" });
|
|
701
|
+
} catch (err) {
|
|
702
|
+
return isErrorText(`vice_watch_add: ${describeError(err)}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let size = 1;
|
|
706
|
+
if (args.size !== undefined) {
|
|
707
|
+
try {
|
|
708
|
+
size = parseByteCount(args.size, { max: 0x100, what: "size" });
|
|
709
|
+
} catch (err) {
|
|
710
|
+
return isErrorText(`vice_watch_add: ${describeError(err)}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const watchType = args.type === undefined ? "write" : args.type;
|
|
715
|
+
let operation: number;
|
|
716
|
+
if (watchType === "read") operation = CheckpointOperation.Load;
|
|
717
|
+
else if (watchType === "write") operation = CheckpointOperation.Store;
|
|
718
|
+
else if (watchType === "both") operation = CheckpointOperation.Load | CheckpointOperation.Store;
|
|
719
|
+
else {
|
|
720
|
+
return isErrorText(`vice_watch_add: type must be one of "read", "write", "both", got ${JSON.stringify(watchType)}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const end = address + size - 1;
|
|
724
|
+
if (end > 0xffff) {
|
|
725
|
+
return isErrorText(`vice_watch_add: address (${address}) + size (${size}) - 1 = ${end} exceeds 0xffff`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// WR-01 (03-REVIEW.md): strict, for exactly the reason handleCheckpointAdd's
|
|
729
|
+
// own identical check above spells out -- `Boolean("false")` is `true`, and
|
|
730
|
+
// nothing upstream of this handler type-checks the argument.
|
|
731
|
+
if (args.stop !== undefined && typeof args.stop !== "boolean") {
|
|
732
|
+
return isErrorText(`vice_watch_add: stop must be a boolean, got ${typeof args.stop}`);
|
|
733
|
+
}
|
|
734
|
+
const stop = args.stop === undefined ? true : args.stop;
|
|
735
|
+
const acknowledgeTraceRisk = args.acknowledgeTraceRisk === true;
|
|
736
|
+
if (!stop && !acknowledgeTraceRisk) {
|
|
737
|
+
return isErrorText(`vice_watch_add: ${STOP_FALSE_HAZARD_TEXT}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Validate/emit the condition BEFORE arming anything -- a condition that
|
|
741
|
+
// fails to emit must never result in a CHECKPOINT_SET being sent at all.
|
|
742
|
+
let expression: string | undefined;
|
|
743
|
+
if (args.condition !== undefined) {
|
|
744
|
+
try {
|
|
745
|
+
const node = nodeFromConditionArg(args.condition);
|
|
746
|
+
expression = emitCondition(node);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
if (err instanceof StockConditionError) {
|
|
749
|
+
return isErrorText(err.message);
|
|
750
|
+
}
|
|
751
|
+
return isErrorText(`vice_watch_add: ${describeError(err)}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const body = checkpointSetBody({ start: address, end, stop, enabled: true, operation, temporary: false });
|
|
756
|
+
let response;
|
|
757
|
+
try {
|
|
758
|
+
response = await session.client.send(CommandType.CheckpointSet, body);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
return convertWireError("vice_watch_add", err);
|
|
761
|
+
}
|
|
762
|
+
if (response.type !== "checkpoint_info") {
|
|
763
|
+
return isErrorText(`vice_watch_add: unexpected reply type "${response.type}" from CHECKPOINT_SET`);
|
|
764
|
+
}
|
|
765
|
+
const checkpoint: ParsedCheckpoint = response.checkpoint;
|
|
766
|
+
|
|
767
|
+
if (expression !== undefined) {
|
|
768
|
+
const failure = await setConditionFailClosed(session, checkpoint.id, expression, "vice_watch_add");
|
|
769
|
+
if (failure) return failure;
|
|
770
|
+
recordCondition(session, checkpoint.id, expression);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!stop) {
|
|
774
|
+
registerTraceCheckpoint(session, checkpoint.id);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const payload: Record<string, unknown> = {
|
|
778
|
+
id: checkpoint.id,
|
|
779
|
+
start: checkpoint.start,
|
|
780
|
+
end: checkpoint.end,
|
|
781
|
+
watchType,
|
|
782
|
+
size,
|
|
783
|
+
operation: { value: checkpoint.operation, flags: decodeOperationFlags(checkpoint.operation) },
|
|
784
|
+
stop: checkpoint.stopWhenHit,
|
|
785
|
+
enabled: checkpoint.enabled,
|
|
786
|
+
condition: expression ?? null,
|
|
787
|
+
hitCount: checkpoint.hitCount,
|
|
788
|
+
hasCondition: checkpoint.hasCondition,
|
|
789
|
+
traceMode: !checkpoint.stopWhenHit,
|
|
790
|
+
};
|
|
791
|
+
const autoDisables = autoDisableReportFor(session);
|
|
792
|
+
if (autoDisables.length > 0) payload.autoDisables = autoDisables;
|
|
793
|
+
return stockAnswer(session.client, payload);
|
|
794
|
+
};
|