@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
|
@@ -2,34 +2,31 @@
|
|
|
2
2
|
// Compiled by `tsc` from broker-control.mts. Edit the TypeScript source and rebuild;
|
|
3
3
|
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
4
|
// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
|
|
5
|
-
// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
|
|
5
|
+
// verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
|
|
6
6
|
// rebuild.
|
|
7
7
|
// broker-control.mts
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
// release. Plan 05 (task 1) completed the message set: recycle, status,
|
|
9
|
+
// The framing, the token gate, acquire/release, recycle, status,
|
|
11
10
|
// host_state, the arrival-ordered pending-acquire structure, and the
|
|
12
|
-
// kernel-enforced singleton guard's low-level bind primitive.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// -09).
|
|
11
|
+
// kernel-enforced singleton guard's low-level bind primitive. Also adds
|
|
12
|
+
// `monitor_claim`/`monitor_release`: exclusive ownership of an instance's
|
|
13
|
+
// raw binmon socket, enforced here rather than left to a client-side
|
|
14
|
+
// heuristic -- stock VICE services exactly one binmon client, and a second
|
|
15
|
+
// connect() produces no reply and no EOF, so the refusal must happen
|
|
16
|
+
// BEFORE any second dial is ever attempted. The subsystem's FIRST network
|
|
17
|
+
// listener: a TCP control plane replacing the bash broker's
|
|
18
|
+
// requests/grants/denials/leases directory tree entirely. One JSON object
|
|
19
|
+
// per line; the connection open IS the claim, connection close IS the
|
|
20
|
+
// release (T-01.6.2-01 through -09).
|
|
23
21
|
//
|
|
24
|
-
// Wire format confirmed at
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// VICE_BROKER_CONTROL_PORT.
|
|
22
|
+
// Wire format confirmed at a blocking checkpoint decision (2026-08-03,
|
|
23
|
+
// `as-specified`, no amendments), which accepted some residual risk and
|
|
24
|
+
// considered and rejected a unix-domain-socket alternative. Auth: per-boot
|
|
25
|
+
// capability token compared constant-time, checked BEFORE any state read
|
|
26
|
+
// or write. Bind: 0.0.0.0 explicitly, never 127.0.0.1 --
|
|
27
|
+
// host.docker.internal is the bridge address, not loopback, so a
|
|
28
|
+
// loopback-only listener is structurally unreachable from the container.
|
|
29
|
+
// Port: 19510 default via VICE_BROKER_CONTROL_PORT.
|
|
33
30
|
import { createServer } from "node:net";
|
|
34
31
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
35
32
|
/** 32 cryptographically random bytes rendered as hex -- the per-boot
|
|
@@ -40,14 +37,98 @@ export function newControlToken() {
|
|
|
40
37
|
return randomBytes(32).toString("hex");
|
|
41
38
|
}
|
|
42
39
|
const MAX_LINE_BYTES = 65536;
|
|
43
|
-
/**
|
|
40
|
+
/** The one refusal wording for a target-naming op whose `target_id` is
|
|
44
41
|
* not the grant the asking connection itself holds. Deliberately worded as an
|
|
45
42
|
* authorisation refusal and NOT as an ownership conflict between two
|
|
46
43
|
* legitimate holders (`monitor_owned`, which names a holder) and never as an
|
|
47
44
|
* emulator fault -- see attachControlProtocol()'s own ownsTarget() comment,
|
|
48
|
-
* and
|
|
45
|
+
* and this file's own prohibition on wedge/hang vocabulary in its
|
|
49
46
|
* monitor-op refusals. */
|
|
50
47
|
const MONITOR_OWNERSHIP_DENIAL = "monitor_claim/monitor_release may only target the grant this connection itself holds";
|
|
48
|
+
/** Resolves the `channel` field on a `monitor_claim`/`monitor_release`
|
|
49
|
+
* request line: an ABSENT field means `binary` deliberately -- a broker
|
|
50
|
+
* restarted mid-upgrade against a client that predates this field keeps
|
|
51
|
+
* working (backward compatibility). An unrecognised NON-EMPTY value is
|
|
52
|
+
* `bad_request`, never a silent fallback and never cast -- the caller
|
|
53
|
+
* below names both accepted values in the refusal message. */
|
|
54
|
+
function resolveMonitorChannel(raw) {
|
|
55
|
+
if (raw === undefined)
|
|
56
|
+
return "binary";
|
|
57
|
+
if (raw === "binary" || raw === "text")
|
|
58
|
+
return raw;
|
|
59
|
+
return "bad_request";
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// The launch-profile narrowing site.
|
|
63
|
+
//
|
|
64
|
+
// THIS IS THE ONE PLACE `profile` IS NARROWED. Do not re-derive this check
|
|
65
|
+
// anywhere else -- not in vice-broker.mts, not in broker-launch.mts, not in
|
|
66
|
+
// the container-side client. A second copy is how one of them ends up
|
|
67
|
+
// accepting a shape the other refuses.
|
|
68
|
+
//
|
|
69
|
+
// WHY IT HAS TO EXIST AT ALL: `ControlRequest` above carries an index
|
|
70
|
+
// signature, so *anything* a container writes on the wire parses into it. The
|
|
71
|
+
// profile then feeds buildViceArgs(), i.e. an `execve(x64sc, argv)` on the
|
|
72
|
+
// HOST. An unvalidated `profile` is therefore an argv-construction surface
|
|
73
|
+
// across a trust boundary, not merely a typing inconvenience.
|
|
74
|
+
//
|
|
75
|
+
// WHY UNKNOWN KEYS ARE REFUSED BY NAME rather than dropped: a silently
|
|
76
|
+
// accepted typo means a caller asked for warp, got an unwarped instance, and
|
|
77
|
+
// received a confident success -- the same undetectable-lie failure a
|
|
78
|
+
// mismatched grant-and-request eligibility check exists to prevent one
|
|
79
|
+
// layer down, and it is why the message below names the offending key --
|
|
80
|
+
// the by-name unexpected-argument discipline the tool handlers already use
|
|
81
|
+
// (RUN_UNTIL_KEYS' own convention).
|
|
82
|
+
//
|
|
83
|
+
// WHAT MUST NEVER BE ADDED HERE: a passthrough string, an `extraArgs`, or any
|
|
84
|
+
// key whose VALUE reaches argv. `profile` maps to exactly two literal flag
|
|
85
|
+
// tokens (`-console`, `-warp`) and to nothing else (T-33-04). `VICE_ARGS`
|
|
86
|
+
// stays the single, deliberate operator-only whole-argv override.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/** The complete accepted key set -- the ONE binding list this narrowing
|
|
89
|
+
* checks against, so adding a knob to LaunchProfile without adding it here
|
|
90
|
+
* refuses the knob rather than silently widening the boundary. */
|
|
91
|
+
const LAUNCH_PROFILE_KEYS = Object.freeze(["warp", "headless"]);
|
|
92
|
+
const LAUNCH_PROFILE_SHAPE = `an object with optional boolean keys ${LAUNCH_PROFILE_KEYS.join("/")}, or absent`;
|
|
93
|
+
/** Narrows an untrusted `profile` field off the wire. Never throws; answers a
|
|
94
|
+
* discriminated result so the caller writes the existing `bad_request` error
|
|
95
|
+
* shape rather than needing a try/catch at the protocol boundary.
|
|
96
|
+
*
|
|
97
|
+
* Rules, in the order they are applied:
|
|
98
|
+
* - `undefined` (key absent) and `null` -> `ok` with `undefined`. Both mean
|
|
99
|
+
* profile-less, which is byte-identically today's behaviour.
|
|
100
|
+
* - a PLAIN object (arrays and every other non-plain value refused) whose
|
|
101
|
+
* keys are a subset of LAUNCH_PROFILE_KEYS and whose PRESENT values are
|
|
102
|
+
* booleans -> `ok` with that object.
|
|
103
|
+
* - anything else -> `ok: false`, with a message naming the offending value
|
|
104
|
+
* (or key) and the accepted shape. Never coerced, never silently dropped:
|
|
105
|
+
* `"yes"`, `1` and `"warp"` are refusals, not truthy warp requests. */
|
|
106
|
+
export function normaliseLaunchProfile(raw) {
|
|
107
|
+
if (raw === undefined || raw === null)
|
|
108
|
+
return { ok: true, profile: undefined };
|
|
109
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
110
|
+
// Arrays are specifically excluded: `typeof [] === "object"` in JS, so
|
|
111
|
+
// without the Array.isArray() arm a JSON array would reach the key walk
|
|
112
|
+
// below and pass it vacuously (an empty array has no own keys).
|
|
113
|
+
return { ok: false, message: `profile must be ${LAUNCH_PROFILE_SHAPE}; got ${JSON.stringify(raw) ?? String(raw)}` };
|
|
114
|
+
}
|
|
115
|
+
const entries = Object.entries(raw);
|
|
116
|
+
const unknownKeys = entries.filter(([key]) => !LAUNCH_PROFILE_KEYS.includes(key)).map(([key]) => key);
|
|
117
|
+
if (unknownKeys.length > 0) {
|
|
118
|
+
return { ok: false, message: `profile has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${LAUNCH_PROFILE_SHAPE}` };
|
|
119
|
+
}
|
|
120
|
+
const profile = {};
|
|
121
|
+
for (const [key, value] of entries) {
|
|
122
|
+
if (typeof value !== "boolean") {
|
|
123
|
+
return { ok: false, message: `profile.${key} must be a boolean; got ${JSON.stringify(value) ?? String(value)}` };
|
|
124
|
+
}
|
|
125
|
+
if (key === "warp")
|
|
126
|
+
profile.warp = value;
|
|
127
|
+
if (key === "headless")
|
|
128
|
+
profile.headless = value;
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, profile };
|
|
131
|
+
}
|
|
51
132
|
export function resolveControlPort(override) {
|
|
52
133
|
if (typeof override === "number")
|
|
53
134
|
return override;
|
|
@@ -74,6 +155,20 @@ function writeLine(socket, obj) {
|
|
|
74
155
|
socket.write(`${JSON.stringify(obj)}\n`);
|
|
75
156
|
}
|
|
76
157
|
}
|
|
158
|
+
/** Writes a `host_tool` SUCCESS response line -- the object host-tool.mts's
|
|
159
|
+
* runHostTool() produced, whatever shape that is (`{ ok: true, ... }` or
|
|
160
|
+
* its own `{ ok: false, message }` refusal). This is deliberately NOT
|
|
161
|
+
* `writeLine()`/`ControlResponse`: the host-tool response shape is
|
|
162
|
+
* host-tool.mts's own contract, not one more `ControlResponse` variant this
|
|
163
|
+
* module would otherwise have to keep in sync with a sibling module's
|
|
164
|
+
* allowlist. A REJECTED onHostTool() promise never reaches this function --
|
|
165
|
+
* it is answered through the ordinary `writeLine()`/`error` path instead,
|
|
166
|
+
* so every protocol-level failure still goes through one shape. */
|
|
167
|
+
function writeHostToolLine(socket, obj) {
|
|
168
|
+
if (socket.writable) {
|
|
169
|
+
socket.write(`${JSON.stringify(obj)}\n`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
77
172
|
function defaultRequestId(prefix) {
|
|
78
173
|
return `${prefix}-${process.pid}-${Date.now()}`;
|
|
79
174
|
}
|
|
@@ -93,9 +188,10 @@ export function enqueueAcquire(queue, entry) {
|
|
|
93
188
|
* to stay correct; a genuinely adversarial retry pattern could still starve
|
|
94
189
|
* an entry across MULTIPLE passes, which is exactly the direct fairness
|
|
95
190
|
* proof this module deliberately does not author -- injecting N acquires
|
|
96
|
-
* and asserting grants return in that order is
|
|
97
|
-
*
|
|
98
|
-
*
|
|
191
|
+
* and asserting grants return in that order is left as a property for a
|
|
192
|
+
* future test to prove, not this module's own deliverable. The original
|
|
193
|
+
* defect this queue replaces (a lexical iteration over
|
|
194
|
+
* `req-<pid>-<ms>-<hex>` filenames) cannot exist here regardless: there
|
|
99
195
|
* is no file, and no re-ordering call of any kind anywhere in this region. */
|
|
100
196
|
export async function drainPendingAcquires(queue) {
|
|
101
197
|
const snapshot = queue.splice(0, queue.length);
|
|
@@ -162,9 +258,9 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
162
258
|
// every other connection and from the server itself (T-01.6.2-06).
|
|
163
259
|
});
|
|
164
260
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
261
|
+
* THE per-connection ownership predicate every target-naming op is
|
|
262
|
+
* gated on -- the same rule `recycle` has enforced since this
|
|
263
|
+
* protocol's earliest version, now shared rather than copied.
|
|
168
264
|
*
|
|
169
265
|
* Before this existed, `monitor_claim`/`monitor_release` took `target_id`
|
|
170
266
|
* from the request and passed it straight through, so any connection
|
|
@@ -176,8 +272,8 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
176
272
|
* session B could lock session A out of its own monitor socket, or
|
|
177
273
|
* RELEASE A's live claim, after which a third client was free to dial the
|
|
178
274
|
* same single-client binmon socket. That is precisely the unserviced-
|
|
179
|
-
* backlog state
|
|
180
|
-
* be reachable.
|
|
275
|
+
* backlog state this ownership check exists to prevent and that
|
|
276
|
+
* CLAUDE.md says must never be reachable.
|
|
181
277
|
*
|
|
182
278
|
* WHAT NOT TO DO: never add another op that acts on a caller-supplied
|
|
183
279
|
* `target_id` without gating it here first. The grant a connection holds
|
|
@@ -194,10 +290,9 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
194
290
|
* `drainPendingAcquires()` drives, so the two paths can never answer
|
|
195
291
|
* differently for the same requestId.
|
|
196
292
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* claim.
|
|
293
|
+
* Two destroyed-socket checks guard a grant against outliving the
|
|
294
|
+
* connection that owns it, and they bound TWO DIFFERENT failures -- do
|
|
295
|
+
* not conflate them into one claim.
|
|
201
296
|
*
|
|
202
297
|
* Half one -- the pre-check immediately below, BEFORE onAcquire() is
|
|
203
298
|
* ever called -- closes the ALWAYS-REACHABLE leak: a client that
|
|
@@ -217,7 +312,7 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
217
312
|
* callback with the same request id instead of silently dropping the
|
|
218
313
|
* grant it produced.
|
|
219
314
|
*/
|
|
220
|
-
function attemptAcquire(requestId) {
|
|
315
|
+
function attemptAcquire(requestId, profile) {
|
|
221
316
|
// Half one: a queued entry whose owning socket is already gone is
|
|
222
317
|
// settled immediately, WITHOUT ever calling onAcquire() -- this is
|
|
223
318
|
// what keeps a retried drain pass from performing a real, ownerless
|
|
@@ -225,7 +320,12 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
225
320
|
if (socket.destroyed)
|
|
226
321
|
return Promise.resolve(true);
|
|
227
322
|
return opts
|
|
228
|
-
|
|
323
|
+
// The profile is threaded through THIS shared helper, which both
|
|
324
|
+
// the immediate first attempt and every later
|
|
325
|
+
// drainPendingAcquires() retry go through -- so a request that
|
|
326
|
+
// queued behind an in-flight launch is retried later with the
|
|
327
|
+
// profile it was MADE with, never with a profile-less one.
|
|
328
|
+
.onAcquire(requestId, profile)
|
|
229
329
|
.then((outcome) => {
|
|
230
330
|
if (outcome.ok) {
|
|
231
331
|
// Half two: the pre-check above ran before this call; a
|
|
@@ -245,6 +345,14 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
245
345
|
url: outcome.grant.url,
|
|
246
346
|
epoch_file: outcome.grant.epochFile,
|
|
247
347
|
supervisor_dir: outcome.grant.supervisorDir,
|
|
348
|
+
// Key omitted entirely when absent -- the fork case only now.
|
|
349
|
+
// A stock grant whose second (text-monitor) port allocation
|
|
350
|
+
// failed never reaches this line at all:
|
|
351
|
+
// acquirePortAndLaunch() fails the WHOLE acquire
|
|
352
|
+
// (`no_free_text_port`) before any grant is produced, so
|
|
353
|
+
// "absent" no longer needs to cover that case. Never a
|
|
354
|
+
// fabricated 0 or null standing in for "no port".
|
|
355
|
+
...(outcome.grant.remoteMonitorPort === undefined ? {} : { remote_monitor_port: outcome.grant.remoteMonitorPort }),
|
|
248
356
|
});
|
|
249
357
|
return true;
|
|
250
358
|
}
|
|
@@ -289,11 +397,51 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
289
397
|
socket.destroy();
|
|
290
398
|
return;
|
|
291
399
|
}
|
|
292
|
-
|
|
400
|
+
// Dispatched FIRST in the chain, before "acquire" -- so the ordering
|
|
401
|
+
// reads clearly. Dispatch here is on EXACT STRING EQUALITY, never
|
|
402
|
+
// fallthrough, so branch order does not itself change which requests
|
|
403
|
+
// reach attemptAcquire() -- what actually makes this branch unable to
|
|
404
|
+
// touch lease state is that opts.onHostTool is its OWN callback (see
|
|
405
|
+
// StartControlListenerOptions' own comment), never composed from
|
|
406
|
+
// onAcquire/onRelease/onRecycle/onStatus/onHostState/onMonitorClaim/
|
|
407
|
+
// onMonitorRelease.
|
|
408
|
+
if (req.op === "host_tool") {
|
|
409
|
+
opts
|
|
410
|
+
.onHostTool(req)
|
|
411
|
+
.then((result) => {
|
|
412
|
+
if (!socket.destroyed)
|
|
413
|
+
writeHostToolLine(socket, result);
|
|
414
|
+
})
|
|
415
|
+
.catch(() => {
|
|
416
|
+
if (!socket.destroyed) {
|
|
417
|
+
writeLine(socket, { kind: "error", code: "internal", message: "host_tool threw" });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
else if (req.op === "acquire") {
|
|
293
422
|
const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("req");
|
|
294
|
-
|
|
423
|
+
// Narrow BEFORE attemptAcquire, so a malformed profile never
|
|
424
|
+
// reaches onAcquire and therefore never reaches the port allocator,
|
|
425
|
+
// a spawn, or argv construction. A refusal also does NOT enqueue --
|
|
426
|
+
// the request is answered and dropped, never retried on a later
|
|
427
|
+
// drain pass with the same bad shape.
|
|
428
|
+
const normalised = normaliseLaunchProfile(req.profile);
|
|
429
|
+
if (!normalised.ok) {
|
|
430
|
+
writeLine(socket, { kind: "error", code: "bad_request", message: normalised.message });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const profile = normalised.profile;
|
|
434
|
+
// A profile-is-stock-only refusal used to live here: it refused
|
|
435
|
+
// `profile.warp`/`profile.headless` when this broker's resolved
|
|
436
|
+
// backend had no `-warp`/`-console` route at all, so a caller
|
|
437
|
+
// learned a knob would be silently ignored rather than getting a
|
|
438
|
+
// confident grant with no effect. Now that the fork backend is
|
|
439
|
+
// gone, there is one backend and it always has that route, so the
|
|
440
|
+
// condition this refused can no longer occur -- deleted rather than
|
|
441
|
+
// left as a check against a value that can never disagree.
|
|
442
|
+
void attemptAcquire(requestId, profile).then((settled) => {
|
|
295
443
|
if (!settled) {
|
|
296
|
-
enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId) });
|
|
444
|
+
enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId, profile) });
|
|
297
445
|
}
|
|
298
446
|
});
|
|
299
447
|
}
|
|
@@ -313,7 +461,7 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
313
461
|
// called, so a mismatched target never reaches the kill discipline
|
|
314
462
|
// and never signals anything -- an injected signal recorder stays
|
|
315
463
|
// empty for this case. Now expressed through the SAME ownsTarget()
|
|
316
|
-
// predicate monitor_claim/monitor_release use
|
|
464
|
+
// predicate monitor_claim/monitor_release use, so the three
|
|
317
465
|
// target-naming ops cannot drift apart.
|
|
318
466
|
if (!ownsTarget(targetId)) {
|
|
319
467
|
writeLine(socket, {
|
|
@@ -354,7 +502,6 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
354
502
|
started_at: hs.startedAt,
|
|
355
503
|
node_version: hs.nodeVersion,
|
|
356
504
|
vice_bin: hs.viceBin,
|
|
357
|
-
warm_floor: hs.warmFloor,
|
|
358
505
|
max_instances: hs.maxInstances,
|
|
359
506
|
base_port: hs.basePort,
|
|
360
507
|
backend: hs.backend,
|
|
@@ -370,29 +517,39 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
370
517
|
writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
|
|
371
518
|
return;
|
|
372
519
|
}
|
|
520
|
+
const channel = resolveMonitorChannel(req.channel);
|
|
521
|
+
if (channel === "bad_request") {
|
|
522
|
+
writeLine(socket, {
|
|
523
|
+
kind: "error",
|
|
524
|
+
code: "bad_request",
|
|
525
|
+
message: `monitor_claim: unrecognised channel ${JSON.stringify(req.channel)} -- accepted values are "binary" and "text"`,
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
373
529
|
const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("claim");
|
|
374
|
-
const outcome = opts.onMonitorClaim(requestId, targetId);
|
|
530
|
+
const outcome = opts.onMonitorClaim(requestId, targetId, channel);
|
|
375
531
|
if (outcome.ok) {
|
|
376
532
|
writeLine(socket, { kind: "monitor_claimed" });
|
|
377
533
|
}
|
|
378
534
|
else if (outcome.code === "monitor_owned") {
|
|
379
|
-
// Ownership conflict, named by holder -- deliberately
|
|
380
|
-
// never suggest the emulator itself has stopped
|
|
381
|
-
//
|
|
535
|
+
// Ownership conflict, named by holder AND channel -- deliberately
|
|
536
|
+
// worded to never suggest the emulator itself has stopped
|
|
537
|
+
// answering.
|
|
382
538
|
//
|
|
383
|
-
//
|
|
539
|
+
// `holder` is REQUIRED by MonitorClaimOutcome for
|
|
384
540
|
// this code, but this handler runs inside socket.on("data") with no
|
|
385
541
|
// try/catch above it, so a producer that ever omitted it would throw a
|
|
386
542
|
// TypeError out of the control listener and take the broker process
|
|
387
543
|
// with it -- a type contract is not a runtime guarantee at a wire
|
|
388
544
|
// boundary. The fallback names the holder as unknown rather than
|
|
389
|
-
// fabricating one
|
|
390
|
-
// with a malformed holder payload
|
|
391
|
-
|
|
545
|
+
// fabricating one (matching what the container-side client now does
|
|
546
|
+
// with a malformed holder payload), and defaults `channel` to the
|
|
547
|
+
// channel THIS request asked for -- never a fabricated third value.
|
|
548
|
+
const holder = outcome.holder ?? { grantId: "unknown", claimedAt: 0, pid: null, channel };
|
|
392
549
|
writeLine(socket, {
|
|
393
550
|
kind: "error",
|
|
394
551
|
code: "monitor_owned",
|
|
395
|
-
message: `instance already has a monitor client (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
|
|
552
|
+
message: `instance already has a monitor client on the ${holder.channel} channel (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
|
|
396
553
|
holder,
|
|
397
554
|
});
|
|
398
555
|
}
|
|
@@ -410,8 +567,17 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
410
567
|
writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
|
|
411
568
|
return;
|
|
412
569
|
}
|
|
570
|
+
const channel = resolveMonitorChannel(req.channel);
|
|
571
|
+
if (channel === "bad_request") {
|
|
572
|
+
writeLine(socket, {
|
|
573
|
+
kind: "error",
|
|
574
|
+
code: "bad_request",
|
|
575
|
+
message: `monitor_release: unrecognised channel ${JSON.stringify(req.channel)} -- accepted values are "binary" and "text"`,
|
|
576
|
+
});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
413
579
|
const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("release-monitor");
|
|
414
|
-
const outcome = opts.onMonitorRelease(requestId, targetId);
|
|
580
|
+
const outcome = opts.onMonitorRelease(requestId, targetId, channel);
|
|
415
581
|
if (outcome.ok) {
|
|
416
582
|
writeLine(socket, { kind: "monitor_released" });
|
|
417
583
|
}
|
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
// Compiled by `tsc` from broker-epoch.mts. Edit the TypeScript source and rebuild;
|
|
3
3
|
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
4
|
// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
|
|
5
|
-
// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
|
|
5
|
+
// verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
|
|
6
6
|
// rebuild.
|
|
7
7
|
// broker-epoch.mts
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// renamed -- matching writeBrokerRecord()'s own choke point in
|
|
9
|
+
// The per-instance epoch.json writer, held to the frozen eight-field
|
|
10
|
+
// contract captured in fixtures/ before the bash writer that produced them
|
|
11
|
+
// was deleted. Ports write_epoch()'s exact field shape and its atomic
|
|
12
|
+
// tmp-sibling-then-rename discipline -- the tmp file is created empty, mode
|
|
13
|
+
// tightened to owner-read-write BEFORE any content reaches it, content
|
|
14
|
+
// written, then renamed -- matching writeBrokerRecord()'s own choke point in
|
|
16
15
|
// vice-broker.mts exactly.
|
|
17
16
|
//
|
|
18
17
|
// Plan 03, Task 1 completes this module: the path derivations
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// Compiled by `tsc` from broker-kill.mts. Edit the TypeScript source and rebuild;
|
|
3
3
|
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
4
|
// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
|
|
5
|
-
// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
|
|
5
|
+
// verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
|
|
6
6
|
// rebuild.
|
|
7
7
|
// broker-kill.mts
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// The identity-verified kill discipline,
|
|
10
10
|
// ported from resources/vice-broker.sh's signal_recorded_pid()/
|
|
11
11
|
// signal_vice_child_pid(): zero-signal liveness check, identity check against
|
|
12
12
|
// the process's own argument string, SIGTERM, poll-then-SIGKILL. The
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
// unconditionally (kill-never-recycle). The uncatchable signals (SIGKILL,
|
|
31
31
|
// SIGSTOP) are deliberately unhandled -- see registerShutdownHandlers()'s
|
|
32
32
|
// own comment.
|
|
33
|
-
// - reapOrphanedInstances(): the unconditional startup reap
|
|
34
|
-
//
|
|
33
|
+
// - reapOrphanedInstances(): the unconditional startup reap that reaches
|
|
34
|
+
// instances this broker process has no in-memory
|
|
35
35
|
// record of, derived from the emulator port band plus this broker's OWN
|
|
36
36
|
// on-disk allocation record (epoch.json) -- never a host process
|
|
37
|
-
// listing or a scan of another process's argv
|
|
37
|
+
// listing or a scan of another process's argv --
|
|
38
38
|
// see reapOrphanedInstances()'s own header comment for the incident
|
|
39
|
-
// this revision closes
|
|
39
|
+
// this revision closes.
|
|
40
40
|
import { execFileSync } from "node:child_process";
|
|
41
41
|
import { readFileSync, readdirSync } from "node:fs";
|
|
42
42
|
import { join } from "node:path";
|
|
@@ -80,7 +80,7 @@ function defaultLog(line) {
|
|
|
80
80
|
* signal_vice_child_pid() do. An empty/null/non-positive pid, or a pid
|
|
81
81
|
* already gone, returns "already_exited" without ever signalling -- "the
|
|
82
82
|
* machine being gone is the goal", per the bash version's own comment. An
|
|
83
|
-
* EMPTY expectedIdentity is REFUSED outright (
|
|
83
|
+
* EMPTY expectedIdentity is REFUSED outright (see the guard's own
|
|
84
84
|
* comment below), as is a live pid whose OWN argument string does not contain
|
|
85
85
|
* expectedIdentity: both return "identity_refused" without ever signalling,
|
|
86
86
|
* the one outcome a caller must be able to tell apart from every other stage
|
|
@@ -99,7 +99,7 @@ export async function verifiedKill({ pid, expectedIdentity, deps = {} }) {
|
|
|
99
99
|
if (!isAlive(pid)) {
|
|
100
100
|
return "already_exited";
|
|
101
101
|
}
|
|
102
|
-
//
|
|
102
|
+
// An EMPTY expectedIdentity REFUSES, it
|
|
103
103
|
// never permits. `"".includes` is vacuously satisfied by every process's
|
|
104
104
|
// argv, so the guard below was unconditionally true for the empty string --
|
|
105
105
|
// which disabled it entirely and let the caller SIGTERM (then SIGKILL)
|
|
@@ -294,18 +294,19 @@ export function registerShutdownHandlers(deps) {
|
|
|
294
294
|
}
|
|
295
295
|
};
|
|
296
296
|
}
|
|
297
|
-
/**
|
|
297
|
+
/** The mandatory start-time banner: printed unconditionally, before the
|
|
298
298
|
* control listener begins accepting, naming exactly what a keyboard
|
|
299
299
|
* interrupt or a closed terminal destroys. On 2026-08-02 a `^C` produced
|
|
300
300
|
* "reap saw 4 recorded instance(s), terminated 4" and killed a live
|
|
301
301
|
* session -- the incident was not caused by missing machinery, it was
|
|
302
302
|
* caused by nobody being told. Detaching stays the operator's own
|
|
303
|
-
* nohup/setsid/systemd choice
|
|
303
|
+
* nohup/setsid/systemd choice -- this banner names that choice
|
|
304
304
|
* rather than offering a flag; the launcher stays thin.
|
|
305
305
|
*
|
|
306
|
-
*
|
|
307
|
-
* warm-floor environment variable does not weaken
|
|
308
|
-
* the line added below reports the variable's
|
|
306
|
+
* The one place naming the retired
|
|
307
|
+
* warm-floor environment variable does not weaken the clean break made when
|
|
308
|
+
* the warm floor was retired -- the line added below reports the variable's
|
|
309
|
+
* mere PRESENCE, never its
|
|
309
310
|
* value, and no reader anywhere in this broker still consults it (the
|
|
310
311
|
* structural gate in broker-kill.test.ts proves that). Without it, an
|
|
311
312
|
* operator with the retired variable set in a shell profile would silently
|
|
@@ -324,27 +325,30 @@ export function startupBanner() {
|
|
|
324
325
|
"vice-broker: to run this broker outside the current terminal session, use your own",
|
|
325
326
|
"vice-broker: nohup/setsid/systemd -- this launcher does not offer a --detach flag.",
|
|
326
327
|
];
|
|
327
|
-
if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check
|
|
328
|
-
lines.push(
|
|
328
|
+
if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check -- never reads the value
|
|
329
|
+
lines.push(
|
|
330
|
+
// This note's own former "use the warm-floor
|
|
331
|
+
// knob instead" replacement is ITSELF retired along with the warm
|
|
332
|
+
// floor -- pointing an operator at a second dead knob would be worse
|
|
333
|
+
// than pointing at none. VICE now launches strictly on demand, on the
|
|
334
|
+
// first request; there is no warming knob left to name.
|
|
335
|
+
"vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. There is no replacement -- VICE now launches strictly on demand, on the first request.");
|
|
329
336
|
}
|
|
330
337
|
return lines.join("\n");
|
|
331
338
|
}
|
|
332
339
|
// ============================================================================
|
|
333
|
-
// Startup reap: unconditional, file-free... but no longer PROCESS-TABLE-free
|
|
334
|
-
// (criterion I, D-15, as revised by 02-03-PLAN.md/D-14/D-15).
|
|
340
|
+
// Startup reap: unconditional, file-free... but no longer PROCESS-TABLE-free.
|
|
335
341
|
//
|
|
336
|
-
//
|
|
337
|
-
// mechanism -- the two functions it lived in are gone from this tree
|
|
342
|
+
// This section's entire former identity
|
|
343
|
+
// mechanism is retired -- the two functions it lived in are gone from this tree
|
|
338
344
|
// outright, not merely unused -- which used to select kill targets by
|
|
339
345
|
// scanning EVERY host process's own argument string for a plain substring
|
|
340
346
|
// match on the configured emulator binary path, gated only by "some bare
|
|
341
|
-
// integer token >= basePort appears somewhere in that same string"
|
|
342
|
-
// todo
|
|
343
|
-
// `.planning/todos/pending/2026-08-12-broker-orphan-reap-substring-identity-match.md`,
|
|
347
|
+
// integer token >= basePort appears somewhere in that same string". This was
|
|
344
348
|
// observed killing two unrelated orchestrator shell processes on a
|
|
345
349
|
// developer's host (a long scratchpad path supplied the qualifying integer;
|
|
346
|
-
// a short VICE_BIN like `/bin/sleep` supplied the substring).
|
|
347
|
-
//
|
|
350
|
+
// a short VICE_BIN like `/bin/sleep` supplied the substring). The replacement
|
|
351
|
+
// uses the broker's OWN allocation record: this reap now
|
|
348
352
|
// enumerates the instance directories under `stateDir` (which THIS broker,
|
|
349
353
|
// or a same-machine predecessor, created) and kills only the pid each
|
|
350
354
|
// directory's own epoch.json actually recorded launching. A host process
|
|
@@ -424,8 +428,9 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
424
428
|
};
|
|
425
429
|
deps.writeEpochRecord({ supervisorDir, record });
|
|
426
430
|
}
|
|
427
|
-
/** The unconditional startup reap (
|
|
428
|
-
* revised
|
|
431
|
+
/** The unconditional startup reap (kill-target identity
|
|
432
|
+
* revised to use the broker's own allocation record instead of a process-table
|
|
433
|
+
* scan). Runs on every broker start, before the
|
|
429
434
|
* control listener accepts and before anything is launched -- unconditional
|
|
430
435
|
* because a broker killed with SIGKILL never runs a shutdown path, so "was
|
|
431
436
|
* the last shutdown clean" is unanswerable, and a marker file recording that
|
|
@@ -435,14 +440,14 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
435
440
|
* Enumerates the on-disk instance directories under `stateDir` in the
|
|
436
441
|
* allocation band (`port >= basePort` -- the 6510-6599 range below it stays
|
|
437
442
|
* reserved by convention for an emulator a human launched for their own
|
|
438
|
-
* work
|
|
443
|
+
* work), and for each one reads its OWN `epoch.json` -- never a host
|
|
439
444
|
* process listing, never an argv scan. A directory whose record is absent,
|
|
440
445
|
* unparseable, or carries no finite positive `pid` contributes nothing to
|
|
441
446
|
* `found`/`killed` and is skipped by the kill half entirely, but the epoch
|
|
442
447
|
* bump below still runs for it: a registry-free restart must still void
|
|
443
448
|
* every in-band instance directory it finds, including one it has no usable
|
|
444
|
-
* pid for, which is the exact case
|
|
445
|
-
*
|
|
449
|
+
* pid for, which is the exact case a registry-free restart must handle --
|
|
450
|
+
* the void has
|
|
446
451
|
* to reach instances a registry-free restart never heard of. A record that
|
|
447
452
|
* DOES carry a usable pid AND a non-empty `vice_bin` is killed via
|
|
448
453
|
* verifiedKill() with `expectedIdentity` set to THAT record's own `vice_bin`
|
|
@@ -450,7 +455,7 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
450
455
|
* does not match what THIS broker itself recorded launching there is refused
|
|
451
456
|
* (`identity_refused`), exactly like every other verifiedKill() call site in
|
|
452
457
|
* this module. A record carrying a usable pid but NO `vice_bin` is NOT a kill
|
|
453
|
-
* candidate at all
|
|
458
|
+
* candidate at all: it contributes nothing to `found`/`killed`, the
|
|
454
459
|
* kill dep is never invoked, and only the epoch bump runs -- an unidentifiable
|
|
455
460
|
* pid is refused, never killed on the strength of the pid alone.
|
|
456
461
|
*
|
|
@@ -478,7 +483,7 @@ export async function reapOrphanedInstances(options) {
|
|
|
478
483
|
if (typeof pid === "number" && Number.isFinite(pid) && pid > 0) {
|
|
479
484
|
const expectedIdentity = typeof epochFields?.vice_bin === "string" ? epochFields.vice_bin : "";
|
|
480
485
|
if (expectedIdentity === "") {
|
|
481
|
-
//
|
|
486
|
+
// A record with a usable pid but NO recorded identity is not a
|
|
482
487
|
// kill candidate at all -- it is not counted in `found` and the kill
|
|
483
488
|
// dep is never invoked. verifiedKill() refuses an empty identity too
|
|
484
489
|
// (second layer, deliberately: removing either leaves the other
|