@henols/vice-mcp 0.1.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 +66 -0
- package/build.ts +268 -0
- package/container-guard.mts +226 -0
- package/containerpath.ts +296 -0
- package/hostpath.ts +318 -0
- package/incident-record.ts +442 -0
- package/install-resources.ts +532 -0
- package/package.json +71 -0
- package/refresh-manifest.ts +103 -0
- package/repo-root.ts +198 -0
- package/resources/broker-control.mjs +343 -0
- package/resources/broker-epoch.mjs +126 -0
- package/resources/broker-kill.mjs +491 -0
- package/resources/broker-launch.mjs +659 -0
- package/resources/broker-state.mjs +173 -0
- package/resources/container-guard.mjs +211 -0
- package/resources/vice-broker.mjs +855 -0
- package/resources/vice-launcher.sh +169 -0
- package/tools-manifest.json +1231 -0
- package/vice-broker-client.ts +899 -0
- package/vice-probe.ts +278 -0
- package/vice-proxy.ts +3093 -0
- package/vice-sync.ts +336 -0
- package/vice.ts +772 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
// GENERATED FILE -- DO NOT EDIT.
|
|
2
|
+
// Compiled by `tsc` from vice-broker.mts. Edit the TypeScript source and rebuild;
|
|
3
|
+
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
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
|
|
6
|
+
// rebuild.
|
|
7
|
+
// vice-broker.mts
|
|
8
|
+
//
|
|
9
|
+
// The long-lived host broker entry point (Phase 01.6.2). Extends the Phase
|
|
10
|
+
// 01.6 tracer in place rather than replacing it: parseArgs(),
|
|
11
|
+
// readBrokerRecordMaybe() and the atomic tmp-sibling-then-rename write
|
|
12
|
+
// discipline all survive; main() grows a real control listener, a
|
|
13
|
+
// heartbeat and a real acquire/release path spawning a real child.
|
|
14
|
+
//
|
|
15
|
+
// heartbeat_at is now MANDATORY, refreshed on a recurring timer for as long
|
|
16
|
+
// as this process lives. The tracer's own header comment used to forbid it
|
|
17
|
+
// ("DELIBERATELY OMITS heartbeat_at") because a heartbeat-less record from a
|
|
18
|
+
// write-once tracer that immediately exits would strand every later
|
|
19
|
+
// session's readBrokerLiveness() classification at never_started forever.
|
|
20
|
+
// That reasoning does not apply here: this broker is genuinely long-lived,
|
|
21
|
+
// so omitting heartbeat_at would instead make a REAL, RUNNING broker read
|
|
22
|
+
// as never_started -- exactly the failure this field exists to prevent.
|
|
23
|
+
//
|
|
24
|
+
// Imports node: builtins ONLY plus this phase's own sibling modules --
|
|
25
|
+
// mcp__vice__* stays the only route to the emulator; nothing here opens a
|
|
26
|
+
// connection to it.
|
|
27
|
+
import { readFileSync, mkdirSync, openSync, writeFileSync, chmodSync, renameSync } from "node:fs";
|
|
28
|
+
import { join, basename, resolve as resolvePath } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
31
|
+
import { containerGuardReport, containerGuardEnforce } from "./container-guard.mjs";
|
|
32
|
+
import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, } from "./broker-state.mjs";
|
|
33
|
+
import { acquirePortAndLaunch, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
|
|
34
|
+
import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
|
|
35
|
+
import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
|
|
36
|
+
import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
|
|
37
|
+
const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
|
|
38
|
+
/** `--repo-root` is required UNLESS `--check-container` is given -- the
|
|
39
|
+
* container guard needs no paths at all, matching the bash launcher's own
|
|
40
|
+
* `--check-container` handling (answered before any path resolution).
|
|
41
|
+
* `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
|
|
42
|
+
* otherwise `.vice-supervisor` under the repo root. */
|
|
43
|
+
export function parseArgs(argv) {
|
|
44
|
+
let repoRoot = null;
|
|
45
|
+
let stateDir = null;
|
|
46
|
+
let checkContainer = false;
|
|
47
|
+
let dryRun = false;
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
if (argv[i] === "--repo-root") {
|
|
50
|
+
repoRoot = argv[i + 1] ?? null;
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
else if (argv[i] === "--state-dir") {
|
|
54
|
+
stateDir = argv[i + 1] ?? null;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
else if (argv[i] === "--check-container") {
|
|
58
|
+
checkContainer = true;
|
|
59
|
+
}
|
|
60
|
+
else if (argv[i] === "--dry-run") {
|
|
61
|
+
dryRun = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!checkContainer && !repoRoot) {
|
|
65
|
+
throw new Error(USAGE);
|
|
66
|
+
}
|
|
67
|
+
const resolvedStateDir = stateDir ?? process.env.VICE_POOL_DIR ?? (repoRoot ? join(repoRoot, ".vice-supervisor") : ".vice-supervisor");
|
|
68
|
+
return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
|
|
69
|
+
}
|
|
70
|
+
/** The deployed JavaScript broker artifact's own name -- D-26's entire
|
|
71
|
+
* point: this field used to read "vice-broker.sh" (the retiring bash
|
|
72
|
+
* daemon), which was false the moment a real TypeScript broker existed.
|
|
73
|
+
* It now names itself. */
|
|
74
|
+
export const WRITTEN_BY = "vice-broker.mjs";
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Small, locally-duplicated env-var readers (plan 05) -- the SAME pattern
|
|
77
|
+
// broker-kill.mts's own resolveBasePortForReap()/resolveViceBinForReap()
|
|
78
|
+
// already established: this module cannot import broker-launch.mts's
|
|
79
|
+
// PRIVATE resolveWarmFloor()/resolveCeiling() (they are not exported, and
|
|
80
|
+
// this file is already the top-level wiring module value-importing every
|
|
81
|
+
// sibling .mjs directly -- exporting them would widen broker-launch.mts's
|
|
82
|
+
// own surface for a one-line env-var read this file can duplicate exactly
|
|
83
|
+
// as cheaply). Both mirror broker-launch.mts's defaults precisely
|
|
84
|
+
// (VICE_BROKER_WARM_FLOOR/1, VICE_BROKER_MAX/16) so broker.json's config echo
|
|
85
|
+
// and host_state's own answer can never disagree with what maintainWarmFloor
|
|
86
|
+
// itself actually enforces. The floor default dropped from 3 to 1 in
|
|
87
|
+
// 01.6.2.1-03-PLAN.md (D-06) -- BOTH readers changed together in that same
|
|
88
|
+
// commit, deliberately, because this invariant (the two numbers never
|
|
89
|
+
// disagree) breaks silently the moment only one of them moves. The
|
|
90
|
+
// ceiling's own default (16) is untouched by D-06 -- it is the unrun
|
|
91
|
+
// concurrency-ceiling spike's territory, not this phase's.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
function resolveWarmFloorForRecord() {
|
|
94
|
+
const raw = process.env.VICE_BROKER_WARM_FLOOR;
|
|
95
|
+
if (raw === undefined || raw === "")
|
|
96
|
+
return 1;
|
|
97
|
+
const n = Number(raw);
|
|
98
|
+
return Number.isFinite(n) ? n : 1;
|
|
99
|
+
}
|
|
100
|
+
function resolveCeilingForRecord() {
|
|
101
|
+
const raw = process.env.VICE_BROKER_MAX;
|
|
102
|
+
if (raw === undefined || raw === "")
|
|
103
|
+
return 16;
|
|
104
|
+
const n = Number(raw);
|
|
105
|
+
return Number.isFinite(n) ? n : 16;
|
|
106
|
+
}
|
|
107
|
+
function resolveViceBinForHostState() {
|
|
108
|
+
return process.env.VICE_BIN ?? "x64sc";
|
|
109
|
+
}
|
|
110
|
+
/** Duplicates vice-broker-client.ts's readBrokerLiveness() classification
|
|
111
|
+
* logic (never_started / stale / alive against BROKER_STALE_MS) rather than
|
|
112
|
+
* importing it -- confirmed empirically (plan 02's own SUMMARY) that
|
|
113
|
+
* importing vice-broker-client.ts into a HOST-BOUND module pulls its
|
|
114
|
+
* transitive dependents (repo-root.ts, install-resources.ts, hostpath.ts)
|
|
115
|
+
* into the SAME tsc build program, which either fails to compile under
|
|
116
|
+
* tsconfig.build.json's allowImportingTsExtensions:false or forces those
|
|
117
|
+
* container-side files to be committed under resources/ as if host-bound.
|
|
118
|
+
* This is the SAME classification a test can drive the REAL
|
|
119
|
+
* readBrokerLiveness() over (broker-control.test.ts does exactly that,
|
|
120
|
+
* against records this function's own caller writes), proving the two never
|
|
121
|
+
* diverge -- this module only needs the classification NAME (never_started
|
|
122
|
+
* / stale / alive), never the pid/heartbeatAt fields readBrokerLiveness()
|
|
123
|
+
* also returns. */
|
|
124
|
+
const BROKER_STALE_MS = Number(process.env.VICE_BROKER_STALE_MS || 180000);
|
|
125
|
+
function classifyBrokerLivenessLocal(path) {
|
|
126
|
+
const parsed = readBrokerRecordMaybe(path);
|
|
127
|
+
if (parsed === null)
|
|
128
|
+
return "never_started";
|
|
129
|
+
const heartbeatAt = typeof parsed.heartbeat_at === "string" ? parsed.heartbeat_at : null;
|
|
130
|
+
const heartbeatMs = heartbeatAt ? Date.parse(heartbeatAt) : NaN;
|
|
131
|
+
if (!Number.isFinite(heartbeatMs))
|
|
132
|
+
return "never_started";
|
|
133
|
+
return Date.now() - heartbeatMs > BROKER_STALE_MS ? "stale" : "alive";
|
|
134
|
+
}
|
|
135
|
+
function isPlainObject(value) {
|
|
136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
/** Read and parse a broker record, treating anything short of a
|
|
139
|
+
* well-formed object as "not there yet" -- missing file, unreadable file,
|
|
140
|
+
* partial write, malformed JSON, non-object shape. Never throws. */
|
|
141
|
+
export function readBrokerRecordMaybe(path) {
|
|
142
|
+
let raw;
|
|
143
|
+
try {
|
|
144
|
+
raw = readFileSync(path, "utf8");
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Atomic tmp-sibling -> mode-tighten -> content -> rename, the same
|
|
158
|
+
* choke-point discipline the tracer's own writeBrokerRecord() used, now
|
|
159
|
+
* shared by both the initial write and every heartbeat refresh -- mode
|
|
160
|
+
* stays owner-read-write on EVERY write, refresh included. */
|
|
161
|
+
function writeBrokerRecordFile(stateDir, record) {
|
|
162
|
+
mkdirSync(stateDir, { recursive: true });
|
|
163
|
+
const finalPath = join(stateDir, "broker.json");
|
|
164
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
165
|
+
writeFileSync(tmpPath, "");
|
|
166
|
+
chmodSync(tmpPath, 0o600);
|
|
167
|
+
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
|
|
168
|
+
renameSync(tmpPath, finalPath);
|
|
169
|
+
return finalPath;
|
|
170
|
+
}
|
|
171
|
+
/** Builds a spawn function that redirects the child's stdout/stderr into a
|
|
172
|
+
* FRESH per-launch log file under logDir (D-23: per-instance boot/crash
|
|
173
|
+
* logs survive under .vice-supervisor/<port>/logs/, same paths, same
|
|
174
|
+
* format as the retiring bash supervisor), returning both the spawn
|
|
175
|
+
* closure and the log's path relative to supervisorDir (the epoch
|
|
176
|
+
* record's own `log` field). Shared by both launch paths -- a cold
|
|
177
|
+
* acquire and warm-floor maintenance -- so there is exactly one place that
|
|
178
|
+
* opens a launch log fd. */
|
|
179
|
+
function makeLoggingSpawn(logDir) {
|
|
180
|
+
mkdirSync(logDir, { recursive: true });
|
|
181
|
+
const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
|
|
182
|
+
const logName = `${viceBinForLog}-${Date.now()}.log`;
|
|
183
|
+
const logFd = openSync(join(logDir, logName), "a");
|
|
184
|
+
return {
|
|
185
|
+
spawn: (cmd, cmdArgs) => nodeSpawn(cmd, cmdArgs, { stdio: ["ignore", logFd, logFd] }),
|
|
186
|
+
logRelPath: `logs/${logName}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Writes the epoch record for a just-launched instance -- shared by both
|
|
190
|
+
* launch paths so D-04's contract (format, location, atomic-write
|
|
191
|
+
* discipline, all unchanged -- only the writer moves) is discharged from
|
|
192
|
+
* exactly one place regardless of WHY the instance was launched. A
|
|
193
|
+
* granted instance and a still-warm instance are equally real processes; both
|
|
194
|
+
* need a real epoch.json the moment they exist, or plan 04's grant-time
|
|
195
|
+
* re-probe (which reads a warm instance's recorded epoch_file, per
|
|
196
|
+
* grant_from_spare()'s bash original) would carry forward a path to
|
|
197
|
+
* nothing. */
|
|
198
|
+
function writeEpochForLaunch(record, logRelPath) {
|
|
199
|
+
const epochRecord = {
|
|
200
|
+
epoch: 1,
|
|
201
|
+
spawned_at: new Date(record.launchedAt).toISOString(),
|
|
202
|
+
pid: record.pid,
|
|
203
|
+
supervisor_pid: process.pid,
|
|
204
|
+
vice_bin: record.viceBin,
|
|
205
|
+
vice_args: record.viceArgs,
|
|
206
|
+
log: logRelPath,
|
|
207
|
+
dry_run: false,
|
|
208
|
+
};
|
|
209
|
+
writeEpochRecord({ supervisorDir: record.supervisorDir, record: epochRecord });
|
|
210
|
+
// The in-memory record's own epoch field must carry the SAME value the
|
|
211
|
+
// epoch record was just written with -- without this, every
|
|
212
|
+
// first-generation instance reports an absent epoch to the status
|
|
213
|
+
// response and an absent epoch-before in a recycle acknowledgement,
|
|
214
|
+
// making a later respawn's advance unobservable at the one place a
|
|
215
|
+
// caller reads it (handleStatus(), handleRecycleForRealBroker()).
|
|
216
|
+
record.epoch = epochRecord.epoch;
|
|
217
|
+
}
|
|
218
|
+
/** Builds the supervision dependency object for withCrashSupervision(),
|
|
219
|
+
* once per launch, so both real launch paths (handleAcquire here; Task 2's
|
|
220
|
+
* maintainWarmFloorForRealBroker) pass a structurally identical
|
|
221
|
+
* SuperviseChildDeps object into the SAME shared wrapper. Deliberately does
|
|
222
|
+
* NOT set spawnFactory: on a respawn, launchSupervised() (broker-launch.mts)
|
|
223
|
+
* derives its own per-instance log path from instanceLogDirFor and names
|
|
224
|
+
* that same path in the epoch record it writes -- supplying a competing
|
|
225
|
+
* spawn factory here would produce two log files per respawn with the
|
|
226
|
+
* epoch record naming the wrong one. Leaving it unset means a respawn's
|
|
227
|
+
* output lands in the supervision module's own log file under the same
|
|
228
|
+
* per-instance logs directory D-23 requires, and the epoch record names
|
|
229
|
+
* the file that actually received the output. */
|
|
230
|
+
function superviseDepsFor(stateDir, state) {
|
|
231
|
+
return {
|
|
232
|
+
state,
|
|
233
|
+
stateDir,
|
|
234
|
+
epoch: { epochPathFor, instanceLogDirFor, nextEpochFor, writeEpochRecord },
|
|
235
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** Sets the deliberate-death marker and its respawn-after-kill answer
|
|
239
|
+
* TOGETHER -- the single place in this module that ever writes either
|
|
240
|
+
* field, so a call site can never set one and forget the other, which is
|
|
241
|
+
* the exact shape of the defect this closes (T-01.6.2-80). Called BEFORE
|
|
242
|
+
* any signal reaches the target child in both handlers below, never after:
|
|
243
|
+
* the exit handler (broker-launch.mts) runs on the child's OWN exit event,
|
|
244
|
+
* so a marker set after the signal arrives too late to be read
|
|
245
|
+
* (T-01.6.2-84). */
|
|
246
|
+
function markDeliberateDeath(instance, respawnAfterKill) {
|
|
247
|
+
instance.deliberateKill = true;
|
|
248
|
+
instance.respawnAfterKill = respawnAfterKill;
|
|
249
|
+
}
|
|
250
|
+
/** Walks `state.instances` for probe-live `ready` candidates, in iteration
|
|
251
|
+
* order, and returns the first that answers a grant-time re-probe (P-02) --
|
|
252
|
+
* or `null` once every candidate has been tried and none answered, letting
|
|
253
|
+
* the caller fall through to a cold launch (P-03). Regardless of
|
|
254
|
+
* `record.reason`: per D-07, a waiting request takes an instance whichever
|
|
255
|
+
* reason booted it, so a warm-floor instance and a not-yet-granted instance are
|
|
256
|
+
* equally eligible. Kill-never-recycle needs no separate guard here --
|
|
257
|
+
* handleRelease() below already deletes a released instance's record
|
|
258
|
+
* outright, so a released instance is structurally absent from
|
|
259
|
+
* `state.instances` and can never be a candidate.
|
|
260
|
+
*
|
|
261
|
+
* A candidate whose grant-time probe FAILS is dropped -- de-registered from
|
|
262
|
+
* `state.instances` -- and identity-verified-killed BEFORE the walk
|
|
263
|
+
* continues to the next candidate, but per WR-02
|
|
264
|
+
* (`.planning/todos/pending/2026-08-05-wr-02-*`, decision: fix now rather
|
|
265
|
+
* than defer further) the kill itself is fire-and-forget, matching
|
|
266
|
+
* handleRelease()'s own posture a few hundred lines below
|
|
267
|
+
* (`verifiedKill(...).catch(...)`, never awaited by that call site either):
|
|
268
|
+
* the acquiring request must not wait up to `VICE_BROKER_KILL_WAIT_S`
|
|
269
|
+
* (default 5s) of SIGTERM-then-poll-then-SIGKILL PER DEAD CANDIDATE before
|
|
270
|
+
* the walk can move on -- that wait is exactly what turns a warm floor's
|
|
271
|
+
* fast, in-memory grant into a multi-second serial teardown on a single
|
|
272
|
+
* request's hot path once the warm floor is configured above its default
|
|
273
|
+
* of 1 (WR-02's own bounding condition). The drop -- `markDeliberateDeath()`
|
|
274
|
+
* plus `state.instances.delete()` -- still happens SYNCHRONOUSLY, in the
|
|
275
|
+
* same tick as the probe failure, before `deps.kill(...)` is even invoked;
|
|
276
|
+
* only the kill's own SETTLEMENT is decoupled from this walk. This is
|
|
277
|
+
* WR-02's fix option 1, not option 2 (capping how many failed candidates a
|
|
278
|
+
* single acquire will wait through): option 1 matches an idiom the file
|
|
279
|
+
* already uses elsewhere rather than inventing a new bound, and removes the
|
|
280
|
+
* wait entirely rather than merely capping it. The grant-time-probe-failure
|
|
281
|
+
* log line's own ordering is decoupled accordingly (see below) -- it can no
|
|
282
|
+
* longer name the kill's resolved stage synchronously, since nothing here
|
|
283
|
+
* waits for it to resolve. The marker is set BEFORE any signal reaches the
|
|
284
|
+
* child (markDeliberateDeath()'s own contract), with a FALSE
|
|
285
|
+
* respawn-after-kill answer -- this arm never wants a replacement on the
|
|
286
|
+
* SAME port; a replacement, if any, comes from either the next candidate in
|
|
287
|
+
* this same walk or the caller's own cold-launch fall-through.
|
|
288
|
+
*
|
|
289
|
+
* Re-checks `record.state === "ready"` AND map membership by identity
|
|
290
|
+
* immediately after every `await` (the probe call itself) and BEFORE ever
|
|
291
|
+
* treating a probe-live candidate as the winner -- this is what makes the
|
|
292
|
+
* caller's own "no await between selection and the grant-recording step"
|
|
293
|
+
* property (T-01.6.2.1-03) actually hold under two concurrent acquires. A
|
|
294
|
+
* candidate's own probe response cannot change because a sibling acquire
|
|
295
|
+
* granted it first, but its RECORDED state does, the instant that sibling's
|
|
296
|
+
* synchronous grant step runs -- recorded state alone catches that case.
|
|
297
|
+
* It does NOT catch a sibling that has already DROPPED this exact candidate
|
|
298
|
+
* (a failed grant-time probe: markDeliberateDeath() + state.instances.delete(),
|
|
299
|
+
* which never touches record.state -- the drop path a few lines below) --
|
|
300
|
+
* 01.6.2.1-VERIFICATION.md's CR-01 finding, re-confirmed here: a state-only
|
|
301
|
+
* recheck is blind to a concurrent drop, letting a second caller's stale
|
|
302
|
+
* object reference win a grant for a record that is no longer in
|
|
303
|
+
* state.instances at all, orphaning the grant. Rechecking
|
|
304
|
+
* `state.instances.get(record.port) === record` (identity, not merely a
|
|
305
|
+
* port-number lookup) closes that case too. */
|
|
306
|
+
async function selectWarmInstance(state, deps) {
|
|
307
|
+
for (const record of Array.from(state.instances.values())) {
|
|
308
|
+
if (record.state !== "ready")
|
|
309
|
+
continue;
|
|
310
|
+
const isReady = await deps.probe(record.port);
|
|
311
|
+
// A sibling acquire may have granted OR dropped this exact candidate
|
|
312
|
+
// while this probe was in flight. "Granted" changes record.state;
|
|
313
|
+
// "dropped" removes the record from state.instances outright and never
|
|
314
|
+
// touches record.state -- so map membership must be rechecked too, not
|
|
315
|
+
// merely the state field (CR-01, 01.6.2.1-REVIEW.md/01.6.2.1-VERIFICATION.md).
|
|
316
|
+
if (record.state !== "ready" || state.instances.get(record.port) !== record) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (isReady) {
|
|
320
|
+
return record;
|
|
321
|
+
}
|
|
322
|
+
// Drop and de-register FIRST, synchronously, before the kill is even
|
|
323
|
+
// invoked -- this is what CR-01's identity recheck above depends on:
|
|
324
|
+
// the record must already be gone from state.instances by the time a
|
|
325
|
+
// concurrent sibling's own probe on this same candidate resolves.
|
|
326
|
+
// WR-02 only changes what happens to the kill's own PROMISE next, never
|
|
327
|
+
// this ordering.
|
|
328
|
+
markDeliberateDeath(record, false);
|
|
329
|
+
state.instances.delete(record.port);
|
|
330
|
+
// Distinct wording from shutdown()'s own "shutdown complete" line
|
|
331
|
+
// (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
|
|
332
|
+
// path -- D-07's standing constraint that a lifecycle decision must be
|
|
333
|
+
// reconstructable from the log after an incident (both 2026-08-01 and
|
|
334
|
+
// 2026-08-02 were diagnosed from broker log lines). Logged BEFORE the
|
|
335
|
+
// kill settles (WR-02): the walk does not wait for deps.kill(...) to
|
|
336
|
+
// resolve, so this line can no longer name the kill's resolved stage --
|
|
337
|
+
// that gets its own, separately-logged line once the kill settles,
|
|
338
|
+
// below.
|
|
339
|
+
deps.log(`vice-broker: grant-time probe failed for port ${record.port} (pid ${record.pid ?? "null"}) -- dropped the record and kicked off an identity-verified kill of the pid (not awaited by the acquire walk, WR-02)`);
|
|
340
|
+
// Fire-and-forget, matching handleRelease()'s own posture
|
|
341
|
+
// (`verifiedKill(...).catch(...)`, a few hundred lines below in this
|
|
342
|
+
// same file) -- the acquire walk moves on to the next candidate (or
|
|
343
|
+
// returns null to the cold-launch fall-through) without waiting up to
|
|
344
|
+
// VICE_BROKER_KILL_WAIT_S per dead candidate. Still identity-verified:
|
|
345
|
+
// this is the SAME deps.kill, never replaced by a bare, unverified
|
|
346
|
+
// signal. The settlement is only OBSERVED asynchronously, via its own
|
|
347
|
+
// log line, never awaited.
|
|
348
|
+
void deps
|
|
349
|
+
.kill({ pid: record.pid, expectedIdentity: record.expectedIdentity })
|
|
350
|
+
.then((killStage) => {
|
|
351
|
+
deps.log(`vice-broker: grant-time-probe-failure kill for port ${record.port} (pid ${record.pid ?? "null"}) settled (kill stage: ${killStage})`);
|
|
352
|
+
})
|
|
353
|
+
.catch(() => {
|
|
354
|
+
// best-effort; nothing further to report on this path, matching
|
|
355
|
+
// handleRelease()'s own posture at its own verifiedKill(...).catch(...) call site.
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
/** Resolves a GRANTABLE instance -- a cold launch is one of two ways of
|
|
361
|
+
* obtaining one, not the only one (this task's own assumption-delta
|
|
362
|
+
* decision: "resolve a grantable instance" is now the primary operation).
|
|
363
|
+
* The warm-instance selection arm (selectWarmInstance(), P-01) runs BEFORE
|
|
364
|
+
* the cold-launch arm; `atCapacity()` gates ONLY the cold-launch arm --
|
|
365
|
+
* checked only once selectWarmInstance() has already answered `null` (no
|
|
366
|
+
* probe-live candidate available) -- NOT before either arm (WR-01,
|
|
367
|
+
* 01.6.2.1-REVIEW.md). A full host still refuses a fresh cold launch before
|
|
368
|
+
* ever touching the port allocator, but a ready, probe-live warm candidate
|
|
369
|
+
* is grantable even when the ceiling is already reached: granting it
|
|
370
|
+
* creates no NEW instance and does not raise `countTotal()`, so refusing to
|
|
371
|
+
* hand out an already-existing idle one was a real availability bug, not a
|
|
372
|
+
* correct interpretation of the ceiling's own purpose (bounding concurrent
|
|
373
|
+
* emulator *processes*, not bounding how many of those processes may be
|
|
374
|
+
* *handed out*). Both arms converge on exactly ONE `state.grants.set()`
|
|
375
|
+
* call -- load-bearing for task 2's structural anti-regression gate, which
|
|
376
|
+
* counts it -- fed by whichever arm produced a record. Answers the full
|
|
377
|
+
* discriminated AcquireOutcome (plan 05): `at_capacity` when the ceiling is
|
|
378
|
+
* already reached AND no warm candidate could be served,
|
|
379
|
+
* `no_free_port`/`launch_in_flight` passed straight through from
|
|
380
|
+
* acquirePortAndLaunch()'s own typed failure (the cold arm only), and
|
|
381
|
+
* `internal` only for a genuine, otherwise-unclassified fault. A
|
|
382
|
+
* `launch_in_flight` outcome is NOT a control-plane error -- broker-
|
|
383
|
+
* control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
|
|
384
|
+
* retry it later rather than refusing it. */
|
|
385
|
+
export async function handleAcquire(requestId, stateDir, state, deps = {}) {
|
|
386
|
+
const probe = deps.probe ?? ((port) => probeReady(port));
|
|
387
|
+
// Textually a verifiedKill( call site, not merely a reference -- reused
|
|
388
|
+
// UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
|
|
389
|
+
// re-derived, and never replaced by a bare process.kill().
|
|
390
|
+
const kill = deps.kill ?? ((opts) => verifiedKill(opts));
|
|
391
|
+
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
392
|
+
const winner = await selectWarmInstance(state, { probe, kill, log });
|
|
393
|
+
let record;
|
|
394
|
+
if (winner) {
|
|
395
|
+
record = winner;
|
|
396
|
+
}
|
|
397
|
+
else if (atCapacity(state)) {
|
|
398
|
+
return { ok: false, reason: "at_capacity" };
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
// acquirePortAndLaunch() holds the single in_flight owner across its own
|
|
402
|
+
// async port allocation (not merely tryLaunchOne()'s synchronous spawn
|
|
403
|
+
// instant) -- see that function's own header comment for the race this
|
|
404
|
+
// closes between a cold acquire and a concurrent warm-floor pass. This
|
|
405
|
+
// is also what restores vice-broker.sh's own process_requests() throttle:
|
|
406
|
+
// a cold acquire that arrives while ANY launch (cold or warm) is already
|
|
407
|
+
// under way is queued here (plan 05), matching the bash original's
|
|
408
|
+
// declined-to-change behaviour of never racing a second instance into
|
|
409
|
+
// existence, but answered LATER instead of refused outright.
|
|
410
|
+
let lastLogRelPath = "";
|
|
411
|
+
const result = await acquirePortAndLaunch("acquire", {
|
|
412
|
+
state,
|
|
413
|
+
stateDir,
|
|
414
|
+
allocatePort: nextFreePort,
|
|
415
|
+
spawnFactory: deps.buildColdSpawnFactory ??
|
|
416
|
+
((port) => {
|
|
417
|
+
const supervisorDir = join(stateDir, String(port));
|
|
418
|
+
const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
|
|
419
|
+
lastLogRelPath = logRelPath;
|
|
420
|
+
return withCrashSupervision("acquire", port, spawn, superviseDepsFor(stateDir, state));
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
if (!result.ok) {
|
|
424
|
+
return { ok: false, reason: result.reason };
|
|
425
|
+
}
|
|
426
|
+
if (result.record.pid === null) {
|
|
427
|
+
// WR-03 (01.6.2.1-REVIEW.md): the spawn never forked a real process
|
|
428
|
+
// (e.g. a bad VICE_BIN path), so there is nothing to signal -- the
|
|
429
|
+
// fix is deleting the just-created broken record alone. Without this,
|
|
430
|
+
// a configuration failure would silently occupy a port slot and count
|
|
431
|
+
// toward countTotal()/atCapacity() until crash supervision's own
|
|
432
|
+
// delayed respawn/give-up machinery eventually noticed and freed it,
|
|
433
|
+
// even though the caller was already told "internal" right now.
|
|
434
|
+
state.instances.delete(result.record.port);
|
|
435
|
+
return { ok: false, reason: "internal" };
|
|
436
|
+
}
|
|
437
|
+
record = result.record;
|
|
438
|
+
// Only the cold-launch arm ever writes a FRESH epoch record -- the warm
|
|
439
|
+
// arm's winner already has one, written when it was warmed
|
|
440
|
+
// (maintainWarmFloorForRealBroker()'s own onLaunched hook), and
|
|
441
|
+
// rewriting it here would advance an epoch no restart caused, which the
|
|
442
|
+
// container-side assertSameMachine() would read as a machine change.
|
|
443
|
+
writeEpochForLaunch(record, lastLogRelPath);
|
|
444
|
+
}
|
|
445
|
+
// THE single grant-recording step, fed by both arms above -- no `await`
|
|
446
|
+
// between resolving `record` (whichever arm produced it) and this
|
|
447
|
+
// synchronous pair, so two concurrent acquires can never both grant the
|
|
448
|
+
// SAME record (T-01.6.2.1-03; see selectWarmInstance()'s own re-check for
|
|
449
|
+
// the other half of that guarantee).
|
|
450
|
+
state.grants.set(requestId, { id: requestId, port: record.port, grantedAt: Date.now(), pid: record.pid });
|
|
451
|
+
record.state = "granted";
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
grant: { port: record.port, url: record.url, epochFile: record.epochFile, supervisorDir: record.supervisorDir },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** Answers the `status` control-plane request: one entry per instance,
|
|
458
|
+
* computed on demand from the SAME in-memory map every other count reads --
|
|
459
|
+
* strictly better than the dropped broker-instances.json projection, which
|
|
460
|
+
* could go stale between passes (D-24). */
|
|
461
|
+
function handleStatus(state) {
|
|
462
|
+
return Array.from(state.instances.values()).map((r) => ({
|
|
463
|
+
port: r.port,
|
|
464
|
+
url: r.url,
|
|
465
|
+
state: r.state,
|
|
466
|
+
reason: r.reason,
|
|
467
|
+
epoch: typeof r.epoch === "number" ? r.epoch : null,
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
/** Resolves a recycle target's emulator child pid from THIS broker's own
|
|
471
|
+
* in-memory instance record -- record.pid is, by construction, exactly the
|
|
472
|
+
* same value broker-epoch.mts's writer puts in epoch.json's own `pid` field
|
|
473
|
+
* (both are set from the same spawned child's own pid at launch time, and
|
|
474
|
+
* both are updated together on every respawn) -- so reading it here is
|
|
475
|
+
* reading "the epoch record's pid", never the supervising broker's own
|
|
476
|
+
* process.pid (T-01.6.2-17; there is no intermediate supervisor process in
|
|
477
|
+
* this topology at all, per broker-kill.mts's own header comment). A
|
|
478
|
+
* recycle's OWNERSHIP check (does this connection hold this grant) already
|
|
479
|
+
* happened in broker-control.mts before this function is ever called -- this
|
|
480
|
+
* function only resolves, marks and kills.
|
|
481
|
+
*
|
|
482
|
+
* Marks the death as broker-ordered AND to be replaced, with a TRUE
|
|
483
|
+
* respawn-after-kill answer, BEFORE the kill -- the actual replacement is
|
|
484
|
+
* then carried out by the per-child supervision exit handler
|
|
485
|
+
* (broker-launch.mts's handleExit(), wired in by plan 12) on the SAME port,
|
|
486
|
+
* asynchronously, after this function has already returned its own
|
|
487
|
+
* acknowledgement. This is exactly what the tool description's own "via the
|
|
488
|
+
* host supervisor's existing respawn loop" wording describes: this function
|
|
489
|
+
* marks and kills; the respawn loop is the exit handler, not this function.
|
|
490
|
+
* Neither the grant nor the instance entry is deleted here -- the grant is
|
|
491
|
+
* what keeps the recycled port belonging to this same session, and the
|
|
492
|
+
* instance entry is what the exit handler reads to decide the relaunch;
|
|
493
|
+
* both must still exist once this function returns for the exit handler to
|
|
494
|
+
* have anything to act on. */
|
|
495
|
+
async function handleRecycleForRealBroker(targetId, state) {
|
|
496
|
+
const grant = state.grants.get(targetId);
|
|
497
|
+
if (!grant) {
|
|
498
|
+
return {
|
|
499
|
+
port: null,
|
|
500
|
+
pid: null,
|
|
501
|
+
viceBin: null,
|
|
502
|
+
killStage: "no_signal",
|
|
503
|
+
epochBefore: null,
|
|
504
|
+
outcome: "grant_lookup_failed",
|
|
505
|
+
reason: `no grant record found for target ${targetId}`,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const instance = state.instances.get(grant.port);
|
|
509
|
+
if (!instance) {
|
|
510
|
+
return {
|
|
511
|
+
port: grant.port,
|
|
512
|
+
pid: null,
|
|
513
|
+
viceBin: null,
|
|
514
|
+
killStage: "no_signal",
|
|
515
|
+
epochBefore: null,
|
|
516
|
+
outcome: "epoch_lookup_failed",
|
|
517
|
+
reason: `no resolvable epoch record for target ${targetId} (port ${grant.port})`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (instance.pid === null) {
|
|
521
|
+
return {
|
|
522
|
+
port: instance.port,
|
|
523
|
+
pid: null,
|
|
524
|
+
viceBin: instance.viceBin,
|
|
525
|
+
killStage: "no_signal",
|
|
526
|
+
epochBefore: typeof instance.epoch === "number" ? instance.epoch : null,
|
|
527
|
+
outcome: "pid_lookup_failed",
|
|
528
|
+
reason: `epoch record carries no pid for target ${targetId}`,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const epochBefore = typeof instance.epoch === "number" ? instance.epoch : null;
|
|
532
|
+
markDeliberateDeath(instance, true);
|
|
533
|
+
const killStage = await verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity });
|
|
534
|
+
const outcome = killStage === "identity_refused" ? "identity_refused" : "ok";
|
|
535
|
+
const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
|
|
536
|
+
return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
|
|
537
|
+
}
|
|
538
|
+
/** The warm-floor concern of the fixed-order evaluation pass (D-24 drops
|
|
539
|
+
* the projection write; the grant sweep does not appear -- D-12's
|
|
540
|
+
* connection-is-the-lease). Builds a fresh MaintainWarmFloorDeps per call
|
|
541
|
+
* (never reused across passes) wiring broker-state.mjs's real
|
|
542
|
+
* allocatePort/counts and broker-launch.mjs's real probeReady, and hooks
|
|
543
|
+
* onLaunched to write the SAME epoch record a cold acquire writes -- a
|
|
544
|
+
* warm instance is a real process the moment it exists, per D-04.
|
|
545
|
+
*
|
|
546
|
+
* WR-04 (01.6.2.1-REVIEW.md): the log-path stash below is a LOCAL variable,
|
|
547
|
+
* declared fresh once per call to THIS function -- exactly mirroring how
|
|
548
|
+
* handleAcquire()'s own equivalent cold-launch log-path variable
|
|
549
|
+
* (`lastLogRelPath`) is already scoped locally rather than to the module.
|
|
550
|
+
* Both the write site (the spawn-wrapping closure) and the read site (the
|
|
551
|
+
* `onLaunched` callback) live inside this SAME function body, so this is a
|
|
552
|
+
* pure relocation with no behavioural change -- it removes the
|
|
553
|
+
* cross-call-sharing risk a module-level `let` carried (correct only
|
|
554
|
+
* because of invariants -- at most one launch per call, never invoked
|
|
555
|
+
* concurrently with itself -- enforced elsewhere and never checked at the
|
|
556
|
+
* point the variable used to be declared). */
|
|
557
|
+
function maintainWarmFloorForRealBroker(stateDir, state) {
|
|
558
|
+
let lastWarmLaunchLogRelPath = "";
|
|
559
|
+
return maintainWarmFloor({
|
|
560
|
+
state,
|
|
561
|
+
stateDir,
|
|
562
|
+
spawnFactory: (port) => {
|
|
563
|
+
const supervisorDir = join(stateDir, String(port));
|
|
564
|
+
const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
|
|
565
|
+
const stashingSpawn = (cmd, args) => {
|
|
566
|
+
const child = spawn(cmd, args);
|
|
567
|
+
// Stash the log path where onLaunched (fired synchronously right
|
|
568
|
+
// after this returns, still within the SAME maintainWarmFloor()
|
|
569
|
+
// call -- at most one launch per call, per the serialised-warming
|
|
570
|
+
// invariant) can find it. withCrashSupervision() below composes
|
|
571
|
+
// AROUND this function, so the stash still runs (and still
|
|
572
|
+
// completes before onLaunched reads it) before the exit listener
|
|
573
|
+
// is ever attached.
|
|
574
|
+
lastWarmLaunchLogRelPath = logRelPath;
|
|
575
|
+
return child;
|
|
576
|
+
};
|
|
577
|
+
return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state));
|
|
578
|
+
},
|
|
579
|
+
probe: (port) => probeReady(port),
|
|
580
|
+
allocatePort: nextFreePort,
|
|
581
|
+
countReady,
|
|
582
|
+
countTotal,
|
|
583
|
+
countLaunching,
|
|
584
|
+
onLaunched: (record) => {
|
|
585
|
+
writeEpochForLaunch(record, lastWarmLaunchLogRelPath);
|
|
586
|
+
},
|
|
587
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/** Releases a grant and identity-verified-kills its instance -- but ONLY
|
|
591
|
+
* when the port's CURRENT occupant is proven to be the SAME process this
|
|
592
|
+
* grant was actually issued for (its own recorded `pid`, set at grant time
|
|
593
|
+
* by handleAcquire()'s single state.grants.set() call site), not merely
|
|
594
|
+
* "whatever now holds this port number." This is Task 2's own closure of
|
|
595
|
+
* CR-01's cross-session-kill blast radius (T-01.6.2.1-28): even after Task
|
|
596
|
+
* 1 closes the specific concurrent-acquire race, this lookup was ALREADY
|
|
597
|
+
* unsafe against any OTHER event that swaps a port's occupant without also
|
|
598
|
+
* clearing the grant -- the clearest independent example being an ordinary
|
|
599
|
+
* (non-deliberate) crash of a GRANTED instance that hits the give-up
|
|
600
|
+
* threshold: broker-launch.mts's handleExit() deletes the record from
|
|
601
|
+
* state.instances regardless of record.state, freeing the port for
|
|
602
|
+
* nextFreePort() to hand to a brand-new, unrelated cold launch, while the
|
|
603
|
+
* original grant sits untouched in state.grants.
|
|
604
|
+
*
|
|
605
|
+
* On a pid MATCH: unchanged from before this task -- marks the death as
|
|
606
|
+
* broker-ordered with a FALSE respawn-after-kill answer BEFORE the kill
|
|
607
|
+
* (the opposite answer from the recycle handler above, since a release
|
|
608
|
+
* wants no replacement), deletes the instance entry (harmless double-delete
|
|
609
|
+
* if the exit handler's own final-death branch also runs), and
|
|
610
|
+
* fire-and-forget identity-verified-kills it.
|
|
611
|
+
*
|
|
612
|
+
* On a pid MISMATCH -- including when there is no instance at all at that
|
|
613
|
+
* port: the grant's own bookkeeping is still removed (a release always
|
|
614
|
+
* retires its OWN request's bookkeeping), but the mismatched CURRENT
|
|
615
|
+
* occupant is left running, untouched -- neither deleted nor signalled in
|
|
616
|
+
* any way -- and a distinct log line names the request id, the port, the
|
|
617
|
+
* grant's own recorded pid, and the current occupant's pid (or "none" when
|
|
618
|
+
* the port is empty), worded distinctly from both the shutdown-complete
|
|
619
|
+
* line (broker-kill.mts) and the grant-time-probe-failure line this same
|
|
620
|
+
* file already emits (D-07's standing constraint that a lifecycle decision
|
|
621
|
+
* must be reconstructable from the log after an incident).
|
|
622
|
+
*
|
|
623
|
+
* A legitimate recycle (broker-launch.mts's handleExit() recycle branch)
|
|
624
|
+
* keeps this grant's `pid` in sync with the respawned record's own pid, so
|
|
625
|
+
* this check never misfires against a recycled instance the grant still
|
|
626
|
+
* legitimately owns. */
|
|
627
|
+
export function handleRelease(requestId, state) {
|
|
628
|
+
const grant = state.grants.get(requestId);
|
|
629
|
+
if (!grant)
|
|
630
|
+
return;
|
|
631
|
+
const instance = state.instances.get(grant.port);
|
|
632
|
+
if (instance && instance.pid === grant.pid) {
|
|
633
|
+
markDeliberateDeath(instance, false);
|
|
634
|
+
state.grants.delete(requestId);
|
|
635
|
+
state.instances.delete(grant.port);
|
|
636
|
+
verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
|
|
637
|
+
// best-effort; nothing further to report on this path this task
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Stale/orphaned grant: the port's current occupant (if any) is NOT the
|
|
642
|
+
// same process this grant was issued for. Retire the grant's own
|
|
643
|
+
// bookkeeping only -- the mismatched occupant, if any, is left running.
|
|
644
|
+
state.grants.delete(requestId);
|
|
645
|
+
process.stderr.write(`vice-broker: release for request ${requestId} found a different instance at port ${grant.port} than the one this grant was issued for ` +
|
|
646
|
+
`(grant pid ${grant.pid ?? "null"}, current occupant pid ${instance ? instance.pid ?? "null" : "none"}) -- the grant's own bookkeeping was retired, ` +
|
|
647
|
+
`and the current occupant was left untouched\n`);
|
|
648
|
+
}
|
|
649
|
+
async function run(args) {
|
|
650
|
+
const finalPath = join(args.stateDir, "broker.json");
|
|
651
|
+
// Plan 05 (criterion K, D-17): the tracer/plan-04-era "refuse to overwrite
|
|
652
|
+
// a record naming a currently-live pid" pre-check is GONE -- REPLACED by
|
|
653
|
+
// the bind-before-write singleton guard below, not merely extended
|
|
654
|
+
// alongside it (this phase's own plan-time note is explicit: the
|
|
655
|
+
// refuse-to-clobber heuristic is replaced, not extended). That old check
|
|
656
|
+
// read broker.json's OWN recorded pid and asked "is that process alive" --
|
|
657
|
+
// a heuristic that can never tell "a live broker legitimately holds this
|
|
658
|
+
// port" apart from "a live but unrelated process happens to share a pid
|
|
659
|
+
// number with a stale record" (pids get reused). The kernel-enforced bind
|
|
660
|
+
// below asks the ONLY question that actually matters -- "is the control
|
|
661
|
+
// port itself already held" -- and broker.json becomes a pure ARBITER of
|
|
662
|
+
// that question's two possible causes, never a gate in its own right.
|
|
663
|
+
//
|
|
664
|
+
// D-25: the mandatory start-time banner, printed unconditionally and
|
|
665
|
+
// BEFORE anything else in this function runs -- an operator must be told
|
|
666
|
+
// what a Ctrl-C costs before there is anything running for them to Ctrl-C.
|
|
667
|
+
process.stderr.write(`${startupBanner()}\n`);
|
|
668
|
+
const state = createBrokerState();
|
|
669
|
+
const token = newControlToken();
|
|
670
|
+
const controlHost = process.env.VICE_BROKER_CONTROL_HOST ?? "0.0.0.0";
|
|
671
|
+
const startedAt = new Date().toISOString(); // FIXED across every heartbeat refresh -- see writeBrokerRecordFile()'s callers below
|
|
672
|
+
const pollMs = Number(process.env.VICE_BROKER_POLL_MS) || 500;
|
|
673
|
+
const controlPort = resolveControlPort();
|
|
674
|
+
// Criterion I / D-15: the unconditional startup reap runs BEFORE the
|
|
675
|
+
// control listener accepts and before anything is launched. A SIGKILLed
|
|
676
|
+
// prior broker never ran a shutdown path, so this is the only place the
|
|
677
|
+
// "every emulator this project's port band could be squatting is either
|
|
678
|
+
// ours or a human's own work" guarantee can be enforced -- no marker file
|
|
679
|
+
// is consulted, per this reap's own header comment in broker-kill.mts.
|
|
680
|
+
//
|
|
681
|
+
// NOTE (plan 05): this reap runs UNCONDITIONALLY, before the bind attempt
|
|
682
|
+
// below -- including for a process that goes on to LOSE the singleton
|
|
683
|
+
// race a moment later (see the EADDRINUSE handling below). That ordering
|
|
684
|
+
// is D-15's own, already established and tested by plan 04
|
|
685
|
+
// (broker-kill.test.ts's own structural source-order check); this task
|
|
686
|
+
// does not change it. A losing second broker's own reap pass is an
|
|
687
|
+
// accepted, pre-existing consequence of "the reap is unconditional" --
|
|
688
|
+
// not something the singleton guard below is required to prevent.
|
|
689
|
+
await reapOrphanedInstances({
|
|
690
|
+
stateDir: args.stateDir,
|
|
691
|
+
epochPathFor,
|
|
692
|
+
nextEpochFor,
|
|
693
|
+
writeEpochRecord,
|
|
694
|
+
});
|
|
695
|
+
// D-18: the singleton guarantee holds only while the control port keeps its default -- two brokers deliberately configured onto different ports are two brokers, and no code prevents that.
|
|
696
|
+
let listener;
|
|
697
|
+
try {
|
|
698
|
+
listener = await startControlListener({
|
|
699
|
+
host: controlHost,
|
|
700
|
+
port: controlPort,
|
|
701
|
+
token,
|
|
702
|
+
onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state),
|
|
703
|
+
onRelease: (requestId) => handleRelease(requestId, state),
|
|
704
|
+
onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
|
|
705
|
+
onStatus: () => handleStatus(state),
|
|
706
|
+
onHostState: () => ({
|
|
707
|
+
pid: process.pid,
|
|
708
|
+
startedAt,
|
|
709
|
+
nodeVersion: process.version,
|
|
710
|
+
viceBin: resolveViceBinForHostState(),
|
|
711
|
+
warmFloor: resolveWarmFloorForRecord(),
|
|
712
|
+
maxInstances: resolveCeilingForRecord(),
|
|
713
|
+
basePort: resolveBasePort(),
|
|
714
|
+
}),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
// Criterion K / D-17 / D-18: CR-01 closes here. A well-known TCP port
|
|
719
|
+
// cannot be bound twice, so EADDRINUSE is the kernel enforcing the
|
|
720
|
+
// singleton -- but the guarantee holds only while the control port
|
|
721
|
+
// keeps its default (two brokers deliberately configured onto
|
|
722
|
+
// DIFFERENT ports are two brokers, and no code here or anywhere else
|
|
723
|
+
// prevents that). On EADDRINUSE, broker.json arbitrates via the SAME
|
|
724
|
+
// never_started/stale/alive classification vice-broker-client.ts's
|
|
725
|
+
// readBrokerLiveness() uses (duplicated locally above -- see
|
|
726
|
+
// classifyBrokerLivenessLocal()'s own header comment for why this
|
|
727
|
+
// cannot be a value import), and takes exactly one of two DISTINCT
|
|
728
|
+
// paths: a record classified alive means this process lost a genuine
|
|
729
|
+
// race against a live broker -- exit quietly, status 0, as designed.
|
|
730
|
+
// A record classified stale or never_started means the port is held by
|
|
731
|
+
// something that does not answer as a broker at all -- fail loudly,
|
|
732
|
+
// naming the port and what to check. Conflating these two would let a
|
|
733
|
+
// squatted port masquerade as a healthy singleton, permanently and
|
|
734
|
+
// silently (T-01.6.2-34). Neither path writes the discovery record,
|
|
735
|
+
// launches an instance, or reaps again -- both simply exit.
|
|
736
|
+
const err = e;
|
|
737
|
+
if (err.code === "EADDRINUSE") {
|
|
738
|
+
const liveness = classifyBrokerLivenessLocal(finalPath);
|
|
739
|
+
if (liveness === "alive") {
|
|
740
|
+
process.stderr.write(`vice-broker: another broker is already running and holds control port ${controlPort} -- exiting quietly as a second instance (record: ${finalPath})\n`);
|
|
741
|
+
process.exitCode = 0;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
process.stderr.write(`vice-broker: FATAL -- control port ${controlPort} is held by something that does not answer as a broker (discovery record classified "${liveness}"). ` +
|
|
745
|
+
`Check what is bound to port ${controlPort} on the host (e.g. \`lsof -i :${controlPort}\` or \`ss -ltnp\`) before restarting. Record: ${finalPath}\n`);
|
|
746
|
+
process.exitCode = 1;
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
process.stderr.write(`vice-broker: failed to start control listener: ${err.message}\n`);
|
|
750
|
+
process.exitCode = 1;
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// C5: every catchable shutdown path (SIGTERM/SIGINT/SIGHUP, an uncaught
|
|
754
|
+
// exception, an unhandled rejection, normal exit) converges on ONE
|
|
755
|
+
// re-entrant-safe teardown that identity-verified-kills every instance
|
|
756
|
+
// this broker launched and clears the map unconditionally
|
|
757
|
+
// (kill-never-recycle). Registered once the listener is up, since there is
|
|
758
|
+
// nothing to tear down before that point.
|
|
759
|
+
registerShutdownHandlers({ state });
|
|
760
|
+
// A successful bind writes the record UNCONDITIONALLY, overwriting
|
|
761
|
+
// whatever was there -- the bind itself is the proof of singleton status
|
|
762
|
+
// (D-17). The fourteen-field set (D-27, criterion G): the lease
|
|
763
|
+
// time-to-live field the bash original carried is gone -- the connection
|
|
764
|
+
// is the lease now (D-12) -- and every other config-echo field survives
|
|
765
|
+
// even though no consumer parses it beyond a status message, because a
|
|
766
|
+
// human reading this file by hand benefits from the full echo.
|
|
767
|
+
let record = {
|
|
768
|
+
version: 1,
|
|
769
|
+
written_by: WRITTEN_BY,
|
|
770
|
+
pid: process.pid,
|
|
771
|
+
started_at: startedAt,
|
|
772
|
+
heartbeat_at: new Date().toISOString(),
|
|
773
|
+
node_version: process.version,
|
|
774
|
+
control_host: listener.host,
|
|
775
|
+
control_port: listener.port,
|
|
776
|
+
control_token: token, // never logged -- T-01.6.2-02
|
|
777
|
+
warm_floor: resolveWarmFloorForRecord(),
|
|
778
|
+
max_instances: resolveCeilingForRecord(),
|
|
779
|
+
base_port: resolveBasePort(),
|
|
780
|
+
poll_ms: pollMs,
|
|
781
|
+
dry_run: args.dryRun,
|
|
782
|
+
};
|
|
783
|
+
writeBrokerRecordFile(args.stateDir, record);
|
|
784
|
+
process.stderr.write(`vice-broker: wrote ${finalPath} (node ${record.node_version}); control listener bound on ${listener.host}:${listener.port}\n`);
|
|
785
|
+
const heartbeatMs = Number(process.env.VICE_BROKER_HEARTBEAT_MS) || 30000;
|
|
786
|
+
setInterval(() => {
|
|
787
|
+
// The refresh path goes through the SAME atomic tmp-then-rename choke
|
|
788
|
+
// point as the initial write (writeBrokerRecordFile() itself), and the
|
|
789
|
+
// mode is tightened to owner-read-write on EVERY write, refresh
|
|
790
|
+
// included -- never only on the first.
|
|
791
|
+
record = { ...record, heartbeat_at: new Date().toISOString() };
|
|
792
|
+
writeBrokerRecordFile(args.stateDir, record);
|
|
793
|
+
}, heartbeatMs);
|
|
794
|
+
// The fixed-order evaluation pass (runBrokerPass, broker-launch.mts):
|
|
795
|
+
// serve pending acquires, then maintain the warm floor -- mirroring
|
|
796
|
+
// vice-broker.sh's own broker_once() ordering. Ticks on
|
|
797
|
+
// VICE_BROKER_POLL_MS (default 500, the SAME env var name and semantics
|
|
798
|
+
// the bash daemon used). serveAcquires now drains the arrival-ordered
|
|
799
|
+
// pending-acquire structure this listener instance owns (D-08's
|
|
800
|
+
// mechanism; plan 02's own `serveAcquires: () => {}` comment reserved
|
|
801
|
+
// exactly this room) -- an acquire queued because a launch was already in
|
|
802
|
+
// flight is retried here, on the SAME pass that also maintains the warm
|
|
803
|
+
// floor, so a stalled pass shows up as a stale record rather than a
|
|
804
|
+
// silently wrong one. Re-entrancy guarded: a pass that is still running
|
|
805
|
+
// (e.g. a slow readiness probe against a genuinely slow host) is never
|
|
806
|
+
// overlapped by the next tick.
|
|
807
|
+
let passInFlight = false;
|
|
808
|
+
setInterval(() => {
|
|
809
|
+
if (passInFlight)
|
|
810
|
+
return;
|
|
811
|
+
passInFlight = true;
|
|
812
|
+
runBrokerPass({
|
|
813
|
+
serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
|
|
814
|
+
maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state),
|
|
815
|
+
})
|
|
816
|
+
.catch((e) => {
|
|
817
|
+
process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
|
|
818
|
+
})
|
|
819
|
+
.finally(() => {
|
|
820
|
+
passInFlight = false;
|
|
821
|
+
});
|
|
822
|
+
}, pollMs);
|
|
823
|
+
}
|
|
824
|
+
/** Parses argv, evaluates the container guard FIRST -- before any state
|
|
825
|
+
* directory is read or written and before anything is spawned (PD-03) --
|
|
826
|
+
* then runs the long-lived broker. Never calls process.exit(); always sets
|
|
827
|
+
* process.exitCode so pending I/O flushes first. */
|
|
828
|
+
export function main(argv = process.argv.slice(2)) {
|
|
829
|
+
let args;
|
|
830
|
+
try {
|
|
831
|
+
args = parseArgs(argv);
|
|
832
|
+
}
|
|
833
|
+
catch (e) {
|
|
834
|
+
process.stderr.write(`${e.message}\n`);
|
|
835
|
+
process.exitCode = 1;
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (args.checkContainer) {
|
|
839
|
+
process.exitCode = containerGuardReport();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const guardRc = containerGuardEnforce();
|
|
843
|
+
if (guardRc !== 0) {
|
|
844
|
+
process.exitCode = guardRc;
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
run(args).catch((e) => {
|
|
848
|
+
process.stderr.write(`vice-broker: ${e.message}\n`);
|
|
849
|
+
process.exitCode = 1;
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
// -------------------------------------------------------------------- CLI
|
|
853
|
+
if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
854
|
+
main();
|
|
855
|
+
}
|