@henols/vice-mcp 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/channel-lock.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// channel-lock.ts
|
|
3
|
+
//
|
|
4
|
+
// THE ONE PLACE the cross-channel mutex, its FIFO queue, its holder record
|
|
5
|
+
// and its refusal text live. Phase 39's `go` verdict (rule R15) selected an
|
|
6
|
+
// in-process async mutex with no narrowing as the shape that serializes
|
|
7
|
+
// every halt-taking operation on either monitor channel -- this is that
|
|
8
|
+
// primitive, built fresh: there is no async mutex anywhere in this tree, and
|
|
9
|
+
// the only prior single-flight queue died with the retired analyser and was
|
|
10
|
+
// explicitly not extracted. None of the four pieces this module owns -- the
|
|
11
|
+
// mutex, its FIFO queue, its holder record, its refusal text -- may be
|
|
12
|
+
// re-derived in stock-dispatch.ts, text-protocol.ts or stock-diagnose.ts.
|
|
13
|
+
//
|
|
14
|
+
// WHY HAND-BUILT RATHER THAN TAKEN FROM A LIBRARY: a generic mutex (e.g.
|
|
15
|
+
// `async-mutex`) grants and releases but exposes no holder record -- it
|
|
16
|
+
// cannot answer "who holds this, since when, doing what". This module's
|
|
17
|
+
// `ChannelLockHolder` is exactly that record, and it is what makes
|
|
18
|
+
// contention readable to `vice_diagnose` (plan 41-04) rather than a bare
|
|
19
|
+
// "something is locked". `async-mutex` was considered and rejected by this
|
|
20
|
+
// phase's own locked decision; it is never installed, and there is no
|
|
21
|
+
// install task in this plan for a package-legitimacy audit to cover.
|
|
22
|
+
//
|
|
23
|
+
// WHY `MonitorChannel` IS DECLARED HERE A SECOND TIME rather than imported
|
|
24
|
+
// from broker-state.mts (which already has an `InstanceRecord.monitorClient`
|
|
25
|
+
// concept): broker-state.mts is host-bound and compiled into
|
|
26
|
+
// `resources/*.mjs`, so a container-side `.ts` module -- this one -- cannot
|
|
27
|
+
// import it at runtime. The shared thing between the two declarations is the
|
|
28
|
+
// two-value contract ("binary" | "text"), not the declaration itself --
|
|
29
|
+
// exactly as textmon-fixtures.ts reimplements the provenance contract rather
|
|
30
|
+
// than importing it from binmon-fixtures.ts.
|
|
31
|
+
//
|
|
32
|
+
// WHAT NOT TO DO:
|
|
33
|
+
// - Never acquire this lock per wire command in a way that lets a foreign
|
|
34
|
+
// command land between a resume and its checkpoint observation -- the
|
|
35
|
+
// lock is acquired per LOGICAL OPERATION (see stock-dispatch.ts's
|
|
36
|
+
// withChannelLockHeld() and text-protocol.ts's withTextChannelLock()),
|
|
37
|
+
// spanning resume -> wait -> observe. A design that preserves the resume
|
|
38
|
+
// count while destroying what the count protects is a regression, not a
|
|
39
|
+
// variant.
|
|
40
|
+
// - Never import stock-run-until.ts from this module. The derivation
|
|
41
|
+
// comment on CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS below states the
|
|
42
|
+
// relationship to RUN_UNTIL_MAX_TIMEOUT_MS in prose; importing it here
|
|
43
|
+
// would invert the dependency, with the primitive importing a consumer.
|
|
44
|
+
// channel-lock.test.ts cross-imports both files to assert the
|
|
45
|
+
// inequality instead.
|
|
46
|
+
// - Never resolve a waiter's timeout through a second admission path.
|
|
47
|
+
// Both the grant-on-free branch and the enqueue branch below run
|
|
48
|
+
// through one internal function (admit()) so there is exactly one place
|
|
49
|
+
// a caller is ever granted the lock.
|
|
50
|
+
// - Never hand the lock to the next waiter through a `setTimeout` on
|
|
51
|
+
// release -- that would let timer scheduling perturb arrival order.
|
|
52
|
+
// release() hands off synchronously, in the same tick.
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// MonitorChannel -- the two-value contract, declared here (see header).
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/** Exactly two channels exist and a third is not anticipated -- stock VICE
|
|
59
|
+
* exposes precisely the binary monitor and the `-remotemonitor` text
|
|
60
|
+
* channel, and this project has no plan to add a third. Frozen so a
|
|
61
|
+
* consumer cannot accidentally push a third value onto it at runtime. */
|
|
62
|
+
export const MONITOR_CHANNELS = Object.freeze(["binary", "text"] as const);
|
|
63
|
+
|
|
64
|
+
export type MonitorChannel = (typeof MONITOR_CHANNELS)[number];
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// The holder record and the lock handle.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** A read-only snapshot of who currently holds halt authority. `grantId` is
|
|
71
|
+
* `null` when the caller did not supply one (not every acquirer has a
|
|
72
|
+
* broker-issued grant id to report) -- `channelLockRefusalMessage()` renders
|
|
73
|
+
* that case as the literal `unknown`, never a fabricated id. */
|
|
74
|
+
export interface ChannelLockHolder {
|
|
75
|
+
channel: MonitorChannel;
|
|
76
|
+
operation: string;
|
|
77
|
+
grantId: string | null;
|
|
78
|
+
heldSince: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returned by a successful acquire. `release()` is idempotent: a second
|
|
82
|
+
* call is a no-op, and a stale handle's release() (called after the lock has
|
|
83
|
+
* already been re-acquired by someone else) never releases the NEW holder's
|
|
84
|
+
* lock -- both are identity-checked against a private id captured at grant
|
|
85
|
+
* time, never against the holder record's own field values. */
|
|
86
|
+
export interface ChannelLockHandle {
|
|
87
|
+
readonly holder: ChannelLockHolder;
|
|
88
|
+
release(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS -- the bounded-wait default (D-06).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Default bound (milliseconds) on how long acquireChannelLock() will queue
|
|
97
|
+
* behind a holder before rejecting. Overridable via
|
|
98
|
+
* VICE_CHANNEL_LOCK_TIMEOUT_MS for a test or a deliberately narrowed
|
|
99
|
+
* deployment.
|
|
100
|
+
*
|
|
101
|
+
* DERIVATION (stated in prose, never imported -- see header): the longest
|
|
102
|
+
* legitimate hold on this lock is a stock wait path bounded by
|
|
103
|
+
* stock-run-until.ts's `RUN_UNTIL_MAX_TIMEOUT_MS` (600000ms). This default
|
|
104
|
+
* is that bound plus a 30000ms margin -- 630000ms -- so the queued-wait
|
|
105
|
+
* bound always EXCEEDS the longest legitimate hold and never fires on
|
|
106
|
+
* healthy operation. A bound below the longest legitimate hold would fire
|
|
107
|
+
* while the machine is doing exactly what it was asked to do, which is
|
|
108
|
+
* precisely the self-inflicted-DoS failure this constant exists to prevent
|
|
109
|
+
* (T-41-06). A waiting caller's own MCP client timeout (150000ms,
|
|
110
|
+
* `.mcp.json`) will surface first for THAT caller regardless -- that is a
|
|
111
|
+
* timeout on their call, not a wedge diagnosis of the instance.
|
|
112
|
+
*/
|
|
113
|
+
export const CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS: number = (() => {
|
|
114
|
+
const raw = process.env.VICE_CHANNEL_LOCK_TIMEOUT_MS;
|
|
115
|
+
if (raw === undefined || raw === "") return 630000;
|
|
116
|
+
const n = Number(raw);
|
|
117
|
+
return Number.isFinite(n) && n > 0 ? n : 630000;
|
|
118
|
+
})();
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// The refusal wording -- the ONE place it is composed.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The ONE refusal wording produced when a caller cannot be granted the lock
|
|
126
|
+
* (either an immediate `tryAcquireChannelLock()` miss reported by a caller,
|
|
127
|
+
* or a `ChannelLockTimeoutError`'s own message). Names the other channel,
|
|
128
|
+
* the operation it is running, the hold duration in whole milliseconds, and
|
|
129
|
+
* the holder's grant id (or the literal `unknown` when there is none --
|
|
130
|
+
* never a fabricated id, matching claimMonitor()'s own posture for a
|
|
131
|
+
* malformed holder payload).
|
|
132
|
+
*
|
|
133
|
+
* This reads as a legitimate ownership statement -- the same register
|
|
134
|
+
* broker-control.mts's MONITOR_OWNERSHIP_DENIAL/`monitor_owned` refusal
|
|
135
|
+
* already establishes for the broker's own monitor-claim conflict -- and
|
|
136
|
+
* must NEVER contain the words wedge, wedged, hang, hung, frozen, stuck or
|
|
137
|
+
* unresponsive, nor otherwise suggest the emulator itself has stopped
|
|
138
|
+
* answering. A hold the OTHER channel cannot see would otherwise read as
|
|
139
|
+
* exactly the `wedged` triage signature, whose recommended remedy is
|
|
140
|
+
* destructive (T-41-06's threat-register entry).
|
|
141
|
+
*/
|
|
142
|
+
export function channelLockRefusalMessage(holder: ChannelLockHolder, nowMs: number): string {
|
|
143
|
+
const heldMs = Math.max(0, nowMs - holder.heldSince);
|
|
144
|
+
const grantId = holder.grantId ?? "unknown";
|
|
145
|
+
return (
|
|
146
|
+
`channel-lock: the ${holder.channel} channel currently holds halt authority ` +
|
|
147
|
+
`(operation "${holder.operation}", grant ${grantId}, held for ${heldMs}ms) -- ` +
|
|
148
|
+
`this call must wait for that channel to release before it can proceed`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Thrown by acquireChannelLock() when a queued waiter's bound
|
|
154
|
+
* (CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS by default, or an explicit override)
|
|
155
|
+
* expires before the lock reaches it. Carries the holder record that was
|
|
156
|
+
* blocking it and the measured hold/wait durations as public fields, with
|
|
157
|
+
* `message` produced by channelLockRefusalMessage() -- callers must never
|
|
158
|
+
* re-word this message, only pass it through verbatim.
|
|
159
|
+
*/
|
|
160
|
+
export class ChannelLockTimeoutError extends Error {
|
|
161
|
+
readonly holder: ChannelLockHolder;
|
|
162
|
+
readonly heldMs: number;
|
|
163
|
+
readonly waitedMs: number;
|
|
164
|
+
|
|
165
|
+
constructor(holder: ChannelLockHolder, waitedMs: number, nowMs: number = Date.now()) {
|
|
166
|
+
super(channelLockRefusalMessage(holder, nowMs));
|
|
167
|
+
this.name = "ChannelLockTimeoutError";
|
|
168
|
+
this.holder = holder;
|
|
169
|
+
this.heldMs = Math.max(0, nowMs - holder.heldSince);
|
|
170
|
+
this.waitedMs = waitedMs;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// The mutex itself: one holder, one FIFO queue, one admission path.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
interface QueueEntry {
|
|
179
|
+
readonly id: symbol;
|
|
180
|
+
readonly channel: MonitorChannel;
|
|
181
|
+
readonly operation: string;
|
|
182
|
+
readonly grantId: string | null;
|
|
183
|
+
readonly arrivedAt: number;
|
|
184
|
+
timer: NodeJS.Timeout | null;
|
|
185
|
+
readonly resolve: (handle: ChannelLockHandle) => void;
|
|
186
|
+
readonly reject: (err: unknown) => void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let currentHolder: ChannelLockHolder | null = null;
|
|
190
|
+
/** Identity of the handle that currently owns `currentHolder` -- distinct
|
|
191
|
+
* from the holder record's own field values, so a stale handle (one whose
|
|
192
|
+
* release() runs after the lock has already passed to someone else) can
|
|
193
|
+
* never be mistaken for the live one even if the new holder happens to
|
|
194
|
+
* share the same channel/operation/grantId. */
|
|
195
|
+
let currentHolderHandleId: symbol | null = null;
|
|
196
|
+
|
|
197
|
+
/** Plain array, used strictly FIFO: push() to enqueue, shift() to wake --
|
|
198
|
+
* per this module's own implementation requirement, never a priority queue,
|
|
199
|
+
* never a LIFO stack. */
|
|
200
|
+
let queue: QueueEntry[] = [];
|
|
201
|
+
|
|
202
|
+
function removeFromQueueById(id: symbol): QueueEntry | null {
|
|
203
|
+
const idx = queue.findIndex((e) => e.id === id);
|
|
204
|
+
if (idx === -1) return null;
|
|
205
|
+
const [entry] = queue.splice(idx, 1);
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The ONE place a caller is granted the lock, whether immediately (queue
|
|
210
|
+
* was empty) or via wakeNext() below (queue had waiters). Builds a fresh
|
|
211
|
+
* holder record and a fresh handle identity every time -- there is no
|
|
212
|
+
* second admission path. */
|
|
213
|
+
function grantLock(channel: MonitorChannel, operation: string, grantId: string | null): ChannelLockHandle {
|
|
214
|
+
const id = Symbol("channel-lock-handle");
|
|
215
|
+
const holder: ChannelLockHolder = { channel, operation, grantId, heldSince: Date.now() };
|
|
216
|
+
currentHolder = holder;
|
|
217
|
+
currentHolderHandleId = id;
|
|
218
|
+
let released = false;
|
|
219
|
+
return {
|
|
220
|
+
holder,
|
|
221
|
+
release: () => {
|
|
222
|
+
if (released) return;
|
|
223
|
+
released = true;
|
|
224
|
+
// Identity check: a stale handle whose lock has already been handed
|
|
225
|
+
// to a new holder (this handle's `id` no longer matches
|
|
226
|
+
// currentHolderHandleId) must not clear the NEW holder's state.
|
|
227
|
+
if (currentHolderHandleId !== id) return;
|
|
228
|
+
currentHolder = null;
|
|
229
|
+
currentHolderHandleId = null;
|
|
230
|
+
wakeNext();
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Hands the lock to the next queued waiter, synchronously -- never through
|
|
236
|
+
* a setTimeout, so release-to-grant handoff cannot be reordered by timer
|
|
237
|
+
* scheduling. No-op when the queue is empty. */
|
|
238
|
+
function wakeNext(): void {
|
|
239
|
+
const next = queue.shift();
|
|
240
|
+
if (!next) return;
|
|
241
|
+
if (next.timer) {
|
|
242
|
+
clearTimeout(next.timer);
|
|
243
|
+
next.timer = null;
|
|
244
|
+
}
|
|
245
|
+
const handle = grantLock(next.channel, next.operation, next.grantId);
|
|
246
|
+
next.resolve(handle);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The ONE internal admission function both acquireChannelLock() and its
|
|
250
|
+
* queueing path run through -- grants immediately when free, otherwise
|
|
251
|
+
* enqueues and resolves later via wakeNext(). */
|
|
252
|
+
function admit(channel: MonitorChannel, operation: string, grantId: string | null, timeoutMs: number): Promise<ChannelLockHandle> {
|
|
253
|
+
if (currentHolder === null) {
|
|
254
|
+
return Promise.resolve(grantLock(channel, operation, grantId));
|
|
255
|
+
}
|
|
256
|
+
return new Promise<ChannelLockHandle>((resolve, reject) => {
|
|
257
|
+
const id = Symbol("channel-lock-waiter");
|
|
258
|
+
const arrivedAt = Date.now();
|
|
259
|
+
const entry: QueueEntry = { id, channel, operation, grantId, arrivedAt, timer: null, resolve, reject };
|
|
260
|
+
entry.timer = setTimeout(() => {
|
|
261
|
+
const removed = removeFromQueueById(id);
|
|
262
|
+
if (!removed) {
|
|
263
|
+
// Already granted or already drained by resetChannelLockForTests()
|
|
264
|
+
// between the timer firing and this callback running -- nothing
|
|
265
|
+
// left to reject. Unreachable in the single-threaded JS event loop
|
|
266
|
+
// under normal operation, but defensive rather than assumed.
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Invariant: a queued entry can only exist while the lock is held --
|
|
270
|
+
// every release() immediately drains the queue via wakeNext() when
|
|
271
|
+
// non-empty, and admit() only enqueues when currentHolder !== null.
|
|
272
|
+
// So currentHolder is guaranteed non-null here.
|
|
273
|
+
const holder = currentHolder;
|
|
274
|
+
if (holder === null) {
|
|
275
|
+
reject(
|
|
276
|
+
new Error(
|
|
277
|
+
"channel-lock: internal invariant violation -- a queued waiter's timeout fired while no holder was recorded",
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
reject(new ChannelLockTimeoutError(holder, Date.now() - arrivedAt));
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
if (typeof entry.timer.unref === "function") entry.timer.unref();
|
|
285
|
+
queue.push(entry);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface AcquireChannelLockOptions {
|
|
290
|
+
channel: MonitorChannel;
|
|
291
|
+
operation: string;
|
|
292
|
+
grantId?: string | null;
|
|
293
|
+
timeoutMs?: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Grants the lock immediately when free; otherwise appends to the FIFO
|
|
298
|
+
* queue and resolves when the lock reaches this caller. On expiry of
|
|
299
|
+
* `timeoutMs` (default CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS) removes its own
|
|
300
|
+
* entry from the queue (by identity, so a later release() can never wake an
|
|
301
|
+
* already-rejected waiter) and rejects with ChannelLockTimeoutError carrying
|
|
302
|
+
* the holder record and the measured hold/wait durations.
|
|
303
|
+
*/
|
|
304
|
+
export function acquireChannelLock(opts: AcquireChannelLockOptions): Promise<ChannelLockHandle> {
|
|
305
|
+
return admit(opts.channel, opts.operation, opts.grantId ?? null, opts.timeoutMs ?? CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface TryAcquireChannelLockOptions {
|
|
309
|
+
channel: MonitorChannel;
|
|
310
|
+
operation: string;
|
|
311
|
+
grantId?: string | null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Fully synchronous, no `await` anywhere: grants and returns a handle when
|
|
316
|
+
* free, returns `null` immediately when held -- never queues. This is the
|
|
317
|
+
* entry point a diagnostic uses so that diagnosing contention never queues
|
|
318
|
+
* behind the holder it is diagnosing (consumed by vice_diagnose, plan
|
|
319
|
+
* 41-04).
|
|
320
|
+
*/
|
|
321
|
+
export function tryAcquireChannelLock(opts: TryAcquireChannelLockOptions): ChannelLockHandle | null {
|
|
322
|
+
if (currentHolder !== null) return null;
|
|
323
|
+
return grantLock(opts.channel, opts.operation, opts.grantId ?? null);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A read-only COPY of the holder record -- never the live object, so a
|
|
327
|
+
* caller cannot mutate module state by holding onto what this returns.
|
|
328
|
+
* `null` when nothing holds the lock. */
|
|
329
|
+
export function currentChannelLockHolder(): ChannelLockHolder | null {
|
|
330
|
+
if (currentHolder === null) return null;
|
|
331
|
+
return { ...currentHolder };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Clears the holder and drains the queue by rejecting every waiter --
|
|
336
|
+
* exists only so a test file can start from a known state, in the register
|
|
337
|
+
* stock-dispatch.ts's own clearHeldStockSession() already establishes.
|
|
338
|
+
* Never called from production code.
|
|
339
|
+
*/
|
|
340
|
+
export function resetChannelLockForTests(): void {
|
|
341
|
+
currentHolder = null;
|
|
342
|
+
currentHolderHandleId = null;
|
|
343
|
+
const pending = queue;
|
|
344
|
+
queue = [];
|
|
345
|
+
for (const entry of pending) {
|
|
346
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
347
|
+
entry.reject(new Error("channel-lock: resetChannelLockForTests() drained this waiter"));
|
|
348
|
+
}
|
|
349
|
+
}
|
package/evid-ingest.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// evid-ingest.ts
|
|
3
|
+
//
|
|
4
|
+
// Plan 43-05 (EVID-01, EVID-04): THE ONE PLACE a parsed `memmapshow` access
|
|
5
|
+
// map (`textmon-memmap.ts`'s `AccessMap`) becomes durable-store-shaped
|
|
6
|
+
// observation rows. `anno-tools.ts`'s `anno_evid_ingest` dispatch arm calls
|
|
7
|
+
// this module's pure functions and then, and only then, writes what they
|
|
8
|
+
// return through `anno-store.ts`'s `insertExecObservations`.
|
|
9
|
+
//
|
|
10
|
+
// THE ONE POSITIVE FACT THIS LAYER IS LICENSED TO ASSERT (quoted from this
|
|
11
|
+
// plan's own objective, verbatim, so a later reader never has to re-derive
|
|
12
|
+
// it): "A row is written if and only if an address's `ram.execute`,
|
|
13
|
+
// `rom.execute` or `io.execute` flag is true in the parsed reply. Nothing
|
|
14
|
+
// else produces a row." An address `memmapshow` mentioned with read or
|
|
15
|
+
// write access but no execute produces no row, and an address absent from
|
|
16
|
+
// the sparse `entries` array produces no row either -- those are two
|
|
17
|
+
// DIFFERENT facts about the world, and neither is recoverable from the
|
|
18
|
+
// store's row set alone, only from the reply itself. That is correct and is
|
|
19
|
+
// the point: the store holds observed execution, and the absence of a row
|
|
20
|
+
// is not a fact the store asserts about the address. It is the absence of
|
|
21
|
+
// an assertion.
|
|
22
|
+
//
|
|
23
|
+
// Read-and-write-only observations are deliberately NOT stored here. The
|
|
24
|
+
// schema can gain a separate table for them later without a migration,
|
|
25
|
+
// because it would be a wholly new table -- the store's existing additive
|
|
26
|
+
// discipline.
|
|
27
|
+
//
|
|
28
|
+
// WHY THE VERB TAKES AN ARGV ARRAY AND NEVER A DIGEST: a caller-supplied
|
|
29
|
+
// pre-computed digest would let a caller invent a run identity, a second
|
|
30
|
+
// notion of sameness sitting beside EVID-01's own. `runIdentityFrom()`
|
|
31
|
+
// below computes `argvDigest` itself, from the exact `argv` the caller
|
|
32
|
+
// supplies, through the ONE shipped digest function
|
|
33
|
+
// (`capture-predicate.ts`'s `argvDigest()`) and nothing else -- there is no
|
|
34
|
+
// code path here that accepts a digest as input.
|
|
35
|
+
//
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// WHAT NOT TO DO -- each of these is a specific, named trap
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// 1. NEVER write a row from an address's ABSENCE. `execObservationsFrom()`
|
|
40
|
+
// only ever iterates `map.entries` -- the sparse array VICE actually
|
|
41
|
+
// sent -- and never pads it back out to the full address space. An
|
|
42
|
+
// address absent from `entries` produces nothing, silently, by
|
|
43
|
+
// construction: there is no branch here that could emit a row for it.
|
|
44
|
+
// 2. NEVER merge two banks' execute bits into one row. `ram.execute`,
|
|
45
|
+
// `rom.execute` and `io.execute` are three independent facts
|
|
46
|
+
// (`textmon-memmap.ts`'s own header states this for `AccessFlags`);
|
|
47
|
+
// this module emits at most one observation per (address, bank) pair,
|
|
48
|
+
// never a combined "any bank executed" row.
|
|
49
|
+
// 3. NEVER accept a pre-computed argv digest. `runIdentityFrom()`'s ONLY
|
|
50
|
+
// input for identity is `argv` itself; there is no `argvDigest`
|
|
51
|
+
// parameter anywhere in this module's exported surface.
|
|
52
|
+
// 4. NEVER open a store here. This module imports nothing from
|
|
53
|
+
// `anno-store.ts` and touches no filesystem, transport or
|
|
54
|
+
// child-process -- the write happens in `anno-tools.ts`'s dispatch
|
|
55
|
+
// arm, which is what keeps this module out of `anno-seam.test.ts`'s
|
|
56
|
+
// single-consumer set (`anno-store.ts` remains the one module naming
|
|
57
|
+
// `node:sqlite`).
|
|
58
|
+
// 5. NEVER read `.read` or `.write` off an `AccessFlags` value anywhere in
|
|
59
|
+
// this file. Only `.execute` is ever inspected -- read-and-write-only
|
|
60
|
+
// access is deliberately not this layer's concern (see the header
|
|
61
|
+
// above).
|
|
62
|
+
import type { AccessMap, AccessMapParseResult } from "./textmon-memmap.ts";
|
|
63
|
+
import { argvDigest } from "./capture-predicate.ts";
|
|
64
|
+
import { EVID_SOURCE_BANKS, type EvidSourceBank } from "./anno-types.ts";
|
|
65
|
+
import { ViceError } from "./vice-errors.ts";
|
|
66
|
+
|
|
67
|
+
/** One observed execute bit: this address, in this source bank, was seen
|
|
68
|
+
* executing. There is no third field -- an `ExecObservation` carries no
|
|
69
|
+
* opinion about whether the address is "code" or "data"; it is a single,
|
|
70
|
+
* narrow fact about what the emulator actually did. */
|
|
71
|
+
export interface ExecObservation {
|
|
72
|
+
readonly address: number;
|
|
73
|
+
readonly sourceBank: EvidSourceBank;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Exactly 64 lowercase hex characters -- the shape a sha256 digest (an
|
|
77
|
+
* image hash, or `argvDigest()`'s own output) always takes. This module's
|
|
78
|
+
* OWN copy of the check, deliberately not imported from `anno-types.ts`
|
|
79
|
+
* (this file's import list is closed to exactly the five named imports
|
|
80
|
+
* above): the caller-supplied `imageSha256` never reaches a digest
|
|
81
|
+
* function here, so there is nothing to route through `argvDigest`'s own
|
|
82
|
+
* shape, and duplicating a four-line regex check is cheaper than widening
|
|
83
|
+
* this module's import surface for it. */
|
|
84
|
+
const RUN_IDENTITY_DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
85
|
+
|
|
86
|
+
/** The exact launch identity a caller must supply. `runClass` exists ONLY
|
|
87
|
+
* on plan 43-01's `promote` branch decision and is never read by this
|
|
88
|
+
* module's `no-change` implementation -- carried in the type so a future
|
|
89
|
+
* `promote` branch has somewhere to put it without a second interface. */
|
|
90
|
+
export interface IngestRunIdentity {
|
|
91
|
+
readonly imageSha256: string;
|
|
92
|
+
readonly argv: readonly string[];
|
|
93
|
+
readonly seed: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `runIdentityFrom()`'s answer: the bare `(imageSha256, argvDigest, seed)`
|
|
97
|
+
* triple the `no-change` run-identity decision selected (plan 43-01,
|
|
98
|
+
* `docs/phase43-instrumentation-perturbation-ab.md`) -- no `runClass`
|
|
99
|
+
* discriminator field exists on this type. */
|
|
100
|
+
export interface RunIdentity {
|
|
101
|
+
readonly imageSha256: string;
|
|
102
|
+
readonly argvDigest: string;
|
|
103
|
+
readonly seed: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Validates `identity.imageSha256` and `identity.seed`, then computes
|
|
107
|
+
* `argvDigest` from `identity.argv` through the ONE shipped digest function
|
|
108
|
+
* -- and nothing else. Refuses BY NAME, throwing inside the `ViceError`
|
|
109
|
+
* family so the anno never-throw boundary (`anno-tools.ts`'s
|
|
110
|
+
* `runAnnoTool()`) can name the class in its `{isError:true}` answer:
|
|
111
|
+
*
|
|
112
|
+
* - `imageSha256` that is not exactly 64 lowercase hex characters
|
|
113
|
+
* (the program image is named by its bytes; a malformed digest here
|
|
114
|
+
* would silently key a run under the wrong image identity forever).
|
|
115
|
+
* - `seed` that is not a non-empty string.
|
|
116
|
+
* - `argv` that is not an array, or an empty array -- both refusals are
|
|
117
|
+
* `argvDigest()`'s OWN (this function never duplicates that check; it
|
|
118
|
+
* lets `argvDigest()` throw and re-wraps the message as a `ViceError`
|
|
119
|
+
* so the caller sees one error family regardless of which check fired).
|
|
120
|
+
*
|
|
121
|
+
* `argvDigest()` is the ONLY place `argv` becomes an identity: there is no
|
|
122
|
+
* second hashing site here, and no parameter anywhere on this module's
|
|
123
|
+
* surface through which a caller could hand in an already-computed digest.
|
|
124
|
+
*/
|
|
125
|
+
export function runIdentityFrom(identity: IngestRunIdentity): RunIdentity {
|
|
126
|
+
if (typeof identity?.imageSha256 !== "string" || !RUN_IDENTITY_DIGEST_RE.test(identity.imageSha256)) {
|
|
127
|
+
throw new ViceError(
|
|
128
|
+
`runIdentityFrom: imageSha256 ${JSON.stringify(identity?.imageSha256)} is not exactly 64 lowercase hex characters -- ` +
|
|
129
|
+
"expected the sha256 digest of the program image's own bytes.",
|
|
130
|
+
{ code: "evid-ingest-bad-image-sha256" },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (typeof identity.seed !== "string" || identity.seed.length === 0) {
|
|
134
|
+
throw new ViceError(`runIdentityFrom: seed ${JSON.stringify(identity.seed)} is not a non-empty string.`, {
|
|
135
|
+
code: "evid-ingest-bad-seed",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let digest: string;
|
|
140
|
+
try {
|
|
141
|
+
digest = argvDigest(identity.argv);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
144
|
+
throw new ViceError(`runIdentityFrom: ${reason}`, { code: "evid-ingest-bad-argv" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { imageSha256: identity.imageSha256, argvDigest: digest, seed: identity.seed };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The pure transform (EVID-01, EVID-04): one observation per (address,
|
|
152
|
+
* bank) pair whose `execute` flag is `true` in `map.entries`, sorted
|
|
153
|
+
* ascending by address then by the bank's index in `EVID_SOURCE_BANKS`'s
|
|
154
|
+
* frozen order (`ram`, `rom`, `io`). One pass over `map.entries`; never
|
|
155
|
+
* reads `.read` or `.write`; never derives an observation from an address's
|
|
156
|
+
* absence, because the loop only ever visits addresses `map.entries`
|
|
157
|
+
* actually contains.
|
|
158
|
+
*/
|
|
159
|
+
export function execObservationsFrom(map: AccessMap): ExecObservation[] {
|
|
160
|
+
const observations: ExecObservation[] = [];
|
|
161
|
+
for (const entry of map.entries) {
|
|
162
|
+
for (const bank of EVID_SOURCE_BANKS) {
|
|
163
|
+
if (entry[bank].execute) {
|
|
164
|
+
observations.push({ address: entry.address, sourceBank: bank });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
observations.sort((a, b) => {
|
|
169
|
+
if (a.address !== b.address) return a.address - b.address;
|
|
170
|
+
return EVID_SOURCE_BANKS.indexOf(a.sourceBank) - EVID_SOURCE_BANKS.indexOf(b.sourceBank);
|
|
171
|
+
});
|
|
172
|
+
return observations;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `ingestAccessMap()`'s success arm: the derived run identity plus the
|
|
176
|
+
* sorted observation list -- exactly what `anno-tools.ts`'s dispatch arm
|
|
177
|
+
* hands to `insertExecObservations()` in one call. */
|
|
178
|
+
export interface IngestAccessMapOk {
|
|
179
|
+
readonly ok: true;
|
|
180
|
+
readonly runIdentity: RunIdentity;
|
|
181
|
+
readonly observations: readonly ExecObservation[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `ingestAccessMap()`'s refusal arm: the parse refusal's own code, line
|
|
185
|
+
* number and offending line, carried through UNABSORBED -- a drifted or
|
|
186
|
+
* malformed `memmapshow` reply is never silently treated as a zero-entry
|
|
187
|
+
* capture. */
|
|
188
|
+
export interface IngestAccessMapRefusal {
|
|
189
|
+
readonly ok: false;
|
|
190
|
+
readonly message: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type IngestAccessMapResult = IngestAccessMapOk | IngestAccessMapRefusal;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The one join of a parse result and a run identity into observations ready
|
|
197
|
+
* to write. THIS FUNCTION DOES NOT WRITE -- it is pure, and the store write
|
|
198
|
+
* happens in `anno-tools.ts`'s dispatch arm.
|
|
199
|
+
*
|
|
200
|
+
* On `parsed.ok === false`, returns the refusal form carrying the refusal's
|
|
201
|
+
* own code, line number and offending line, and touches nothing else --
|
|
202
|
+
* neither `runIdentityFrom()` nor `execObservationsFrom()` is ever called on
|
|
203
|
+
* a refused parse, because there is nothing in a refusal to derive either
|
|
204
|
+
* from.
|
|
205
|
+
*/
|
|
206
|
+
export function ingestAccessMap(parsed: AccessMapParseResult, identity: IngestRunIdentity): IngestAccessMapResult {
|
|
207
|
+
if (!parsed.ok) {
|
|
208
|
+
const { refusal } = parsed;
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
message: `memmapshow reply refused [${refusal.code}] at line ${refusal.lineNumber}: ${refusal.message} (offending line: ${JSON.stringify(refusal.line)})`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const runIdentity = runIdentityFrom(identity);
|
|
215
|
+
const observations = execObservationsFrom(parsed.value);
|
|
216
|
+
return { ok: true, runIdentity, observations };
|
|
217
|
+
}
|