@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
package/stock-connect.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-connect.ts
|
|
3
|
+
//
|
|
4
|
+
// The ONE place that performs the stock connect handshake: claim the
|
|
5
|
+
// monitor socket from the broker (BEFORE any TCP dial), open a
|
|
6
|
+
// ViceMonitorClient, assert the wire's api_version, read the connected
|
|
7
|
+
// build's identity via VICE_INFO, settle its version-gated capabilities
|
|
8
|
+
// exactly once per binary (BACK-04 -- at connect time, not at first use),
|
|
9
|
+
// and detect whether the machine underneath a reconnect is the SAME machine
|
|
10
|
+
// this client originally handshook with.
|
|
11
|
+
//
|
|
12
|
+
// WHY THIS FILE EXISTS: stock-protocol.ts's ViceMonitorClient deliberately
|
|
13
|
+
// answers only "this socket died" (see its own header comment on D-11) --
|
|
14
|
+
// it never decides whether a freshly reconnected socket belongs to the same
|
|
15
|
+
// emulator process. backend-detect.mts's capability cache is written once
|
|
16
|
+
// per binary by a --help probe that cannot observe a version quad at all
|
|
17
|
+
// (that file's own header comment says as much). Something has to sit
|
|
18
|
+
// between those two files and turn "a claimed, connected,
|
|
19
|
+
// api_version-checked socket" into "a named build with settled
|
|
20
|
+
// capabilities, whose continued identity across a reconnect is provable" --
|
|
21
|
+
// this file is that seam.
|
|
22
|
+
//
|
|
23
|
+
// WHAT NOT TO DO:
|
|
24
|
+
// - Never re-derive an epoch or restart heuristic here. vice.ts's
|
|
25
|
+
// MachineRestartedError is the ONE restart-error type this whole module
|
|
26
|
+
// tree uses (D-11); reuse it, do not define a second one.
|
|
27
|
+
// - Never dial the binmon port before claimMonitor() has succeeded --
|
|
28
|
+
// stock VICE services exactly one client, and a refused claim must
|
|
29
|
+
// arrive as a JSON response on a working control-plane socket, never as
|
|
30
|
+
// a connect() that silently sits unserviced in the backlog (PROTO-08,
|
|
31
|
+
// D-13, vice-broker-client.ts's own MonitorOwnershipError header
|
|
32
|
+
// comment).
|
|
33
|
+
import { ViceMonitorClient, CommandType, ErrorCode, StockProtocolError } from "./stock-protocol.ts";
|
|
34
|
+
import { readCapabilityRecord, writeCapabilityRecord, type CapabilityDeps } from "./backend-detect.mts";
|
|
35
|
+
import { MachineRestartedError, ViceError, readEpoch, type EpochResult } from "./vice.ts";
|
|
36
|
+
import {
|
|
37
|
+
MonitorOwnershipError,
|
|
38
|
+
type ClaimMonitorOptions,
|
|
39
|
+
type ClaimMonitorOutcome,
|
|
40
|
+
type ReleaseMonitorOptions,
|
|
41
|
+
type ReleaseMonitorOutcome,
|
|
42
|
+
} from "./vice-broker-client.ts";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Broker control surface this handshake needs -- deliberately narrower than
|
|
46
|
+
// the full BrokerControlSession (acquire/release/recycle/status/hostState):
|
|
47
|
+
// this file only ever claims and releases a monitor socket, never opens or
|
|
48
|
+
// closes the acquire-level lease itself. Any real BrokerControlSession
|
|
49
|
+
// satisfies this structurally; tests inject a minimal stub instead of the
|
|
50
|
+
// whole session.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export interface StockConnectBrokerControl {
|
|
54
|
+
claimMonitor(opts: ClaimMonitorOptions): Promise<ClaimMonitorOutcome>;
|
|
55
|
+
releaseMonitor(opts: ReleaseMonitorOptions): Promise<ReleaseMonitorOutcome>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Capabilities -- BACK-04's version-gated answer set. Today this is just
|
|
60
|
+
// CPUHISTORY_GET's three-way outcome (docs/phase0-binmon-findings.md §5,
|
|
61
|
+
// D-10): 0x00 OK on the 3.10 fork, 0x83 INVALID_TYPE (opcode absent) on
|
|
62
|
+
// stock 3.9, 0x8f CMD_FAILURE (compiled without support) as the distinct
|
|
63
|
+
// third case. A later plan adding a second version-gated opcode extends this
|
|
64
|
+
// type, not a parallel mechanism.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export type CpuHistoryCapability = "available" | "absent" | "not_compiled_in";
|
|
68
|
+
|
|
69
|
+
export interface StockCapabilities {
|
|
70
|
+
cpuHistory: CpuHistoryCapability;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** CPUHISTORY_GET's count field is read as uint32 by this handshake's own
|
|
74
|
+
* request body but VICE stores it internally in a uint16_t
|
|
75
|
+
* (monitor_binary.c:1492) -- any count >= 65536 wraps silently server-side.
|
|
76
|
+
* Clamp client-side rather than ever sending an unclamped value. This
|
|
77
|
+
* handshake only ever probes with count 0 (it wants the capability answer,
|
|
78
|
+
* not any history), but the clamp is a general guard for any future caller
|
|
79
|
+
* of this same request shape. */
|
|
80
|
+
const CPU_HISTORY_MAX_COUNT = 65535;
|
|
81
|
+
|
|
82
|
+
/** WR-02/WR-12: bounded at BOTH ends, and against non-finite input. A bare
|
|
83
|
+
* `Math.min(count, 65535)` clamped only the documented uint16 wrap and passed
|
|
84
|
+
* negatives and NaN straight through to `body.writeUInt32LE(count, 1)`, which
|
|
85
|
+
* THROWS for either. This function's own doc comment above advertises it as "a
|
|
86
|
+
* general guard for any future caller of this same request shape" -- and that
|
|
87
|
+
* caller is exactly the one who will hand it unvalidated input, so the guard
|
|
88
|
+
* has to hold for the whole numeric domain, not just the upper bound. A
|
|
89
|
+
* fractional count truncates rather than throwing: the wire field is an
|
|
90
|
+
* integer, and 0 is the safe floor (this handshake's own probe uses it). */
|
|
91
|
+
export function clampCpuHistoryCount(count: number): number {
|
|
92
|
+
if (!Number.isFinite(count)) return 0;
|
|
93
|
+
return Math.min(Math.max(Math.trunc(count), 0), CPU_HISTORY_MAX_COUNT);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sends CPUHISTORY_GET (0x86) with memspace=main and a zero, clamped count,
|
|
97
|
+
* and maps the wire outcome to CpuHistoryCapability's three-way answer --
|
|
98
|
+
* 0x00 OK -> "available", 0x83 INVALID_TYPE -> "absent" (the pre-3.10 case),
|
|
99
|
+
* 0x8f CMD_FAILURE -> "not_compiled_in" (the distinct compiled-without-
|
|
100
|
+
* support case). Any other rejection (a timeout, a closed socket, an
|
|
101
|
+
* unrecognized error code) is not this function's to interpret -- it
|
|
102
|
+
* propagates unchanged. */
|
|
103
|
+
async function probeCpuHistory(client: ViceMonitorClient): Promise<CpuHistoryCapability> {
|
|
104
|
+
const count = clampCpuHistoryCount(0);
|
|
105
|
+
const body = Buffer.alloc(5);
|
|
106
|
+
body[0] = 0x00; // memspace: main
|
|
107
|
+
body.writeUInt32LE(count, 1);
|
|
108
|
+
try {
|
|
109
|
+
await client.send(CommandType.CpuHistoryGet, body);
|
|
110
|
+
return "available";
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err instanceof StockProtocolError) {
|
|
113
|
+
if (err.errorCode === ErrorCode.InvalidType) return "absent"; // 0x83 -- opcode absent, pre-3.10
|
|
114
|
+
if (err.errorCode === ErrorCode.CmdFailure) return "not_compiled_in"; // 0x8f -- compiled without support
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Gates the CPUHISTORY_GET probe behind backend-detect.mts's own capability
|
|
121
|
+
* cache (BACK-04): a record whose stored versionQuad matches the one this
|
|
122
|
+
* handshake just observed short-circuits the probe entirely; a miss, a
|
|
123
|
+
* stale record (different versionQuad -- the binary was swapped), or the
|
|
124
|
+
* absence of a `binPath` to key on all fall through to a fresh probe, whose
|
|
125
|
+
* answer is then written back exactly once. This function never invents a
|
|
126
|
+
* backend verdict -- writeCapabilityRecord() itself is a no-op unless
|
|
127
|
+
* backend-detect.mts's own resolvedBackend() has already written a matching
|
|
128
|
+
* record for this binary (see that function's own header comment). */
|
|
129
|
+
async function resolveCapabilities(client: ViceMonitorClient, versionQuad: string, deps: StockConnectDeps): Promise<StockCapabilities> {
|
|
130
|
+
const readCap = deps.readCapabilityRecordFn ?? readCapabilityRecord;
|
|
131
|
+
const writeCap = deps.writeCapabilityRecordFn ?? writeCapabilityRecord;
|
|
132
|
+
|
|
133
|
+
if (deps.binPath) {
|
|
134
|
+
const capDeps: CapabilityDeps = { supervisorDir: deps.supervisorDir, observedVersionQuad: versionQuad };
|
|
135
|
+
const existing = readCap(deps.binPath, capDeps);
|
|
136
|
+
if (existing && !existing.stale && existing.cpuHistoryAvailable !== undefined) {
|
|
137
|
+
// backend-detect.mts's own cache schema stores only a boolean --
|
|
138
|
+
// "absent" and "not_compiled_in" both collapse to `false` there,
|
|
139
|
+
// since both mean "never attempt CPUHISTORY_GET again"; they differ
|
|
140
|
+
// only in WHY. A cache hit cannot recover which of the two it
|
|
141
|
+
// originally was, so it reports the more common non-3.10 case
|
|
142
|
+
// ("absent") rather than re-probing to find out -- re-probing on
|
|
143
|
+
// every connect is exactly what BACK-04 exists to avoid.
|
|
144
|
+
return { cpuHistory: existing.cpuHistoryAvailable ? "available" : "absent" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cpuHistory = await probeCpuHistory(client);
|
|
149
|
+
if (deps.binPath) {
|
|
150
|
+
writeCap(deps.binPath, { versionQuad, cpuHistoryAvailable: cpuHistory === "available" }, { supervisorDir: deps.supervisorDir });
|
|
151
|
+
}
|
|
152
|
+
return { cpuHistory };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// stockConnect() -- the handshake itself.
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export interface StockConnectDeps {
|
|
160
|
+
/** The binary this handshake is connected to -- the SAME key
|
|
161
|
+
* backend-detect.mts's cache is written under. Omitted entirely disables
|
|
162
|
+
* the capability cache (every connect re-probes, never persisted). */
|
|
163
|
+
binPath?: string;
|
|
164
|
+
/** Same meaning as backend-detect.mts's own `supervisorDir` option: a
|
|
165
|
+
* caller-supplied string, never re-derived here (this file must not
|
|
166
|
+
* become a second, driftable copy of "where is .vice-supervisor"). */
|
|
167
|
+
supervisorDir?: string;
|
|
168
|
+
/** Path to this instance's own epoch.json (broker-epoch.mts's own writer),
|
|
169
|
+
* used ONLY as the reconnect baseline/comparison (Task 2). Omitted
|
|
170
|
+
* entirely means identity across a reconnect can never be proven -- see
|
|
171
|
+
* stockReconnect()'s own header comment. */
|
|
172
|
+
epochPath?: string;
|
|
173
|
+
readCapabilityRecordFn?: typeof readCapabilityRecord;
|
|
174
|
+
writeCapabilityRecordFn?: typeof writeCapabilityRecord;
|
|
175
|
+
readEpochFn?: typeof readEpoch;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface StockConnectOptions {
|
|
179
|
+
host: string;
|
|
180
|
+
port: number;
|
|
181
|
+
targetId: string;
|
|
182
|
+
brokerControl: StockConnectBrokerControl;
|
|
183
|
+
deps?: StockConnectDeps;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface StockConnectSession {
|
|
187
|
+
client: ViceMonitorClient;
|
|
188
|
+
versionQuad: string;
|
|
189
|
+
capabilities: StockCapabilities;
|
|
190
|
+
host: string;
|
|
191
|
+
port: number;
|
|
192
|
+
targetId: string;
|
|
193
|
+
brokerControl: StockConnectBrokerControl;
|
|
194
|
+
deps: StockConnectDeps;
|
|
195
|
+
/** This instance's epoch, as read at connect time -- `null` when no
|
|
196
|
+
* epoch evidence could be read at all (deps.epochPath omitted, absent, or
|
|
197
|
+
* unreadable). Consumed only by stockReconnect() (Task 2). */
|
|
198
|
+
baselineEpoch: number | null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function safeDisconnect(client: ViceMonitorClient): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await client.disconnect();
|
|
204
|
+
} catch {
|
|
205
|
+
// disconnect() itself never throws in stock-protocol.ts's own
|
|
206
|
+
// implementation, but this handshake's own failure-cleanup path must
|
|
207
|
+
// never itself fail on the way out.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* CR-02 (code review 2026-08-13). THE load-bearing counterpart to every
|
|
213
|
+
* command this handshake sends. docs/phase0-binmon-findings.md §4, read from
|
|
214
|
+
* VICE's own source, is explicit: `monitor_check_binary()` calls
|
|
215
|
+
* `monitor_startup_trap()` on ANY INBOUND BYTE (monitor_binary.c:281), and
|
|
216
|
+
* that check runs every vsync -- so the bare `PING` (0x81) below halts the
|
|
217
|
+
* emulated C64 within roughly one frame and emits `STOPPED` (0x62). `EXIT`
|
|
218
|
+
* (0xaa) is the ONLY thing that resumes it.
|
|
219
|
+
*
|
|
220
|
+
* Before this function existed, nothing in this tree ever sent 0xaa: the
|
|
221
|
+
* first `vice_ping` on the stock backend froze the machine and left it frozen
|
|
222
|
+
* for the life of the held session, with a `STOPPED` event nobody consumed --
|
|
223
|
+
* exactly the "stopped advancing / not wedged / merely paused" state
|
|
224
|
+
* `vice-wedge-triage` exists to disambiguate, manufactured by the health
|
|
225
|
+
* check itself.
|
|
226
|
+
*
|
|
227
|
+
* WHAT NOT TO DO: never add a command sequence to this file (or to any future
|
|
228
|
+
* stock handler) that leaves the machine halted. The invariant is that a
|
|
229
|
+
* handshake returns the machine to the run state it found it in. That is why
|
|
230
|
+
* the success-path call below is INSIDE the try: a handshake that cannot
|
|
231
|
+
* prove it resumed the machine is a FAILED handshake, routed through the same
|
|
232
|
+
* disconnect-and-release cleanup as any other step, never a success that
|
|
233
|
+
* silently leaves the C64 frozen.
|
|
234
|
+
*
|
|
235
|
+
* The `RESUMED` (0x63) event VICE emits alongside the EXIT reply arrives at
|
|
236
|
+
* request id 0xffffffff and is routed to ViceMonitorClient's 'event' channel
|
|
237
|
+
* by the request-id-first demux -- deliberately not awaited here: keying on
|
|
238
|
+
* it would mean waiting on an unsolicited frame, which is precisely what that
|
|
239
|
+
* demux forbids resolving a request with.
|
|
240
|
+
*/
|
|
241
|
+
async function resumeMachine(client: ViceMonitorClient): Promise<void> {
|
|
242
|
+
await client.send(CommandType.Exit);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Best-effort resume for the FAILURE path only: the machine may already be
|
|
246
|
+
* halted by whichever command got through before the failure, and this
|
|
247
|
+
* handshake must not leave it that way if it can help it. Every error is
|
|
248
|
+
* swallowed -- the original handshake failure is what the caller must see
|
|
249
|
+
* (WR-07's own concern), never a cleanup error replacing it. Skipped entirely
|
|
250
|
+
* when the socket is already gone, since there is nothing to send through. */
|
|
251
|
+
async function safeResume(client: ViceMonitorClient): Promise<void> {
|
|
252
|
+
if (!client.connected) return;
|
|
253
|
+
try {
|
|
254
|
+
await resumeMachine(client);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(`stockConnect: best-effort resume (EXIT 0xaa) after a failed handshake did not complete: ${String(err)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The one connect handshake for the stock path, in load-bearing order:
|
|
262
|
+
*
|
|
263
|
+
* 1. claimMonitor() -- BEFORE any socket is opened (PROTO-08, D-13). A
|
|
264
|
+
* `monitor_owned` refusal rejects with MonitorOwnershipError naming the
|
|
265
|
+
* holder; a `timeout` refusal rejects distinctly (the broker did not
|
|
266
|
+
* answer, which is not "someone else owns it").
|
|
267
|
+
* 2. Open a ViceMonitorClient against host:port.
|
|
268
|
+
* 3. Assert the protocol version by sending one PING (0x81).
|
|
269
|
+
* stock-protocol.ts's own parser validates api_version on every frame
|
|
270
|
+
* it decodes -- a mismatch surfaces as a StockFramingError straight out
|
|
271
|
+
* of `client.send()`, which this function propagates as a fatal
|
|
272
|
+
* handshake failure rather than re-deriving the check.
|
|
273
|
+
* 4. Send VICE_INFO (0x85) and read the version quad.
|
|
274
|
+
* 5. Gate capabilities via resolveCapabilities() above.
|
|
275
|
+
* 6. Record this instance's epoch (deps.epochPath) as the reconnect
|
|
276
|
+
* baseline (Task 2) -- absence is normal here (D-3's own posture) and
|
|
277
|
+
* becomes significant only at stockReconnect() time.
|
|
278
|
+
* 7. Send EXIT (0xaa) to RESUME the machine step 3's PING halted (CR-02).
|
|
279
|
+
* Non-optional: see resumeMachine()'s own header comment. This handshake
|
|
280
|
+
* returns the emulator to the run state it found it in, or fails.
|
|
281
|
+
*
|
|
282
|
+
* Every failure path releases the monitor claim before propagating -- a
|
|
283
|
+
* handshake that fails at any step must never leave the instance claimed --
|
|
284
|
+
* and best-effort resumes the machine first, so a failed handshake does not
|
|
285
|
+
* leave a frozen C64 behind either.
|
|
286
|
+
*/
|
|
287
|
+
export async function stockConnect({ host, port, targetId, brokerControl, deps = {} }: StockConnectOptions): Promise<StockConnectSession> {
|
|
288
|
+
const claimOutcome = await brokerControl.claimMonitor({ targetId });
|
|
289
|
+
if (!claimOutcome.ok) {
|
|
290
|
+
if (claimOutcome.reason === "monitor_owned") {
|
|
291
|
+
throw new MonitorOwnershipError(
|
|
292
|
+
`stockConnect: monitor for target ${targetId} on port ${port} is already claimed by grant ${claimOutcome.holder.grantId}`,
|
|
293
|
+
{ holderGrantId: claimOutcome.holder.grantId, holderClaimedAt: claimOutcome.holder.claimedAt, port },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
// "timeout" (the broker did not answer) is kept strictly distinct from
|
|
297
|
+
// "monitor_owned" (someone else holds it) -- never conflated, matching
|
|
298
|
+
// vice-broker-client.ts's own MonitorOwnershipError header comment.
|
|
299
|
+
throw new ViceError(`stockConnect: monitor claim for target ${targetId} failed (${claimOutcome.reason})`, { code: claimOutcome.reason });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const client = new ViceMonitorClient();
|
|
303
|
+
// CR-02: flipped immediately BEFORE step 7's resume is attempted, so the
|
|
304
|
+
// failure path below never sends a SECOND EXIT for a resume that already
|
|
305
|
+
// failed on its own (which would stack a second full timeout on top of the
|
|
306
|
+
// first, and re-report the same problem twice).
|
|
307
|
+
let resumeAttempted = false;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await client.connect(host, port);
|
|
311
|
+
|
|
312
|
+
// Step 3: api_version assertion. A non-0x02 api_version rejects this
|
|
313
|
+
// send() call directly with a StockFramingError naming the observed
|
|
314
|
+
// value -- see this function's own header comment. NOTE (CR-02): this
|
|
315
|
+
// single byte HALTS the emulated machine (any inbound byte does --
|
|
316
|
+
// docs/phase0-binmon-findings.md §4); step 7's EXIT is what undoes it.
|
|
317
|
+
await client.send(CommandType.Ping);
|
|
318
|
+
|
|
319
|
+
// Step 4: build identity.
|
|
320
|
+
const infoResponse = await client.send(CommandType.ViceInfo);
|
|
321
|
+
if (infoResponse.type !== "vice_info") {
|
|
322
|
+
throw new ViceError(`stockConnect: VICE_INFO reply for target ${targetId} had unexpected shape "${infoResponse.type}"`);
|
|
323
|
+
}
|
|
324
|
+
const versionQuad = infoResponse.versionString;
|
|
325
|
+
|
|
326
|
+
// Step 5: settle version-gated capabilities, once per binary.
|
|
327
|
+
const capabilities = await resolveCapabilities(client, versionQuad, deps);
|
|
328
|
+
|
|
329
|
+
// Step 6: record the reconnect baseline (Task 2). Absence is normal --
|
|
330
|
+
// matches vice.ts's own readEpoch()/D-3 posture -- and is not an error
|
|
331
|
+
// here; it becomes significant only inside stockReconnect().
|
|
332
|
+
const readEpochFn = deps.readEpochFn ?? readEpoch;
|
|
333
|
+
const baselineRecord: EpochResult | null = deps.epochPath ? readEpochFn(deps.epochPath) : null;
|
|
334
|
+
const baselineEpoch = baselineRecord && baselineRecord.present ? baselineRecord.epoch : null;
|
|
335
|
+
|
|
336
|
+
// Step 7 (CR-02): resume the machine the PING in step 3 halted. LAST, and
|
|
337
|
+
// inside the try -- see resumeMachine()'s own header comment for why a
|
|
338
|
+
// failure here must fail the whole handshake rather than return a session
|
|
339
|
+
// whose emulator is frozen.
|
|
340
|
+
resumeAttempted = true;
|
|
341
|
+
await resumeMachine(client);
|
|
342
|
+
|
|
343
|
+
return { client, versionQuad, capabilities, host, port, targetId, brokerControl, deps, baselineEpoch };
|
|
344
|
+
} catch (err) {
|
|
345
|
+
// Every failure path releases the claim before propagating -- a
|
|
346
|
+
// handshake that fails at any step must not leave the instance locked --
|
|
347
|
+
// and, CR-02, tries to leave the machine RUNNING on the way out too.
|
|
348
|
+
if (!resumeAttempted) await safeResume(client);
|
|
349
|
+
await safeDisconnect(client);
|
|
350
|
+
// WR-07: the release must never REPLACE the original failure. Before this,
|
|
351
|
+
// a bare `await brokerControl.releaseMonitor(...)` sat between the catch and
|
|
352
|
+
// the throw, so a rejecting release substituted its own error for the real
|
|
353
|
+
// handshake cause (an api-version mismatch, a timeout, a closed socket) and
|
|
354
|
+
// the caller lost it entirely. Its `{ ok: false, reason }` outcome was
|
|
355
|
+
// discarded too, so a FAILED release was silent -- the instance stayed
|
|
356
|
+
// claimed while the caller was told the handshake failed for an unrelated
|
|
357
|
+
// reason. Both outcomes are now reported on stderr and neither can
|
|
358
|
+
// displace `err`.
|
|
359
|
+
try {
|
|
360
|
+
const released = await brokerControl.releaseMonitor({ targetId });
|
|
361
|
+
if (!released.ok) {
|
|
362
|
+
console.error(
|
|
363
|
+
`stockConnect: monitor release for target ${targetId} after a failed handshake was refused (${released.reason}) -- the instance may still be claimed`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
} catch (releaseErr) {
|
|
367
|
+
console.error(`stockConnect: monitor release for target ${targetId} after a failed handshake threw: ${String(releaseErr)}`);
|
|
368
|
+
}
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Normal counterpart to stockConnect()'s claim: disconnects the socket and
|
|
374
|
+
* releases the monitor claim together, so a caller never ends up holding
|
|
375
|
+
* one without the other. This is the "success path" release alongside
|
|
376
|
+
* stockConnect()'s own failure-path release above. */
|
|
377
|
+
export async function stockDisconnect(session: StockConnectSession): Promise<void> {
|
|
378
|
+
await safeDisconnect(session.client);
|
|
379
|
+
await session.brokerControl.releaseMonitor({ targetId: session.targetId });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// stockReconnect() -- Task 2: reconnect-with-identity-check.
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
export interface StockReconnectOptions {
|
|
387
|
+
lastToolCall?: string | null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Reconnects against the SAME target this session originally handshook
|
|
392
|
+
* with, proving identity via the per-instance epoch file (deps.epochPath)
|
|
393
|
+
* BEFORE running the handshake again. Three failure meanings, three
|
|
394
|
+
* distinct types -- conflating any two of them is the regression this
|
|
395
|
+
* comment exists to prevent:
|
|
396
|
+
*
|
|
397
|
+
* - StockRequestTimeoutError (stock-protocol.ts): "connected but silent."
|
|
398
|
+
* - StockConnectionClosedError (stock-protocol.ts): "this socket died."
|
|
399
|
+
* - MachineRestartedError (vice.ts, reused -- never redefined here):
|
|
400
|
+
* "the machine under you is not the machine you handshook with," or its
|
|
401
|
+
* identity across the reconnect could not be proven at all (no epoch
|
|
402
|
+
* evidence either way is treated the same as proven-different -- D-3's
|
|
403
|
+
* own "identity that cannot be proven is treated as not proven").
|
|
404
|
+
*
|
|
405
|
+
* On a proven match, this function re-runs the FULL handshake (stockConnect
|
|
406
|
+
* again) rather than merely re-dialling: re-reading VICE_INFO and
|
|
407
|
+
* re-validating the capability record against the freshly observed version
|
|
408
|
+
* quad means a restart that swapped the underlying binary never inherits
|
|
409
|
+
* the old build's capability answers (resolveCapabilities()'s own staleness
|
|
410
|
+
* check above).
|
|
411
|
+
*/
|
|
412
|
+
export async function stockReconnect(session: StockConnectSession, { lastToolCall = null }: StockReconnectOptions = {}): Promise<StockConnectSession> {
|
|
413
|
+
const readEpochFn = session.deps.readEpochFn ?? readEpoch;
|
|
414
|
+
const current: EpochResult | null = session.deps.epochPath ? readEpochFn(session.deps.epochPath) : null;
|
|
415
|
+
const currentEpoch = current && current.present ? current.epoch : null;
|
|
416
|
+
const baselineEpoch = session.baselineEpoch;
|
|
417
|
+
|
|
418
|
+
if (baselineEpoch === null || currentEpoch === null || currentEpoch !== baselineEpoch) {
|
|
419
|
+
throw new MachineRestartedError(
|
|
420
|
+
`stockConnect: reconnect to target ${session.targetId} could not prove machine identity across the reconnect ` +
|
|
421
|
+
`(baseline epoch ${String(baselineEpoch)}, current epoch ${String(currentEpoch)})`,
|
|
422
|
+
{ baselineEpoch, currentEpoch, where: "stock-connect.ts:stockReconnect", lastToolCall },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return stockConnect({ host: session.host, port: session.port, targetId: session.targetId, brokerControl: session.brokerControl, deps: session.deps });
|
|
427
|
+
}
|
package/stock-derived.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-derived.ts
|
|
3
|
+
//
|
|
4
|
+
// THE derived-tool leaf (DERIV-07). This file owns the one thing
|
|
5
|
+
// stock-dispatch.ts does not: the CONTAINER-PATH DISCIPLINE for a stock tool
|
|
6
|
+
// whose answer is computed CLIENT-SIDE rather than fetched from the wire.
|
|
7
|
+
// It holds STOCK_DERIVED_TOOLS (the data-only registry, D-03),
|
|
8
|
+
// derivedContainerPath() (D-01/D-02), DerivedToolError, and the
|
|
9
|
+
// DerivedPureHandler type. withDerivedTool() itself lives beside
|
|
10
|
+
// withStockSession() in stock-dispatch.ts -- see this plan's
|
|
11
|
+
// plan_decision_module_split block for why the adapter and this leaf are two
|
|
12
|
+
// files rather than one (withDerivedTool() must delegate to
|
|
13
|
+
// ensureStockSession(), which lives in stock-dispatch.ts; putting the
|
|
14
|
+
// adapter here would close a runtime cycle stock-dispatch.ts -> stock-derived.ts
|
|
15
|
+
// -> stock-dispatch.ts).
|
|
16
|
+
//
|
|
17
|
+
// WHY THIS FILE EXISTS: this is the MIRROR IMAGE of stock-paths.ts's D-17
|
|
18
|
+
// hazard. There, NOT translating an emulator-side path is the bug -- four
|
|
19
|
+
// tools carry a filename stock VICE opens on the HOST, and stock-paths.ts's
|
|
20
|
+
// whole job is making sure that translation happens. HERE, translating a
|
|
21
|
+
// CLIENT-SIDE-DERIVED path is the bug: rewriteArguments() runs INSIDE the
|
|
22
|
+
// fork-forwarding function at vice-proxy.ts:2773, before call() -- a derived
|
|
23
|
+
// tool sitting behind call() would receive HOST-translated paths and act on
|
|
24
|
+
// them INSIDE THE CONTAINER (ROADMAP Phase 4 Notes, CLAUDE.md). The
|
|
25
|
+
// derived-tool seam this file anchors exists so a derived tool's handler is
|
|
26
|
+
// reached BEFORE that fork-forwarding function ever runs
|
|
27
|
+
// rewriteArguments() at all.
|
|
28
|
+
//
|
|
29
|
+
// SECOND CONSUMER, named now so Phase 5's edit is a one-liner:
|
|
30
|
+
// gatherWedgeEvidence() (vice-proxy.ts:1343) calls rewriteArguments() itself
|
|
31
|
+
// at line 1367. On the stock backend, PERFORMING that translation becomes
|
|
32
|
+
// the bug -- its own comment inverts. Phase 5 criterion 5 owns that fix; it
|
|
33
|
+
// is deliberately NOT repointed here (it is currently unreachable on stock
|
|
34
|
+
// anyway: handleRecycle() is backend-aware and refused by name after CR-07,
|
|
35
|
+
// and vice_display_screenshot does not exist on stock until Phase 5).
|
|
36
|
+
//
|
|
37
|
+
// WHAT NOT TO DO:
|
|
38
|
+
// - Never `import` hostpath.ts from this file, or from any module listed
|
|
39
|
+
// in STOCK_DERIVED_TOOLS' implementations -- hostpath-consumers.test.ts
|
|
40
|
+
// fails the build if you do.
|
|
41
|
+
// - Never `import` vice-proxy.ts, and never call rewriteArguments().
|
|
42
|
+
// - Never add a client-side-derived tool to stock-paths.ts's
|
|
43
|
+
// STOCK_EMULATOR_SIDE_PATH_TOOLS -- that file's own header already warns
|
|
44
|
+
// against exactly this.
|
|
45
|
+
// - Never build a second dispatch table or a fall-through (D-03, Phase 2
|
|
46
|
+
// D-09). Derived-ness is a property of which adapter wraps a handler,
|
|
47
|
+
// never a routing decision -- there is still exactly one
|
|
48
|
+
// STOCK_DISPATCH_TABLE and exactly one dispatchStock( call site in
|
|
49
|
+
// vice-proxy.ts.
|
|
50
|
+
// - Never re-implement session acquisition here -- withDerivedTool() in
|
|
51
|
+
// stock-dispatch.ts delegates to the one ensureStockSession() the 25
|
|
52
|
+
// direct tools use.
|
|
53
|
+
import { ViceError, type ViceErrorOptions } from "./vice.ts";
|
|
54
|
+
import type { StockToolResult } from "./stock-handler.ts";
|
|
55
|
+
import type { StockDispatchDeps } from "./stock-dispatch.ts";
|
|
56
|
+
|
|
57
|
+
/** The one error type this module ever throws -- never a bare Error,
|
|
58
|
+
* matching vice.ts's established ViceError hierarchy (stock-address.ts's
|
|
59
|
+
* StockAddressError and stock-paths.ts's StockPathError are the sibling
|
|
60
|
+
* precedents). */
|
|
61
|
+
export class DerivedToolError extends ViceError {
|
|
62
|
+
constructor(message: string, options: ViceErrorOptions = {}) {
|
|
63
|
+
super(message, options);
|
|
64
|
+
this.name = "DerivedToolError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// STOCK_DERIVED_TOOLS -- the data-only registry (D-03). This set is DATA and
|
|
70
|
+
// is NEVER consulted to ROUTE a call -- it is consulted only to refuse an
|
|
71
|
+
// undeclared tool (derivedContainerPath(), withDerivedTool()) and to answer
|
|
72
|
+
// Phase 8's "which tools are derived" question. There is still exactly one
|
|
73
|
+
// STOCK_DISPATCH_TABLE; a tool's presence here changes nothing about how it
|
|
74
|
+
// is dispatched, only which adapter wraps its handler.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export const STOCK_DERIVED_TOOLS: ReadonlySet<string> = new Set([
|
|
78
|
+
"vice_disassemble", // Phase 4, DERIV-07's first consumer (04-05) -- client-side 6510 disassembler
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The handler shape for a derived tool that needs NO session (D-04). It
|
|
83
|
+
* receives no session argument at all, so it structurally cannot reach the
|
|
84
|
+
* wire -- there is no `session.client` to send anything through. `deps` is
|
|
85
|
+
* threaded down for anything the handler needs beyond the session (matching
|
|
86
|
+
* StockSessionHandler's own `deps` parameter).
|
|
87
|
+
*
|
|
88
|
+
* `StockDispatchDeps` is imported `type`-only from stock-dispatch.ts -- under
|
|
89
|
+
* verbatimModuleSyntax an `import type` erases completely at compile time,
|
|
90
|
+
* so it creates no runtime cycle even though stock-dispatch.ts imports THIS
|
|
91
|
+
* file's other exports at runtime (exactly the arrangement stock-handler.ts
|
|
92
|
+
* already uses and documents for the same reason).
|
|
93
|
+
*/
|
|
94
|
+
export type DerivedPureHandler = (args: Record<string, unknown>, deps: StockDispatchDeps) => Promise<StockToolResult>;
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// derivedContainerPath() -- the container-path discipline (D-01/D-02).
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns `containerPath` UNCHANGED, having consulted nothing -- no
|
|
102
|
+
* `isInsideContainer()` branch, no `hostPathCandidates()`, no
|
|
103
|
+
* `tryHostPaths()`, no environment variable. Its entire behaviour is "return
|
|
104
|
+
* the container path", and its entire value is that it is the ONE named seam
|
|
105
|
+
* a derived tool routes an output path through -- so a future reviewer (or
|
|
106
|
+
* hostpath-consumers.test.ts's asserted-absence scan) has something concrete
|
|
107
|
+
* to point at when checking that no translation ever happened.
|
|
108
|
+
*
|
|
109
|
+
* Refuses any `toolName` not declared in STOCK_DERIVED_TOOLS -- the same
|
|
110
|
+
* refuse-if-not-declared shape as stock-paths.ts's withEmulatorSidePath() --
|
|
111
|
+
* so a handler cannot opt itself into derived treatment without being
|
|
112
|
+
* declared in the registry above.
|
|
113
|
+
*/
|
|
114
|
+
export function derivedContainerPath(toolName: string, containerPath: string): string {
|
|
115
|
+
if (!STOCK_DERIVED_TOOLS.has(toolName)) {
|
|
116
|
+
throw new DerivedToolError(
|
|
117
|
+
`derivedContainerPath: ${toolName} is not declared in STOCK_DERIVED_TOOLS -- only a tool listed there may ` +
|
|
118
|
+
`route a path through this function.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return containerPath;
|
|
122
|
+
}
|