@henols/vice-mcp 0.2.3 → 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/anno-cli.ts +156 -158
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-export-asm.ts +100 -101
- package/anno-graphics.ts +16 -16
- package/anno-hazard-report.ts +2 -2
- package/anno-import.ts +15 -15
- package/anno-index.ts +8 -8
- package/anno-join.ts +35 -35
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +4 -4
- package/anno-regbits-gen.ts +13 -13
- package/anno-store-export.ts +11 -11
- package/anno-store.ts +139 -144
- package/anno-symbols.ts +7 -7
- package/anno-types.ts +55 -55
- package/package.json +1 -1
- package/resources/broker-control.mjs +85 -92
- package/resources/broker-epoch.mjs +6 -7
- package/resources/broker-kill.mjs +29 -30
- package/resources/broker-launch.mjs +352 -370
- package/resources/broker-state.mjs +9 -10
- package/resources/host-tool.mjs +636 -664
- package/resources/vice-broker.mjs +189 -191
- package/vice-broker-client.ts +98 -100
|
@@ -6,30 +6,27 @@
|
|
|
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,21 +37,20 @@ 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";
|
|
51
48
|
/** Resolves the `channel` field on a `monitor_claim`/`monitor_release`
|
|
52
|
-
* request line
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* values in the refusal message. */
|
|
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. */
|
|
58
54
|
function resolveMonitorChannel(raw) {
|
|
59
55
|
if (raw === undefined)
|
|
60
56
|
return "binary";
|
|
@@ -63,8 +59,7 @@ function resolveMonitorChannel(raw) {
|
|
|
63
59
|
return "bad_request";
|
|
64
60
|
}
|
|
65
61
|
// ---------------------------------------------------------------------------
|
|
66
|
-
//
|
|
67
|
-
// narrowing site.
|
|
62
|
+
// The launch-profile narrowing site.
|
|
68
63
|
//
|
|
69
64
|
// THIS IS THE ONE PLACE `profile` IS NARROWED. Do not re-derive this check
|
|
70
65
|
// anywhere else -- not in vice-broker.mts, not in broker-launch.mts, not in
|
|
@@ -79,10 +74,11 @@ function resolveMonitorChannel(raw) {
|
|
|
79
74
|
//
|
|
80
75
|
// WHY UNKNOWN KEYS ARE REFUSED BY NAME rather than dropped: a silently
|
|
81
76
|
// accepted typo means a caller asked for warp, got an unwarped instance, and
|
|
82
|
-
// received a confident success
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// tool handlers already use
|
|
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).
|
|
86
82
|
//
|
|
87
83
|
// WHAT MUST NEVER BE ADDED HERE: a passthrough string, an `extraArgs`, or any
|
|
88
84
|
// key whose VALUE reaches argv. `profile` maps to exactly two literal flag
|
|
@@ -159,16 +155,15 @@ function writeLine(socket, obj) {
|
|
|
159
155
|
socket.write(`${JSON.stringify(obj)}\n`);
|
|
160
156
|
}
|
|
161
157
|
}
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* shape. */
|
|
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. */
|
|
172
167
|
function writeHostToolLine(socket, obj) {
|
|
173
168
|
if (socket.writable) {
|
|
174
169
|
socket.write(`${JSON.stringify(obj)}\n`);
|
|
@@ -193,9 +188,10 @@ export function enqueueAcquire(queue, entry) {
|
|
|
193
188
|
* to stay correct; a genuinely adversarial retry pattern could still starve
|
|
194
189
|
* an entry across MULTIPLE passes, which is exactly the direct fairness
|
|
195
190
|
* proof this module deliberately does not author -- injecting N acquires
|
|
196
|
-
* and asserting grants return in that order is
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
199
195
|
* is no file, and no re-ordering call of any kind anywhere in this region. */
|
|
200
196
|
export async function drainPendingAcquires(queue) {
|
|
201
197
|
const snapshot = queue.splice(0, queue.length);
|
|
@@ -262,9 +258,9 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
262
258
|
// every other connection and from the server itself (T-01.6.2-06).
|
|
263
259
|
});
|
|
264
260
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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.
|
|
268
264
|
*
|
|
269
265
|
* Before this existed, `monitor_claim`/`monitor_release` took `target_id`
|
|
270
266
|
* from the request and passed it straight through, so any connection
|
|
@@ -276,8 +272,8 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
276
272
|
* session B could lock session A out of its own monitor socket, or
|
|
277
273
|
* RELEASE A's live claim, after which a third client was free to dial the
|
|
278
274
|
* same single-client binmon socket. That is precisely the unserviced-
|
|
279
|
-
* backlog state
|
|
280
|
-
* be reachable.
|
|
275
|
+
* backlog state this ownership check exists to prevent and that
|
|
276
|
+
* CLAUDE.md says must never be reachable.
|
|
281
277
|
*
|
|
282
278
|
* WHAT NOT TO DO: never add another op that acts on a caller-supplied
|
|
283
279
|
* `target_id` without gating it here first. The grant a connection holds
|
|
@@ -294,10 +290,9 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
294
290
|
* `drainPendingAcquires()` drives, so the two paths can never answer
|
|
295
291
|
* differently for the same requestId.
|
|
296
292
|
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* 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.
|
|
301
296
|
*
|
|
302
297
|
* Half one -- the pre-check immediately below, BEFORE onAcquire() is
|
|
303
298
|
* ever called -- closes the ALWAYS-REACHABLE leak: a client that
|
|
@@ -325,8 +320,8 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
325
320
|
if (socket.destroyed)
|
|
326
321
|
return Promise.resolve(true);
|
|
327
322
|
return opts
|
|
328
|
-
//
|
|
329
|
-
//
|
|
323
|
+
// The profile is threaded through THIS shared helper, which both
|
|
324
|
+
// the immediate first attempt and every later
|
|
330
325
|
// drainPendingAcquires() retry go through -- so a request that
|
|
331
326
|
// queued behind an in-flight launch is retried later with the
|
|
332
327
|
// profile it was MADE with, never with a profile-less one.
|
|
@@ -350,12 +345,12 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
350
345
|
url: outcome.grant.url,
|
|
351
346
|
epoch_file: outcome.grant.epochFile,
|
|
352
347
|
supervisor_dir: outcome.grant.supervisorDir,
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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
|
|
359
354
|
// fabricated 0 or null standing in for "no port".
|
|
360
355
|
...(outcome.grant.remoteMonitorPort === undefined ? {} : { remote_monitor_port: outcome.grant.remoteMonitorPort }),
|
|
361
356
|
});
|
|
@@ -402,14 +397,14 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
402
397
|
socket.destroy();
|
|
403
398
|
return;
|
|
404
399
|
}
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
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.
|
|
413
408
|
if (req.op === "host_tool") {
|
|
414
409
|
opts
|
|
415
410
|
.onHostTool(req)
|
|
@@ -425,24 +420,23 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
425
420
|
}
|
|
426
421
|
else if (req.op === "acquire") {
|
|
427
422
|
const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("req");
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
// shape.
|
|
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.
|
|
434
428
|
const normalised = normaliseLaunchProfile(req.profile);
|
|
435
429
|
if (!normalised.ok) {
|
|
436
430
|
writeLine(socket, { kind: "error", code: "bad_request", message: normalised.message });
|
|
437
431
|
return;
|
|
438
432
|
}
|
|
439
433
|
const profile = normalised.profile;
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
// there is one backend
|
|
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
|
|
446
440
|
// condition this refused can no longer occur -- deleted rather than
|
|
447
441
|
// left as a check against a value that can never disagree.
|
|
448
442
|
void attemptAcquire(requestId, profile).then((settled) => {
|
|
@@ -467,7 +461,7 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
467
461
|
// called, so a mismatched target never reaches the kill discipline
|
|
468
462
|
// and never signals anything -- an injected signal recorder stays
|
|
469
463
|
// empty for this case. Now expressed through the SAME ownsTarget()
|
|
470
|
-
// predicate monitor_claim/monitor_release use
|
|
464
|
+
// predicate monitor_claim/monitor_release use, so the three
|
|
471
465
|
// target-naming ops cannot drift apart.
|
|
472
466
|
if (!ownsTarget(targetId)) {
|
|
473
467
|
writeLine(socket, {
|
|
@@ -538,12 +532,11 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
538
532
|
writeLine(socket, { kind: "monitor_claimed" });
|
|
539
533
|
}
|
|
540
534
|
else if (outcome.code === "monitor_owned") {
|
|
541
|
-
// Ownership conflict, named by holder AND channel
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// polices this).
|
|
535
|
+
// Ownership conflict, named by holder AND channel -- deliberately
|
|
536
|
+
// worded to never suggest the emulator itself has stopped
|
|
537
|
+
// answering.
|
|
545
538
|
//
|
|
546
|
-
//
|
|
539
|
+
// `holder` is REQUIRED by MonitorClaimOutcome for
|
|
547
540
|
// this code, but this handler runs inside socket.on("data") with no
|
|
548
541
|
// try/catch above it, so a producer that ever omitted it would throw a
|
|
549
542
|
// TypeError out of the control listener and take the broker process
|
|
@@ -6,13 +6,12 @@
|
|
|
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
|
|
@@ -6,7 +6,7 @@
|
|
|
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,9 +325,9 @@ 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
|
+
if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check -- never reads the value
|
|
328
329
|
lines.push(
|
|
329
|
-
//
|
|
330
|
+
// This note's own former "use the warm-floor
|
|
330
331
|
// knob instead" replacement is ITSELF retired along with the warm
|
|
331
332
|
// floor -- pointing an operator at a second dead knob would be worse
|
|
332
333
|
// than pointing at none. VICE now launches strictly on demand, on the
|
|
@@ -336,21 +337,18 @@ export function startupBanner() {
|
|
|
336
337
|
return lines.join("\n");
|
|
337
338
|
}
|
|
338
339
|
// ============================================================================
|
|
339
|
-
// Startup reap: unconditional, file-free... but no longer PROCESS-TABLE-free
|
|
340
|
-
// (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.
|
|
341
341
|
//
|
|
342
|
-
//
|
|
343
|
-
// 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
|
|
344
344
|
// outright, not merely unused -- which used to select kill targets by
|
|
345
345
|
// scanning EVERY host process's own argument string for a plain substring
|
|
346
346
|
// match on the configured emulator binary path, gated only by "some bare
|
|
347
|
-
// integer token >= basePort appears somewhere in that same string"
|
|
348
|
-
// todo
|
|
349
|
-
// `.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
|
|
350
348
|
// observed killing two unrelated orchestrator shell processes on a
|
|
351
349
|
// developer's host (a long scratchpad path supplied the qualifying integer;
|
|
352
|
-
// a short VICE_BIN like `/bin/sleep` supplied the substring).
|
|
353
|
-
//
|
|
350
|
+
// a short VICE_BIN like `/bin/sleep` supplied the substring). The replacement
|
|
351
|
+
// uses the broker's OWN allocation record: this reap now
|
|
354
352
|
// enumerates the instance directories under `stateDir` (which THIS broker,
|
|
355
353
|
// or a same-machine predecessor, created) and kills only the pid each
|
|
356
354
|
// directory's own epoch.json actually recorded launching. A host process
|
|
@@ -430,8 +428,9 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
430
428
|
};
|
|
431
429
|
deps.writeEpochRecord({ supervisorDir, record });
|
|
432
430
|
}
|
|
433
|
-
/** The unconditional startup reap (
|
|
434
|
-
* 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
|
|
435
434
|
* control listener accepts and before anything is launched -- unconditional
|
|
436
435
|
* because a broker killed with SIGKILL never runs a shutdown path, so "was
|
|
437
436
|
* the last shutdown clean" is unanswerable, and a marker file recording that
|
|
@@ -441,14 +440,14 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
441
440
|
* Enumerates the on-disk instance directories under `stateDir` in the
|
|
442
441
|
* allocation band (`port >= basePort` -- the 6510-6599 range below it stays
|
|
443
442
|
* reserved by convention for an emulator a human launched for their own
|
|
444
|
-
* work
|
|
443
|
+
* work), and for each one reads its OWN `epoch.json` -- never a host
|
|
445
444
|
* process listing, never an argv scan. A directory whose record is absent,
|
|
446
445
|
* unparseable, or carries no finite positive `pid` contributes nothing to
|
|
447
446
|
* `found`/`killed` and is skipped by the kill half entirely, but the epoch
|
|
448
447
|
* bump below still runs for it: a registry-free restart must still void
|
|
449
448
|
* every in-band instance directory it finds, including one it has no usable
|
|
450
|
-
* pid for, which is the exact case
|
|
451
|
-
*
|
|
449
|
+
* pid for, which is the exact case a registry-free restart must handle --
|
|
450
|
+
* the void has
|
|
452
451
|
* to reach instances a registry-free restart never heard of. A record that
|
|
453
452
|
* DOES carry a usable pid AND a non-empty `vice_bin` is killed via
|
|
454
453
|
* verifiedKill() with `expectedIdentity` set to THAT record's own `vice_bin`
|
|
@@ -456,7 +455,7 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
|
|
|
456
455
|
* does not match what THIS broker itself recorded launching there is refused
|
|
457
456
|
* (`identity_refused`), exactly like every other verifiedKill() call site in
|
|
458
457
|
* this module. A record carrying a usable pid but NO `vice_bin` is NOT a kill
|
|
459
|
-
* candidate at all
|
|
458
|
+
* candidate at all: it contributes nothing to `found`/`killed`, the
|
|
460
459
|
* kill dep is never invoked, and only the epoch bump runs -- an unidentifiable
|
|
461
460
|
* pid is refused, never killed on the strength of the pid alone.
|
|
462
461
|
*
|
|
@@ -484,7 +483,7 @@ export async function reapOrphanedInstances(options) {
|
|
|
484
483
|
if (typeof pid === "number" && Number.isFinite(pid) && pid > 0) {
|
|
485
484
|
const expectedIdentity = typeof epochFields?.vice_bin === "string" ? epochFields.vice_bin : "";
|
|
486
485
|
if (expectedIdentity === "") {
|
|
487
|
-
//
|
|
486
|
+
// A record with a usable pid but NO recorded identity is not a
|
|
488
487
|
// kill candidate at all -- it is not counted in `found` and the kill
|
|
489
488
|
// dep is never invoked. verifiedKill() refuses an empty identity too
|
|
490
489
|
// (second layer, deliberately: removing either leaves the other
|