@henols/vice-mcp 0.2.2 → 0.2.4
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 +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- 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 +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- 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 +253 -108
- 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/stock-dispatch.ts
CHANGED
|
@@ -24,11 +24,13 @@
|
|
|
24
24
|
// one place a tools/call for the stock backend is routed from.
|
|
25
25
|
// - Never acquire a broker lease here (Task 2's own ensureStockSession()
|
|
26
26
|
// header comment explains this prohibition fully).
|
|
27
|
+
// - Never acquire channel-lock.ts's mutex anywhere but inside
|
|
28
|
+
// withChannelLockHeld() (plan 41-02, CHAN-04) -- that is the ONE acquire
|
|
29
|
+
// site on the binary side, and it wraps the delegated HANDLER call only,
|
|
30
|
+
// never the session-acquisition preamble above it.
|
|
27
31
|
import { resolve, join } from "node:path";
|
|
28
32
|
|
|
29
|
-
import type {
|
|
30
|
-
import type { ToolInfo } from "./vice.ts";
|
|
31
|
-
import { capabilityRefusalMessage } from "./capability-registry.ts";
|
|
33
|
+
import type { ToolInfo } from "./vice-errors.ts";
|
|
32
34
|
import { type HeldLease } from "./vice-broker-client.ts";
|
|
33
35
|
import { stockConnect, stockDisconnect, stockReconnect, type StockConnectSession, type StockConnectDeps } from "./stock-connect.ts";
|
|
34
36
|
import {
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
} from "./stock-handler.ts";
|
|
44
46
|
import { attachRunStateTracker } from "./stock-runstate.ts";
|
|
45
47
|
import { STOCK_DERIVED_TOOLS, type DerivedPureHandler } from "./stock-derived.ts";
|
|
48
|
+
import { acquireChannelLock, ChannelLockTimeoutError } from "./channel-lock.ts";
|
|
46
49
|
|
|
47
50
|
// The six family modules (plans 03-06 through 03-11) -- each exports its
|
|
48
51
|
// tools as StockSessionHandler-shaped values; this file (D-09) is the ONE
|
|
@@ -72,6 +75,7 @@ import { handleCyclesStopwatch, forgetTimingForOtherTargets } from "./stock-timi
|
|
|
72
75
|
import { handleRunUntil } from "./stock-run-until.ts";
|
|
73
76
|
import { handleDiagnoseStock } from "./stock-diagnose.ts";
|
|
74
77
|
import { handleRecycleStock } from "./stock-recycle.ts";
|
|
78
|
+
import { handleDeviceConsole, handleWarpSet, handleMemmapShow, handleMemmapZap, handleCpuHistory, handleProfileFlat, handleBacktrace, handleIoRegisters } from "./text-tools.ts";
|
|
75
79
|
|
|
76
80
|
// Re-exported so Phase 2's existing import surface (and its 921-line test
|
|
77
81
|
// file) keeps working unchanged -- these four names used to be DEFINED
|
|
@@ -86,21 +90,26 @@ export type { StockToolResult, StockOkResult, StockErrorResult };
|
|
|
86
90
|
// ---------------------------------------------------------------------------
|
|
87
91
|
|
|
88
92
|
/**
|
|
89
|
-
* Resolves
|
|
93
|
+
* Resolves the one manifest file backing the stock backend's advertised tool
|
|
90
94
|
* surface, following the EXACT override precedence vice-proxy.ts's own
|
|
91
95
|
* manifestPath() already establishes: an explicit VICE_TOOLS_MANIFEST value
|
|
92
96
|
* (passed in as `envOverride`, never read from process.env directly here --
|
|
93
|
-
* this function stays a pure, injectable seam) wins
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
97
|
+
* this function stays a pure, injectable seam) wins, unchanged; otherwise the
|
|
98
|
+
* committed default file beside `hereDir` is used. `envOverride` is
|
|
99
|
+
* deliberately a plain parameter, not a process.env read, so this function
|
|
100
|
+
* has no hidden global dependency and a test can drive both cases without
|
|
101
|
+
* mutating the real environment.
|
|
102
|
+
*
|
|
103
|
+
* FORKRM-05 (plan 52-07): this used to take a `backend` parameter selecting
|
|
104
|
+
* between two committed manifest files -- there is one manifest now, so the
|
|
105
|
+
* parameter is gone rather than pinned to a literal no caller could ever
|
|
106
|
+
* vary.
|
|
98
107
|
*/
|
|
99
|
-
export function manifestPathForBackend(
|
|
108
|
+
export function manifestPathForBackend(hereDir: string, envOverride: string | undefined): string {
|
|
100
109
|
if (envOverride) {
|
|
101
110
|
return resolve(envOverride);
|
|
102
111
|
}
|
|
103
|
-
return
|
|
112
|
+
return join(hereDir, "tools-manifest.stock.json");
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/**
|
|
@@ -108,44 +117,38 @@ export function manifestPathForBackend(backend: ViceBackend, hereDir: string, en
|
|
|
108
117
|
* is a name-keyed record: whichever assignment to a given key runs LAST
|
|
109
118
|
* wins, and vice-proxy.ts's own registration order used to assign
|
|
110
119
|
* RECYCLE_TOOL/DIAGNOSE_TOOL's literal (fork-worded) definitions
|
|
111
|
-
* UNCONDITIONALLY,
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* corrected stock manifest entry sat right there in
|
|
120
|
+
* UNCONDITIONALLY, straight after the manifest loop had already populated
|
|
121
|
+
* the same keys correctly. `tools/list` served the fork's five-verdict
|
|
122
|
+
* vocabulary -- including `stale_read_path`, which stock cannot produce
|
|
123
|
+
* (D-03) -- and omitted `monitor_held_elsewhere`, which stock can, even
|
|
124
|
+
* though the corrected stock manifest entry sat right there in
|
|
117
125
|
* tools-manifest.stock.json, unread. This function is the ONE place that
|
|
118
126
|
* decision is now made, so the manifest loop's own per-tool selection and
|
|
119
127
|
* the two synthetic tools' registration agree.
|
|
120
128
|
*
|
|
121
|
-
* Behaviour:
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* (T-07-16-02).
|
|
131
|
-
* - NEVER merges fields from the two definitions. Picking one whole
|
|
132
|
-
* definition keeps `description`, `inputSchema` and `outputSchema`
|
|
133
|
-
* internally consistent; a field-by-field merge could pair a fork
|
|
134
|
-
* description with a stock `outputSchema`, or the reverse.
|
|
129
|
+
* Behaviour: returns the `manifestTools` entry whose `name` equals
|
|
130
|
+
* `syntheticDef.name`, if one exists. Falls back to `syntheticDef` when no
|
|
131
|
+
* match exists -- `readManifestTools()`'s own malformed/unreadable-manifest
|
|
132
|
+
* fallbacks answer `[]`, and in that case the proxy must still advertise a
|
|
133
|
+
* WORKING tool rather than none at all (T-07-16-02). NEVER merges fields
|
|
134
|
+
* from the two definitions -- picking one whole definition keeps
|
|
135
|
+
* `description`, `inputSchema` and `outputSchema` internally consistent; a
|
|
136
|
+
* field-by-field merge could pair one definition's description with the
|
|
137
|
+
* other's `outputSchema`.
|
|
135
138
|
*
|
|
136
139
|
* Declared as a `function`, not a `const` arrow, per this module tree's own
|
|
137
140
|
* standing rule: stock-dispatch.ts <-> stock-diagnose.ts <-> stock-recycle.ts
|
|
138
141
|
* form a runtime import cycle, and the phase already reproduced a live
|
|
139
142
|
* `ReferenceError` from a `const` handler export sitting in that cycle.
|
|
143
|
+
*
|
|
144
|
+
* FORKRM-05 (plan 52-07): this used to take a `backend` parameter and
|
|
145
|
+
* return `syntheticDef` unchanged when it was `"fork"` -- vice-proxy.ts's
|
|
146
|
+
* two call sites always passed the literal `"stock"`, so that branch was
|
|
147
|
+
* dead from the moment plan 52-06 collapsed backend detection. Removed
|
|
148
|
+
* rather than left as an unreachable branch a reader could mistake for live
|
|
149
|
+
* code.
|
|
140
150
|
*/
|
|
141
|
-
export function resolveAdvertisedToolDefinition(
|
|
142
|
-
syntheticDef: ToolInfo,
|
|
143
|
-
backend: ViceBackend,
|
|
144
|
-
manifestTools: ToolInfo[],
|
|
145
|
-
): ToolInfo {
|
|
146
|
-
if (backend === "fork") {
|
|
147
|
-
return syntheticDef;
|
|
148
|
-
}
|
|
151
|
+
export function resolveAdvertisedToolDefinition(syntheticDef: ToolInfo, manifestTools: ToolInfo[]): ToolInfo {
|
|
149
152
|
const manifestEntry = manifestTools.find((t) => t.name === syntheticDef.name);
|
|
150
153
|
return manifestEntry ?? syntheticDef;
|
|
151
154
|
}
|
|
@@ -186,8 +189,8 @@ export type LeaseProvider = () => Promise<{ ok: true; lease: HeldLease | null }
|
|
|
186
189
|
* never implies resolution it did not achieve. It is a plain string handed down from vice-proxy.ts's
|
|
187
190
|
* OWN single, module-scope call to `resolvedBackend()` (see that file's own
|
|
188
191
|
* "resolve the active backend once" discipline) -- this module must never
|
|
189
|
-
* call `resolvedBackend()
|
|
190
|
-
*
|
|
192
|
+
* call `resolvedBackend()` itself, per backend-detect.mts's own "do not
|
|
193
|
+
* call this per tool or per call" prohibition. Omitted entirely
|
|
191
194
|
* (never expected in production) falls back to an empty string rather than
|
|
192
195
|
* throwing.
|
|
193
196
|
*/
|
|
@@ -202,6 +205,12 @@ export interface StockDispatchDeps {
|
|
|
202
205
|
* Omitted defaults to `false` -- the honest answer when nothing said
|
|
203
206
|
* otherwise. */
|
|
204
207
|
resolvedBinaryPathIsResolved?: boolean;
|
|
208
|
+
/** Test-only override of channel-lock.ts's acquire bound for THIS call's
|
|
209
|
+
* withChannelLockHeld() wrapping. Production call sites never set this --
|
|
210
|
+
* they always take channel-lock.ts's own CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS
|
|
211
|
+
* default. Exists so a test can observe a ChannelLockTimeoutError (and its
|
|
212
|
+
* refusal text) without waiting out the real ~630-second default. */
|
|
213
|
+
channelLockTimeoutMs?: number;
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
export type EnsureStockSessionOutcome = { ok: true; session: StockConnectSession } | { ok: false; message: string };
|
|
@@ -422,7 +431,7 @@ export async function ensureStockSession(deps: StockDispatchDeps): Promise<Ensur
|
|
|
422
431
|
* come from the lease vice-proxy.ts built (see HeldLease's own field comments
|
|
423
432
|
* for why they are two DIFFERENT directories), and `binPath` is the same
|
|
424
433
|
* already-settled `resolvedBinaryPath` vice_ping reports -- this module must
|
|
425
|
-
* never call resolvedBackend()
|
|
434
|
+
* never call resolvedBackend() itself.
|
|
426
435
|
*
|
|
427
436
|
* An empty string is treated as ABSENT rather than passed through: the two
|
|
428
437
|
* consumers both branch on truthiness, and passing "" would key a capability
|
|
@@ -462,6 +471,53 @@ export { stockDisconnect };
|
|
|
462
471
|
* prohibits). */
|
|
463
472
|
export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDeps) => Promise<StockToolResult>;
|
|
464
473
|
|
|
474
|
+
/**
|
|
475
|
+
* withChannelLockHeld -- acquires channel-lock.ts's mutex for
|
|
476
|
+
* `channel: "binary"` around `fn`, releasing in a `finally` so a throwing
|
|
477
|
+
* `fn` still releases (D-05). This is the ONE acquire site on the binary
|
|
478
|
+
* side; withStockSession() and withDerivedTool()'s `needsSession: true`
|
|
479
|
+
* branch both call it, and neither may acquire the lock any other way.
|
|
480
|
+
*
|
|
481
|
+
* This is what makes the lock's critical section span a whole LOGICAL
|
|
482
|
+
* operation, not a single wire command: `vice_run_until`'s wait
|
|
483
|
+
* (stock-run-until.ts's `waitForCheckpointHit()`) and the reproducible-run
|
|
484
|
+
* path's wait (stock-reproducible-run.ts's `waitForReproducibleStop()`,
|
|
485
|
+
* reached through `runReproducible()`) both run INSIDE the wrapped `fn`, so
|
|
486
|
+
* the lock stays held across resume -> wait -> observe without either wait
|
|
487
|
+
* path being re-cut.
|
|
488
|
+
*
|
|
489
|
+
* FORBIDDEN ALTERNATIVE, named here because it is the obvious-looking wrong
|
|
490
|
+
* design: acquiring and releasing this lock around each individual wire
|
|
491
|
+
* command instead of around the whole handler call. A per-wire-command lock
|
|
492
|
+
* preserves the resume count while destroying what the count protects -- a
|
|
493
|
+
* foreign command (e.g. a text-channel command) can land in the gap between
|
|
494
|
+
* "resume sent" and "checkpoint observed", halting a machine that was
|
|
495
|
+
* supposed to be running toward the checkpoint, so the checkpoint never
|
|
496
|
+
* fires even though no protocol invariant was technically violated per
|
|
497
|
+
* command (41-RESEARCH.md Pitfall 6).
|
|
498
|
+
*
|
|
499
|
+
* A `ChannelLockTimeoutError` is converted into refusal text using the
|
|
500
|
+
* error's OWN message verbatim -- it is already `channelLockRefusalMessage()`'s
|
|
501
|
+
* output -- and NEVER routed through `convertWireError()`, which would
|
|
502
|
+
* re-frame a legitimate ownership statement as a wire fault.
|
|
503
|
+
*/
|
|
504
|
+
async function withChannelLockHeld(toolName: string, timeoutMs: number | undefined, fn: () => Promise<StockToolResult>): Promise<StockToolResult> {
|
|
505
|
+
let handle;
|
|
506
|
+
try {
|
|
507
|
+
handle = await acquireChannelLock({ channel: "binary", operation: toolName, timeoutMs });
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (err instanceof ChannelLockTimeoutError) {
|
|
510
|
+
return isErrorText(err.message);
|
|
511
|
+
}
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
return await fn();
|
|
516
|
+
} finally {
|
|
517
|
+
handle.release();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
465
521
|
/**
|
|
466
522
|
* withStockSession -- THE ONE adapter every STOCK_DISPATCH_TABLE entry goes
|
|
467
523
|
* through (Task 1, plan 03-12). Before this existed, `viceHandlerPing` was
|
|
@@ -489,6 +545,17 @@ export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDe
|
|
|
489
545
|
* Code for the rest of the session (T-3-04) -- a single escaped
|
|
490
546
|
* exception here would silently end the session's entire tool surface,
|
|
491
547
|
* not just this one call.
|
|
548
|
+
*
|
|
549
|
+
* Step 3's own try/catch runs INSIDE withChannelLockHeld(toolName, ...)
|
|
550
|
+
* (plan 41-02, CHAN-04): the session-acquisition step above (step 1/2) is
|
|
551
|
+
* NOT covered by the lock -- only the delegated handler call is -- so a
|
|
552
|
+
* broker liveness classification or a handshake failure never queues behind
|
|
553
|
+
* the OTHER channel's halt authority. This is what makes D-05's critical
|
|
554
|
+
* section span a whole logical operation: `vice_run_until`'s and the
|
|
555
|
+
* reproducible-run path's waits run inside their handlers, so the lock is
|
|
556
|
+
* held across resume -> wait -> observe without either wait path being
|
|
557
|
+
* re-cut (see withChannelLockHeld()'s own header for the forbidden
|
|
558
|
+
* per-wire-command alternative).
|
|
492
559
|
*/
|
|
493
560
|
export function withStockSession(toolName: string, handler: StockSessionHandler): StockHandler {
|
|
494
561
|
return async (args, deps) => {
|
|
@@ -503,11 +570,13 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
|
|
|
503
570
|
return isErrorText(outcome.message);
|
|
504
571
|
}
|
|
505
572
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
573
|
+
return withChannelLockHeld(toolName, deps.channelLockTimeoutMs, async () => {
|
|
574
|
+
try {
|
|
575
|
+
return await handler(args, outcome.session, deps);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
return convertWireError(toolName, err);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
511
580
|
};
|
|
512
581
|
}
|
|
513
582
|
|
|
@@ -531,7 +600,14 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
|
|
|
531
600
|
* calls ensureStockSession() at all -- not a lighter-weight variant of it
|
|
532
601
|
* (04-RESEARCH.md Pitfall 3) -- and invokes `handler(args, deps)` inside a
|
|
533
602
|
* single try/catch converting through convertWireError(), so the
|
|
534
|
-
* never-throw boundary still holds.
|
|
603
|
+
* never-throw boundary still holds. This branch also NEVER acquires
|
|
604
|
+
* channel-lock.ts's mutex (plan 41-02, CHAN-04): taking halt authority for a
|
|
605
|
+
* pure client-side computation that never touches the wire would block a
|
|
606
|
+
* REAL halting operation for no reason. `vice_diagnose` is also registered
|
|
607
|
+
* `needsSession: false` and therefore also does not acquire here -- its own
|
|
608
|
+
* handler takes the lock with `tryAcquireChannelLock()` (channel-lock.ts)
|
|
609
|
+
* instead, deliberately, so that diagnosing contention never queues behind
|
|
610
|
+
* the holder it is diagnosing (plan 41-04).
|
|
535
611
|
*
|
|
536
612
|
* `needsSession: true` runs the EXACT same three-step preamble
|
|
537
613
|
* withStockSession() runs, reusing the same imported converters -- never a
|
|
@@ -539,7 +615,11 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
|
|
|
539
615
|
* inside its own try/catch -> convertHandshakeError(toolName, err); a
|
|
540
616
|
* `{ ok: false }` outcome returns outcome.message verbatim through
|
|
541
617
|
* isErrorText(), never re-worded; otherwise handler(args, outcome.session, deps)
|
|
542
|
-
* inside a SECOND try/catch -> convertWireError(toolName, err)
|
|
618
|
+
* inside a SECOND try/catch -> convertWireError(toolName, err), with that
|
|
619
|
+
* second try/catch running inside withChannelLockHeld(toolName, ...) --
|
|
620
|
+
* exactly the same wrapping withStockSession() applies, and for the same
|
|
621
|
+
* reason (see that function's own comment on the forbidden per-wire-command
|
|
622
|
+
* alternative).
|
|
543
623
|
*/
|
|
544
624
|
export function withDerivedTool(toolName: string, opts: { needsSession: true }, handler: StockSessionHandler): StockHandler;
|
|
545
625
|
export function withDerivedTool(toolName: string, opts: { needsSession: false }, handler: DerivedPureHandler): StockHandler;
|
|
@@ -572,11 +652,13 @@ export function withDerivedTool(
|
|
|
572
652
|
return isErrorText(outcome.message);
|
|
573
653
|
}
|
|
574
654
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
655
|
+
return withChannelLockHeld(toolName, deps.channelLockTimeoutMs, async () => {
|
|
656
|
+
try {
|
|
657
|
+
return await (handler as StockSessionHandler)(args, outcome.session, deps);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
return convertWireError(toolName, err);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
580
662
|
};
|
|
581
663
|
}
|
|
582
664
|
|
|
@@ -713,9 +795,9 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
|
|
|
713
795
|
vice_run_until: withDerivedTool("vice_run_until", { needsSession: true }, handleRunUntil),
|
|
714
796
|
|
|
715
797
|
// derived (TIME-04) -- the two proxy-local synthetic tools (RECYCLE_TOOL/
|
|
716
|
-
// DIAGNOSE_TOOL in vice-proxy.ts),
|
|
717
|
-
//
|
|
718
|
-
// transport. Deliberate asymmetry, documented at this call site (see also
|
|
798
|
+
// DIAGNOSE_TOOL in vice-proxy.ts), registered via buildViceTool() and
|
|
799
|
+
// routed to this table's own dispatchStock() entry point, never to the
|
|
800
|
+
// deleted fork transport. Deliberate asymmetry, documented at this call site (see also
|
|
719
801
|
// DerivedPureHandler's amended doc comment in stock-derived.ts):
|
|
720
802
|
// vice_diagnose uses needsSession:false because its own handler acquires
|
|
721
803
|
// the session itself (inside its own try/catch) so it can convert a
|
|
@@ -726,6 +808,30 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
|
|
|
726
808
|
// evidence and has no verdict vocabulary of its own to preserve.
|
|
727
809
|
vice_diagnose: withDerivedTool("vice_diagnose", { needsSession: false }, handleDiagnoseStock),
|
|
728
810
|
vice_recycle: withDerivedTool("vice_recycle", { needsSession: true }, handleRecycleStock),
|
|
811
|
+
|
|
812
|
+
// text-channel remedy tools (plan 41-06, CHAN-03). needsSession:false,
|
|
813
|
+
// deliberately -- NOT withStockSession()/withDerivedTool(needsSession:
|
|
814
|
+
// true): both of those wrap the whole handler call in
|
|
815
|
+
// withChannelLockHeld("binary", ...), and each handler below takes its OWN
|
|
816
|
+
// channel-lock.ts acquire for `channel: "text"` internally
|
|
817
|
+
// (withTextChannelLock(), text-protocol.ts). Registering through either
|
|
818
|
+
// binary-locking adapter would nest a second acquireChannelLock() call
|
|
819
|
+
// inside the first (channel-lock.ts is one single, non-reentrant mutex
|
|
820
|
+
// across both channels) -- a self-deadlock that only resolves by expiring
|
|
821
|
+
// CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS. See text-tools.ts's own header comment
|
|
822
|
+
// (ADAPTER CHOICE) for the full measurement. Neither handler needs a
|
|
823
|
+
// binary session at all -- both resolve the lease via deps.ensureLease()
|
|
824
|
+
// themselves and dial only through textConnect() -- so needsSession:false
|
|
825
|
+
// is not merely the deadlock-avoiding choice, it is also the structurally
|
|
826
|
+
// correct one, matching vice_diagnose's own precedent above.
|
|
827
|
+
vice_device_console: withDerivedTool("vice_device_console", { needsSession: false }, handleDeviceConsole),
|
|
828
|
+
vice_warp_set: withDerivedTool("vice_warp_set", { needsSession: false }, handleWarpSet),
|
|
829
|
+
vice_memmap_show: withDerivedTool("vice_memmap_show", { needsSession: false }, handleMemmapShow),
|
|
830
|
+
vice_memmap_zap: withDerivedTool("vice_memmap_zap", { needsSession: false }, handleMemmapZap),
|
|
831
|
+
vice_cpu_history: withDerivedTool("vice_cpu_history", { needsSession: false }, handleCpuHistory),
|
|
832
|
+
vice_profile_flat: withDerivedTool("vice_profile_flat", { needsSession: false }, handleProfileFlat),
|
|
833
|
+
vice_backtrace: withDerivedTool("vice_backtrace", { needsSession: false }, handleBacktrace),
|
|
834
|
+
vice_io_registers: withDerivedTool("vice_io_registers", { needsSession: false }, handleIoRegisters),
|
|
729
835
|
};
|
|
730
836
|
|
|
731
837
|
/** Looks up the table entry for `name` -- `undefined` on a miss, never a
|
|
@@ -740,31 +846,27 @@ export function stockHandlerFor(name: string): StockHandler | undefined {
|
|
|
740
846
|
/**
|
|
741
847
|
* The ONE dispatch entry point for the stock backend (D-09). On a hit,
|
|
742
848
|
* delegates to the table entry, unchanged. On a miss, refuses EXPLICITLY --
|
|
743
|
-
* naming the tool
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
849
|
+
* naming the tool and stating there is no dispatch entry for it -- WITHOUT
|
|
850
|
+
* reading `deps` at all (no lease is ever requested for a tool that does not
|
|
851
|
+
* exist on this backend). There is no third branch, and in particular NO
|
|
852
|
+
* fall-through to the fork's HTTP-forwarding path anywhere in this file or
|
|
853
|
+
* anything it calls -- that is D-09's whole point, grep-gated to zero
|
|
854
|
+
* occurrences of that function's name in this file's own code lines.
|
|
855
|
+
*
|
|
856
|
+
* FORKRM-05 (plan 52-07): this used to fall back to the deleted per-backend
|
|
857
|
+
* capability registry's refusal renderer first, naming the fork as the
|
|
858
|
+
* backend that provides the tool -- that renderer, and the fork it named,
|
|
859
|
+
* are both gone. A name reaching this branch is advertised on the stock
|
|
860
|
+
* manifest (so it passed vice-proxy.ts's own lookup) but has no dispatch
|
|
861
|
+
* entry: that is always an internal inconsistency now, never a capability
|
|
862
|
+
* gap with a second backend to point a caller at.
|
|
750
863
|
*/
|
|
751
864
|
export async function dispatchStock(name: string, args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
|
|
752
865
|
const handler = stockHandlerFor(name);
|
|
753
866
|
if (!handler) {
|
|
754
|
-
// WR-13: route through capability-registry.ts's ONE authoritative
|
|
755
|
-
// refusal renderer rather than a second, locally-composed wording --
|
|
756
|
-
// that renderer knows which backend ACTUALLY provides each name
|
|
757
|
-
// (avoiding the false "the fork backend provides this tool" claim for a
|
|
758
|
-
// stock-only-gain name) and never uses "wait for a later phase" framing
|
|
759
|
-
// for a hardware loss. Fall back to an internal-inconsistency message
|
|
760
|
-
// ONLY when the registry has no entry at all for `name` -- meaning the
|
|
761
|
-
// tool is advertised on the stock manifest (so it reached this branch)
|
|
762
|
-
// but stockHandlerFor() has no dispatch entry AND capability-registry.ts
|
|
763
|
-
// has no divergence entry either: a bug to report, not a capability gap.
|
|
764
867
|
return isErrorText(
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
`dispatch table -- this is an internal inconsistency, not a capability gap; please file an issue.`,
|
|
868
|
+
`${name} is advertised on the stock backend's manifest but has no handler in the stock ` +
|
|
869
|
+
`dispatch table -- this is an internal inconsistency, not a capability gap; please file an issue.`,
|
|
768
870
|
);
|
|
769
871
|
}
|
|
770
872
|
return handler(args, deps);
|
package/stock-handler.ts
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
// time, so it creates no runtime cycle even though stock-dispatch.ts
|
|
37
37
|
// imports this file at runtime.
|
|
38
38
|
import { MonitorOwnershipError } from "./vice-broker-client.ts";
|
|
39
|
-
import { MachineRestartedError } from "./vice.ts";
|
|
39
|
+
import { MachineRestartedError } from "./vice-errors.ts";
|
|
40
40
|
import { ErrorCode, StockFramingError, StockProtocolError, StockResponseMismatchError, type ViceMonitorClient } from "./stock-protocol.ts";
|
|
41
41
|
import { runStateFor } from "./stock-runstate.ts";
|
|
42
42
|
import type { StockConnectSession } from "./stock-connect.ts";
|
package/stock-paths.ts
CHANGED
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
// side finds this comment.
|
|
18
18
|
//
|
|
19
19
|
// WHAT NOT TO DO:
|
|
20
|
-
// - Never
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// is
|
|
20
|
+
// - Never merge this file's emulator-side translation with, or replace it
|
|
21
|
+
// by, a general argument-rewriting pass applied before dispatch. Stock
|
|
22
|
+
// tool calls no longer go through any such pass -- vice-proxy.ts's own
|
|
23
|
+
// fork-only per-call path-rewriter (and the generic forwarding function
|
|
24
|
+
// that ran it) is deleted outright -- and reintroducing one would
|
|
25
|
+
// re-create exactly the inversion this file's header names: what a
|
|
26
|
+
// general rewriter does for a client-side-derived path is precisely
|
|
27
|
+
// wrong for the four emulator-side filenames this file translates.
|
|
25
28
|
// - Never build a host path with a local heuristic (a hand-rolled prefix
|
|
26
29
|
// swap, a hardcoded mount guess, anything not routed through
|
|
27
30
|
// hostpath.ts's own hostPathCandidates()/tryHostPaths()). hostpath.ts is
|
|
@@ -35,8 +38,8 @@
|
|
|
35
38
|
// through withEmulatorSidePath() should stop and re-read this paragraph.
|
|
36
39
|
import { dirname, join } from "node:path";
|
|
37
40
|
|
|
38
|
-
import { ViceError, type ViceErrorOptions } from "./vice.ts";
|
|
39
|
-
import { repoRoot } from "./repo-root.ts";
|
|
41
|
+
import { ViceError, type ViceErrorOptions } from "./vice-errors.ts";
|
|
42
|
+
import { repoRoot, toolsDir } from "./repo-root.ts";
|
|
40
43
|
import { isInsideContainer } from "./container-guard.mts";
|
|
41
44
|
import { tryHostPaths } from "./hostpath.ts";
|
|
42
45
|
import { ErrorCode, StockProtocolError } from "./stock-protocol.ts";
|
|
@@ -167,21 +170,22 @@ export function sanitizeSnapshotName(name: unknown): string {
|
|
|
167
170
|
|
|
168
171
|
/**
|
|
169
172
|
* The container path a snapshot named `name` lives at:
|
|
170
|
-
* `<
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
173
|
+
* `<toolsDir>/snapshots/<name>.vsf` -- a subdirectory of the single
|
|
174
|
+
* tool-written root `repo-root.ts`'s `toolsDir()` owns (D-33). The directory
|
|
175
|
+
* is inside the workspace rather than under `~/.config/vice/` (the fork's own
|
|
176
|
+
* location) because only a workspace path is inside hostpath.ts's bind-mount
|
|
177
|
+
* mapping -- anything outside it cannot be translated for the host at all --
|
|
178
|
+
* and keeping it inside the workspace makes workspace escape structurally
|
|
175
179
|
* impossible rather than merely checked (T-3-05).
|
|
176
180
|
*/
|
|
177
181
|
export function snapshotPathFor(name: string): string {
|
|
178
|
-
return join(
|
|
182
|
+
return join(toolsDir(), "snapshots", `${sanitizeSnapshotName(name)}.vsf`);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
/** The sidecar metadata path for the same snapshot: same directory, `.json`
|
|
182
186
|
* extension, same sanitisation. */
|
|
183
187
|
export function snapshotMetaPathFor(name: string): string {
|
|
184
|
-
return join(
|
|
188
|
+
return join(toolsDir(), "snapshots", `${sanitizeSnapshotName(name)}.json`);
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
// Re-exported so a caller building a directory before translating (Task 3's
|
package/stock-petscii.ts
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// bytes (0x40/0x41, 0x5a/0x5b, 0x60/0x61, 0x7a/0x7b) and the control-code
|
|
33
33
|
// regions do not follow that rule uniformly; each range below is
|
|
34
34
|
// checked explicitly, not derived from a single arithmetic shortcut.
|
|
35
|
-
import { ViceError } from "./vice.ts";
|
|
35
|
+
import { ViceError } from "./vice-errors.ts";
|
|
36
36
|
|
|
37
37
|
/** PETSCII's Return code. Both ASCII LF (`\n`) and CR (`\r`) map here -- this
|
|
38
38
|
* is what the fork's own "Use \n for Return" tool description promises. */
|
package/stock-protocol.ts
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { EventEmitter } from "node:events";
|
|
42
42
|
import net from "node:net";
|
|
43
43
|
|
|
44
|
-
import { ViceError } from "./vice.ts";
|
|
44
|
+
import { ViceError } from "./vice-errors.ts";
|
|
45
45
|
|
|
46
46
|
// ---------------------------------------------------------------------------
|
|
47
47
|
// Wire constants (hand-copied, not imported -- see header comment above)
|
package/stock-recycle.ts
CHANGED
|
@@ -64,7 +64,7 @@ import { handleRegistersGet } from "./stock-registers.ts";
|
|
|
64
64
|
import { stockAnswer, isErrorText, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
|
|
65
65
|
import { stockDisconnect, type StockConnectSession } from "./stock-connect.ts";
|
|
66
66
|
import type { StockDispatchDeps } from "./stock-dispatch.ts";
|
|
67
|
-
import { readEpoch } from "./vice.ts";
|
|
67
|
+
import { readEpoch } from "./vice-errors.ts";
|
|
68
68
|
|
|
69
69
|
function describeError(err: unknown): string {
|
|
70
70
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -307,6 +307,81 @@ export async function gatherStockWedgeEvidence(session: StockConnectSession, dep
|
|
|
307
307
|
* per-outcome vocabulary is the same one. Redeclared locally rather than
|
|
308
308
|
* imported: importing it would mean importing vice-proxy.ts, which this
|
|
309
309
|
* module must never do. */
|
|
310
|
+
const DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS = 3000;
|
|
311
|
+
const RECYCLE_EPOCH_POLL_INTERVAL_MS = 50;
|
|
312
|
+
|
|
313
|
+
/** Read fresh on EVERY call -- same load-time-vs-call-time reasoning as
|
|
314
|
+
* stockCaptureStepTimeoutMs() above: a static `import` is hoisted ahead of
|
|
315
|
+
* any top-level statement in the importing file, so a module-level constant
|
|
316
|
+
* computed once at load time could never be retuned by a test that sets
|
|
317
|
+
* `process.env` afterwards. Deliberately its OWN environment variable,
|
|
318
|
+
* distinct from `VICE_RECYCLE_CAPTURE_TIMEOUT_MS` -- that knob bounds one
|
|
319
|
+
* evidence-gathering step before the kill; this one bounds the epoch poll
|
|
320
|
+
* after it, and a single shared knob would let retuning one silently retune
|
|
321
|
+
* the other. Exported so the test file can assert the default directly. */
|
|
322
|
+
export function stockRecycleEpochPollTimeoutMs(): number {
|
|
323
|
+
const raw = process.env.VICE_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
|
|
324
|
+
if (raw === undefined || raw === "") return DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
|
|
325
|
+
const parsed = Number(raw);
|
|
326
|
+
// A non-positive deadline would make the poll finish before it ever reads,
|
|
327
|
+
// so every confirmed kill would record a null epoch_after -- and the field
|
|
328
|
+
// would look present (the producer ran) while carrying no information at
|
|
329
|
+
// all, which is worse than the missing producer this poll exists to fix.
|
|
330
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
331
|
+
console.error(
|
|
332
|
+
`VICE_RECYCLE_EPOCH_POLL_TIMEOUT_MS=${JSON.stringify(raw)} is not a positive number of milliseconds -- ignoring it and using the ` +
|
|
333
|
+
`default ${DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS}ms. A value of 0 would end the post-kill epoch poll before its first read, so a ` +
|
|
334
|
+
"confirmed kill would always record a null epoch_after, indistinguishable from a genuine stall.",
|
|
335
|
+
);
|
|
336
|
+
return DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sleep(ms: number): Promise<void> {
|
|
340
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The ONE predicate that may promote a post-kill read into the record. A
|
|
344
|
+
* present-but-unchanged value must stay out: a pair of equal before/after
|
|
345
|
+
* numbers reads to a future investigator as a confirmed no-turnover, which is
|
|
346
|
+
* a false claim for a kill the guard above this call already established
|
|
347
|
+
* succeeded. Absence of a pre-kill epoch (a lease that never had one) makes
|
|
348
|
+
* the first present read count as an advance -- there is nothing higher than
|
|
349
|
+
* "nothing" to compare against. */
|
|
350
|
+
function epochAdvanced(before: number | null, after: ReturnType<typeof readEpoch>): boolean {
|
|
351
|
+
if (!after.present || after.epoch === null) return false;
|
|
352
|
+
return before === null || after.epoch > before;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Bounded post-kill epoch poll. Reuses the SAME dependency-injected reader
|
|
357
|
+
* and file path the pre-kill read above already used, so the before and
|
|
358
|
+
* after values come from one source. Loops until either the epoch has
|
|
359
|
+
* advanced (epochAdvanced() above) or its own wall-clock deadline passes,
|
|
360
|
+
* clamping the final sleep so the loop cannot overshoot that deadline. A
|
|
361
|
+
* lease with no epoch file polls nothing and resolves null immediately --
|
|
362
|
+
* there is nothing to read. A throw from the reader is treated as a read
|
|
363
|
+
* that did not advance, never as a fatal error on a path that runs after a
|
|
364
|
+
* destructive action has already happened.
|
|
365
|
+
*/
|
|
366
|
+
async function pollEpochAfter(readEpochFn: typeof readEpoch, epochFile: string, epochBefore: number | null): Promise<number | null> {
|
|
367
|
+
if (!epochFile) return null;
|
|
368
|
+
const deadline = Date.now() + stockRecycleEpochPollTimeoutMs();
|
|
369
|
+
for (;;) {
|
|
370
|
+
let result: ReturnType<typeof readEpoch> | null;
|
|
371
|
+
try {
|
|
372
|
+
result = readEpochFn(epochFile);
|
|
373
|
+
} catch {
|
|
374
|
+
result = null;
|
|
375
|
+
}
|
|
376
|
+
if (result && epochAdvanced(epochBefore, result)) {
|
|
377
|
+
return result.epoch;
|
|
378
|
+
}
|
|
379
|
+
const remaining = deadline - Date.now();
|
|
380
|
+
if (remaining <= 0) return null;
|
|
381
|
+
await sleep(Math.min(RECYCLE_EPOCH_POLL_INTERVAL_MS, remaining));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
310
385
|
function recycleAckOutcomeMessage(ack: { outcome: string; kill_stage: string; reason: string }): string {
|
|
311
386
|
const stage = ack.kill_stage || "unknown";
|
|
312
387
|
const reasonSuffix = ack.reason ? ` (${ack.reason})` : "";
|
|
@@ -472,7 +547,13 @@ export async function handleRecycleStock(args: Record<string, unknown>, session:
|
|
|
472
547
|
return isErrorText(`vice_recycle: ${recycleAckOutcomeMessage(ack)} Incident record: ${recordPath}.`);
|
|
473
548
|
}
|
|
474
549
|
|
|
475
|
-
|
|
550
|
+
// Post-kill epoch poll -- the record's own `epoch_after` producer. Only
|
|
551
|
+
// reached on a confirmed kill: a refusal, a timeout or a broker-gone
|
|
552
|
+
// outcome each leave the machine's state unknown, and polling for an
|
|
553
|
+
// epoch advance on any of those would invent a fact this handler has no
|
|
554
|
+
// basis for.
|
|
555
|
+
const epochAfter = await pollEpochAfter(readEpochFn, lease.epochFile, epochBefore);
|
|
556
|
+
finaliseIncidentRecord(recordPath, { outcome: "ok", kill_stage: killStage, epoch_after: epochAfter });
|
|
476
557
|
|
|
477
558
|
// stockAnswer() stamps runState from session.client -- read BEFORE the
|
|
478
559
|
// teardown below disconnects it, so the answer reports the machine's
|