@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,899 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Container-side half of the on-demand broker protocol. Through Phase 01.2
|
|
3
|
+
// this module wrote the request/lease files resources/vice-broker.sh read
|
|
4
|
+
// and read the grant/denial/broker files that script wrote, all on the SAME
|
|
5
|
+
// .vice-supervisor/ bind mount tools/vice-supervisor.sh's epoch.json already
|
|
6
|
+
// used. Plan 01.6.2-07 deletes that file protocol wholesale (D-12: six
|
|
7
|
+
// mechanisms retiring together -- startHeartbeat()/the mtime-as-heartbeat
|
|
8
|
+
// convention/touchLease()/pollGrant()/pollRecycleAck()/the request-grant-
|
|
9
|
+
// denial-lease-recycle-ack directory tree) now that vice-proxy.ts's
|
|
10
|
+
// acquisition, release AND recycle all run over the TCP control plane
|
|
11
|
+
// (openBrokerControl()/BrokerControlSession below) instead. What survives:
|
|
12
|
+
// the request-id primitives (the new client's own acquire()/recycle() still
|
|
13
|
+
// mint ids with newRequestId()), brokerRootDir()/brokerJsonPath() (the
|
|
14
|
+
// discovery record's own location), and readBrokerLiveness() (unchanged
|
|
15
|
+
// classification, still reading the SAME broker.json openBrokerControl()
|
|
16
|
+
// reads for its control_host/control_port/control_token).
|
|
17
|
+
//
|
|
18
|
+
// Every read of broker.json is still untrusted input: parse in try/catch, a
|
|
19
|
+
// malformed or half-written file is "not there yet" or "absent", never a
|
|
20
|
+
// thrown exception. See 01.2-PATTERNS.md's "Never-throw /
|
|
21
|
+
// never-cache-a-negative-result" section.
|
|
22
|
+
//
|
|
23
|
+
// MUST NOT import hostpath.ts: the host-path consumer set is closed to
|
|
24
|
+
// four production modules by vice-mcp-selector-docs.test.mjs's assertion 4,
|
|
25
|
+
// and host-path message text stays in vice-proxy.mjs, which is already on
|
|
26
|
+
// that list.
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import { join, resolve } from "node:path";
|
|
30
|
+
import { connect, type Socket } from "node:net";
|
|
31
|
+
|
|
32
|
+
import { supervisorDir } from "./repo-root.ts";
|
|
33
|
+
// The module tree's ONE definition of the container-visible host alias
|
|
34
|
+
// (vice.ts:49), carrying its own VICE_MCP_HOST override -- consumed below by
|
|
35
|
+
// resolveControlTarget() rather than a fourth `host.docker.internal` literal
|
|
36
|
+
// (vice.ts:35-48 names the three duplicate copies that predated that
|
|
37
|
+
// function; this file must not become a fourth). Deliberately NOT
|
|
38
|
+
// `containerpath.ts`'s `containerHost()`: that function rewrites URL
|
|
39
|
+
// *strings*, not bare hostnames; its own loopback matcher structurally
|
|
40
|
+
// EXCLUDES `0.0.0.0` (a wildcard bind is not loopback, so the very address
|
|
41
|
+
// at fault here would pass through it untouched); `containerpath.ts:32-37`
|
|
42
|
+
// states outright that it does not know the container-visible host alias;
|
|
43
|
+
// and importing it would pull `hostpath.ts` into this module, which this
|
|
44
|
+
// file's own header (lines 23-26) forbids and which the host-path
|
|
45
|
+
// consumer-set assertion polices.
|
|
46
|
+
import { mcpHost } from "./vice.ts";
|
|
47
|
+
|
|
48
|
+
// -------------------------------------------------------------- request ids
|
|
49
|
+
//
|
|
50
|
+
// Primary noun of this protocol (assumption-delta decision, 01.2-01-PLAN.md):
|
|
51
|
+
// a request/grant/lease is identified by this id, never by port -- ports are
|
|
52
|
+
// recycled across sessions under on-demand launch, so a port is an attribute
|
|
53
|
+
// OF a grant, not identity. Matched byte-for-byte against the same shape
|
|
54
|
+
// resources/vice-broker.sh's own request-id pattern validates (T-01.2-01);
|
|
55
|
+
// the request-id-pattern parity test in vice-broker.test.mjs drives one
|
|
56
|
+
// shared corpus through both validators so neither side can silently accept
|
|
57
|
+
// an id shape the other rejects.
|
|
58
|
+
//
|
|
59
|
+
// C7 (Phase 01.6.1): this is the criterion's whole container-side
|
|
60
|
+
// deliverable -- a real, typed, NAMED export whose VALUE is unchanged from
|
|
61
|
+
// the pre-conversion .mjs (verified live, this plan's SUMMARY quotes both).
|
|
62
|
+
// 01.6.2's in-process broker imports this exact binding rather than
|
|
63
|
+
// re-stating the pattern a third time; the bash copy
|
|
64
|
+
// (resources/vice-broker.sh) does not retire until that phase deletes it.
|
|
65
|
+
export const REQUEST_ID_PATTERN: RegExp = /^req-[0-9]+-[0-9]+-[0-9a-f]{8}$/;
|
|
66
|
+
|
|
67
|
+
export function newRequestId(): string {
|
|
68
|
+
return `req-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isValidRequestId(id: unknown): id is string {
|
|
72
|
+
return typeof id === "string" && REQUEST_ID_PATTERN.test(id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// -------------------------------------------------------------- directories
|
|
76
|
+
//
|
|
77
|
+
// Resolved from VICE_POOL_DIR when set, otherwise from repo-root.ts's
|
|
78
|
+
// supervisorDir() -- the SAME default `.vice-supervisor` directory every
|
|
79
|
+
// other host/container pairing in this module tree already agrees on, so
|
|
80
|
+
// container and host never derive two different roots for this protocol.
|
|
81
|
+
// The five sibling directory helpers this function used to anchor
|
|
82
|
+
// (requestsDir/grantsDir/denialsDir/brokerLeasesDir/recycleAcksDir) and the
|
|
83
|
+
// lease path helper (leasePathFor) are GONE, not merely unused -- their
|
|
84
|
+
// directories cease to exist under D-01/D-12; only brokerJsonPath() below
|
|
85
|
+
// survives, since broker.json itself is not part of the retiring protocol.
|
|
86
|
+
export function brokerRootDir(): string {
|
|
87
|
+
return process.env.VICE_POOL_DIR ? resolve(process.env.VICE_POOL_DIR) : supervisorDir();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function brokerJsonPath(dir: string = brokerRootDir()): string {
|
|
91
|
+
return join(dir, "broker.json");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
95
|
+
* an array. Shared by readJsonMaybe()'s parse step, matching
|
|
96
|
+
* vice-broker.mts's readBrokerRecordMaybe()'s own isPlainObject() predicate
|
|
97
|
+
* exactly (that file's own doc comment states it matches this module's
|
|
98
|
+
* posture). */
|
|
99
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read and JSON.parse `path`, treating any failure (missing file, partial
|
|
104
|
+
* write, malformed JSON, non-object shape) as "not there yet" rather than
|
|
105
|
+
* throwing -- matches the posture vice-pool.mjs's readRegistry() used
|
|
106
|
+
* before its 2026-08-02 deletion. Two nested try/catch layers, one for the
|
|
107
|
+
* read and one for the parse -- never collapsed into one, never replaced by
|
|
108
|
+
* a thrown error (T-01.6.1-01). */
|
|
109
|
+
function readJsonMaybe(path: string): Record<string, unknown> | null {
|
|
110
|
+
let raw: string;
|
|
111
|
+
try {
|
|
112
|
+
raw = readFileSync(path, "utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const parsed: unknown = JSON.parse(raw);
|
|
118
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// writeRequest/createLease/touchLease/releaseLease/pollGrant/pollRecycleAck
|
|
125
|
+
// and their record interfaces (RequestRecord, RecycleRequestRecord,
|
|
126
|
+
// LeaseRecord, PollOptions, PollGrantResult, PollRecycleAckResult) are GONE:
|
|
127
|
+
// the whole file-messaging protocol they implemented (D-01/D-12) is replaced
|
|
128
|
+
// wholesale by the TCP control plane below. GRANT_POLL_TIMEOUT_MS/
|
|
129
|
+
// GRANT_POLL_INTERVAL_MS/RECYCLE_ACK_TIMEOUT_MS/RECYCLE_ACK_POLL_INTERVAL_MS
|
|
130
|
+
// (the retiring polls' own timeout/interval constants) and sleepMs() (their
|
|
131
|
+
// shared poll-delay helper) are gone with them -- nothing here polls a
|
|
132
|
+
// filesystem for a deadline any more.
|
|
133
|
+
|
|
134
|
+
export interface BrokerLivenessResult {
|
|
135
|
+
state: "never_started" | "stale" | "alive";
|
|
136
|
+
pid: number | null;
|
|
137
|
+
heartbeatAt: string | null;
|
|
138
|
+
path: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --------------------------------------------------------- readBrokerLiveness
|
|
142
|
+
//
|
|
143
|
+
// Classifies broker.json as never_started / stale / alive against
|
|
144
|
+
// BROKER_STALE_MS. Plan 04 consumes the three states for its diagnostics;
|
|
145
|
+
// this task only needs the classification to exist and be correct.
|
|
146
|
+
export const BROKER_STALE_MS: number = Number(process.env.VICE_BROKER_STALE_MS || 180000);
|
|
147
|
+
|
|
148
|
+
/** Pure classification over an ALREADY-PARSED record (or null for "no file
|
|
149
|
+
* read anything back") -- factored out of readBrokerLiveness() below so
|
|
150
|
+
* openBrokerControl() (plan 06) can classify liveness against the SAME
|
|
151
|
+
* broker.json read it already performed for control_host/control_port/
|
|
152
|
+
* control_token, rather than re-reading the file a second time via a second
|
|
153
|
+
* readBrokerLiveness() call. readBrokerLiveness()'s own exported behaviour is
|
|
154
|
+
* unchanged by this split -- it still takes a path and returns the same
|
|
155
|
+
* shape; this is purely an internal refactor. */
|
|
156
|
+
function classifyLivenessFromRecord(parsed: Record<string, unknown> | null, path: string): BrokerLivenessResult {
|
|
157
|
+
if (parsed === null) {
|
|
158
|
+
return { state: "never_started", pid: null, heartbeatAt: null, path };
|
|
159
|
+
}
|
|
160
|
+
const pid = typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : null;
|
|
161
|
+
const heartbeatAt = typeof parsed.heartbeat_at === "string" ? parsed.heartbeat_at : null;
|
|
162
|
+
const heartbeatMs = heartbeatAt ? Date.parse(heartbeatAt) : NaN;
|
|
163
|
+
if (!Number.isFinite(heartbeatMs)) {
|
|
164
|
+
return { state: "never_started", pid, heartbeatAt, path };
|
|
165
|
+
}
|
|
166
|
+
const state: BrokerLivenessResult["state"] = Date.now() - heartbeatMs > BROKER_STALE_MS ? "stale" : "alive";
|
|
167
|
+
return { state, pid, heartbeatAt, path };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function readBrokerLiveness(path: string = brokerJsonPath()): BrokerLivenessResult {
|
|
171
|
+
const parsed = readJsonMaybe(path);
|
|
172
|
+
return classifyLivenessFromRecord(parsed, path);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// StartHeartbeatOptions/HEARTBEAT_MS/startHeartbeat() are GONE -- the
|
|
176
|
+
// lease-heartbeat interval (one of D-12's six retiring mechanisms) has no
|
|
177
|
+
// successor. Nothing needs touching to prove a TCP connection is alive; it
|
|
178
|
+
// either is, or the broker's own "close" handler has already reclaimed the
|
|
179
|
+
// instance.
|
|
180
|
+
|
|
181
|
+
// -------------------------------------------------------- dial resolution
|
|
182
|
+
//
|
|
183
|
+
// `broker.json`'s `control_host` field is the broker's BIND address
|
|
184
|
+
// (vice-broker.mts:782 writes `listener.host` into it, which is
|
|
185
|
+
// deliberately `0.0.0.0` per broker-control.mts:16-20's own rule: "Bind:
|
|
186
|
+
// 0.0.0.0 explicitly, never 127.0.0.1 -- host.docker.internal is the bridge
|
|
187
|
+
// address, not loopback"). A bind address is not a dial address: `0.0.0.0`
|
|
188
|
+
// dialed from inside THIS container reaches this container's own network
|
|
189
|
+
// stack, where nothing listens. Both connect sites below (the tracer's own
|
|
190
|
+
// acquireOverControlPlane() and openBrokerControl() further down) resolve
|
|
191
|
+
// their target through resolveControlTarget() and never read `control_host`
|
|
192
|
+
// as anything but diagnostic text.
|
|
193
|
+
//
|
|
194
|
+
// `VICE_BROKER_CONTROL_DIAL_HOST` is a NEW variable, deliberately not a
|
|
195
|
+
// homonym of the EXISTING `VICE_BROKER_CONTROL_HOST` (the broker's own BIND
|
|
196
|
+
// host, set on the HOST side -- vice-broker.mts:671, broker-control.mts:507,
|
|
197
|
+
// driven in broker-control.test.ts:949). Collapsing the two into one
|
|
198
|
+
// variable would reproduce this exact defect in env-var form: one name
|
|
199
|
+
// cannot correctly answer both "what should I bind" and "what should I
|
|
200
|
+
// dial", for the same reason `control_host` itself cannot -- those are two
|
|
201
|
+
// different consumers wanting two different addresses.
|
|
202
|
+
export interface ResolvedControlTarget {
|
|
203
|
+
host: string;
|
|
204
|
+
port: number;
|
|
205
|
+
source: "dial_override" | "bridge_alias";
|
|
206
|
+
/** The record's OWN `control_host` value -- carried through for the
|
|
207
|
+
* diagnostic only. Never a candidate dial target. */
|
|
208
|
+
recorded: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type ResolveControlTargetResult =
|
|
212
|
+
| { ok: true; target: ResolvedControlTarget }
|
|
213
|
+
| { ok: false; kind: "unreachable_control_plane"; message: string; target: string };
|
|
214
|
+
|
|
215
|
+
const IPV4_LOOPBACK_RE = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
216
|
+
// Fully-expanded IPv6 "::" (all eight groups zero) and "::1" (seven zero
|
|
217
|
+
// groups then 1) -- the WHATWG URL parser's own bracketed short forms are
|
|
218
|
+
// matched as literals below; this regex pair only needs to catch the
|
|
219
|
+
// long-hand spellings a bare hostname string might still carry.
|
|
220
|
+
const IPV6_ALL_ZEROS_RE = /^(0{1,4}:){7}0{1,4}$/;
|
|
221
|
+
const IPV6_LOOPBACK_FULL_RE = /^(0{1,4}:){7}0{0,3}1$/;
|
|
222
|
+
|
|
223
|
+
function stripBrackets(host: string): string {
|
|
224
|
+
return host.replace(/^\[/, "").replace(/\]$/, "");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isWildcardBindHost(host: string): boolean {
|
|
228
|
+
const bare = stripBrackets(host);
|
|
229
|
+
return bare === "0.0.0.0" || bare === "::" || IPV6_ALL_ZEROS_RE.test(bare);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isLoopbackConnectHost(host: string): boolean {
|
|
233
|
+
const bare = stripBrackets(host);
|
|
234
|
+
return bare === "localhost" || bare === "::1" || IPV4_LOOPBACK_RE.test(bare) || IPV6_LOOPBACK_FULL_RE.test(bare);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Classifies a bare hostname (never a full URL) the same way
|
|
238
|
+
* `containerpath.ts`'s `isLoopbackHostname()` classifies loopback --
|
|
239
|
+
* matched STRUCTURALLY, whole address classes rather than single literals,
|
|
240
|
+
* deliberately RE-STATED here rather than imported (see this section's own
|
|
241
|
+
* header comment for why `containerpath.ts` is off-limits to this module).
|
|
242
|
+
* `wildcard_bind` covers the IPv4/IPv6 "listen on everything" addresses in
|
|
243
|
+
* their bracketed, unbracketed and fully-expanded spellings; `loopback`
|
|
244
|
+
* covers the whole 127.0.0.0/8 block, `localhost`, and IPv6 loopback in the
|
|
245
|
+
* same three spellings; everything else is `routable`. */
|
|
246
|
+
export function classifyConnectHost(host: string): "wildcard_bind" | "loopback" | "routable" {
|
|
247
|
+
if (isWildcardBindHost(host)) return "wildcard_bind";
|
|
248
|
+
if (isLoopbackConnectHost(host)) return "loopback";
|
|
249
|
+
return "routable";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Resolves the address this process will actually DIAL for the control
|
|
253
|
+
* plane -- never the record's own `control_host`, which flows through only
|
|
254
|
+
* as `recorded`, never as a candidate target. Precedence:
|
|
255
|
+
* `VICE_BROKER_CONTROL_DIAL_HOST` when set and non-empty (`source:
|
|
256
|
+
* "dial_override"`), otherwise `mcpHost()` (`source: "bridge_alias"`) --
|
|
257
|
+
* the SAME default source `vice-proxy.test.ts` already configures via
|
|
258
|
+
* `VICE_MCP_HOST` at ~30 call sites, which is exactly why that source was
|
|
259
|
+
* chosen: every one of those fixtures stays green with zero edits.
|
|
260
|
+
*
|
|
261
|
+
* Refuses -- before any connect is attempted -- when the resolved host
|
|
262
|
+
* classifies as `wildcard_bind`: that class is an address to listen on,
|
|
263
|
+
* never one to dial. Does NOT refuse `loopback`: an explicitly configured
|
|
264
|
+
* loopback host is a statement that the listener lives inside THIS
|
|
265
|
+
* container, which is the only topology the project's hard rule (nothing
|
|
266
|
+
* may dial the real host directly) permits a test to exercise -- and a
|
|
267
|
+
* loopback value can now only ever arrive from explicit configuration,
|
|
268
|
+
* never from the record, since the record's own value is never treated as
|
|
269
|
+
* a candidate. */
|
|
270
|
+
export function resolveControlTarget(record: Record<string, unknown>, port: number): ResolveControlTargetResult {
|
|
271
|
+
const recorded = typeof record.control_host === "string" ? record.control_host : "";
|
|
272
|
+
const override = process.env.VICE_BROKER_CONTROL_DIAL_HOST;
|
|
273
|
+
const useOverride = typeof override === "string" && override.length > 0;
|
|
274
|
+
const host = useOverride ? override : mcpHost();
|
|
275
|
+
const source: "dial_override" | "bridge_alias" = useOverride ? "dial_override" : "bridge_alias";
|
|
276
|
+
|
|
277
|
+
if (classifyConnectHost(host) === "wildcard_bind") {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
kind: "unreachable_control_plane",
|
|
281
|
+
message:
|
|
282
|
+
`openBrokerControl: the resolved dial target ${host}:${port} is a wildcard-bind address -- ` +
|
|
283
|
+
`it is valid to listen on but structurally impossible to dial. Refusing to attempt a connection.`,
|
|
284
|
+
target: `${host}:${port}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return { ok: true, target: { host, port, source, recorded } };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------- TCP control plane
|
|
291
|
+
//
|
|
292
|
+
// The container-side half of the TCP control plane (broker-control.mts is
|
|
293
|
+
// the host-side half). Wire format confirmed at plan 01's blocking
|
|
294
|
+
// checkpoint:decision (2026-08-03, `as-specified`; see
|
|
295
|
+
// .planning/RE-FINDINGS.md for the full record): newline-delimited JSON,
|
|
296
|
+
// per-boot capability token, connection open = claim / close = release.
|
|
297
|
+
export interface AcquireGrant {
|
|
298
|
+
id: string;
|
|
299
|
+
port: number;
|
|
300
|
+
url: string;
|
|
301
|
+
epoch_file: string;
|
|
302
|
+
supervisor_dir: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface AcquireOverControlPlaneHandle {
|
|
306
|
+
grant: AcquireGrant;
|
|
307
|
+
/** Closes the connection -- the connection IS the lease, so this alone
|
|
308
|
+
* is the release; the broker's own "close" handler tears the instance
|
|
309
|
+
* down (broker-control.mts). */
|
|
310
|
+
release: () => void;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** P-08 (01.6.2.1-04-PLAN.md): default raised from 25000 to 120000. The
|
|
314
|
+
* knob (VICE_BROKER_ACQUIRE_TIMEOUT_MS) is unchanged -- an explicitly
|
|
315
|
+
* configured value keeps working exactly as before.
|
|
316
|
+
*
|
|
317
|
+
* Counter-evidence, recorded here rather than only in the plan: at the
|
|
318
|
+
* measured sub-second cold-launch boot (spike-003), the OLD 25000 ms value
|
|
319
|
+
* already implied a cliff far past what the instance ceiling would ever
|
|
320
|
+
* force -- so this raise is robustness headroom for a slow or contended
|
|
321
|
+
* host, not an unblocking of any wave-width constraint. .mcp.json's own
|
|
322
|
+
* `timeout` field is raised to 150000 in the same commit (see the ordering-
|
|
323
|
+
* invariant test in vice-proxy.test.ts), keeping this deadline strictly
|
|
324
|
+
* less than the MCP client's own configured timeout -- so a waiting caller
|
|
325
|
+
* always sees this module's warming-and-retry diagnostic rather than the
|
|
326
|
+
* client's generic timeout. */
|
|
327
|
+
export const CONTROL_ACQUIRE_TIMEOUT_MS: number = Number(process.env.VICE_BROKER_ACQUIRE_TIMEOUT_MS || 120000);
|
|
328
|
+
|
|
329
|
+
/** Reads broker.json ONCE for control_host/control_port/control_token,
|
|
330
|
+
* opens ONE TCP connection, sends a single `acquire` request framed as one
|
|
331
|
+
* JSON line, and awaits the grant line against
|
|
332
|
+
* CONTROL_ACQUIRE_TIMEOUT_MS. Rejects (never throws synchronously) on any
|
|
333
|
+
* failure: broker.json absent/unreadable/missing the control fields, a
|
|
334
|
+
* connection error, an `error` response, or a timeout. */
|
|
335
|
+
export function acquireOverControlPlane(dir: string = brokerRootDir()): Promise<AcquireOverControlPlaneHandle> {
|
|
336
|
+
return new Promise((resolvePromise, reject) => {
|
|
337
|
+
const broker = readJsonMaybe(brokerJsonPath(dir));
|
|
338
|
+
if (broker === null) {
|
|
339
|
+
reject(new Error("acquireOverControlPlane: broker.json not present or unreadable"));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const controlHost = typeof broker.control_host === "string" ? broker.control_host : null;
|
|
343
|
+
const port = typeof broker.control_port === "number" ? broker.control_port : null;
|
|
344
|
+
const token = typeof broker.control_token === "string" ? broker.control_token : null;
|
|
345
|
+
if (controlHost === null || port === null || token === null) {
|
|
346
|
+
reject(new Error("acquireOverControlPlane: broker.json missing control_host/control_port/control_token"));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const targetResult = resolveControlTarget(broker, port);
|
|
351
|
+
if (!targetResult.ok) {
|
|
352
|
+
reject(new Error(targetResult.message));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const { host } = targetResult.target;
|
|
356
|
+
|
|
357
|
+
const socket = connect({ host, port });
|
|
358
|
+
let buffer = "";
|
|
359
|
+
let settled = false;
|
|
360
|
+
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
if (settled) return;
|
|
363
|
+
settled = true;
|
|
364
|
+
socket.destroy();
|
|
365
|
+
reject(new Error(`acquireOverControlPlane: no grant within ${CONTROL_ACQUIRE_TIMEOUT_MS}ms`));
|
|
366
|
+
}, CONTROL_ACQUIRE_TIMEOUT_MS);
|
|
367
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
368
|
+
|
|
369
|
+
socket.on("connect", () => {
|
|
370
|
+
const requestId = newRequestId();
|
|
371
|
+
socket.write(`${JSON.stringify({ op: "acquire", id: requestId, token })}\n`);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
socket.on("data", (chunk: Buffer) => {
|
|
375
|
+
if (settled) return;
|
|
376
|
+
buffer += chunk.toString("utf8");
|
|
377
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
378
|
+
if (newlineIdx === -1) return;
|
|
379
|
+
const line = buffer.slice(0, newlineIdx);
|
|
380
|
+
|
|
381
|
+
let parsed: unknown;
|
|
382
|
+
try {
|
|
383
|
+
parsed = JSON.parse(line);
|
|
384
|
+
} catch {
|
|
385
|
+
settled = true;
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
socket.destroy();
|
|
388
|
+
reject(new Error("acquireOverControlPlane: malformed response line"));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
392
|
+
settled = true;
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
socket.destroy();
|
|
395
|
+
reject(new Error("acquireOverControlPlane: response line is not a JSON object"));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const resp = parsed as Record<string, unknown>;
|
|
399
|
+
if (resp.kind === "grant") {
|
|
400
|
+
settled = true;
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
const grant: AcquireGrant = {
|
|
403
|
+
id: String(resp.id),
|
|
404
|
+
port: Number(resp.port),
|
|
405
|
+
url: String(resp.url),
|
|
406
|
+
epoch_file: String(resp.epoch_file),
|
|
407
|
+
supervisor_dir: String(resp.supervisor_dir),
|
|
408
|
+
};
|
|
409
|
+
resolvePromise({
|
|
410
|
+
grant,
|
|
411
|
+
release: () => {
|
|
412
|
+
socket.destroy();
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
} else if (resp.kind === "error") {
|
|
416
|
+
settled = true;
|
|
417
|
+
clearTimeout(timer);
|
|
418
|
+
socket.destroy();
|
|
419
|
+
reject(new Error(`acquireOverControlPlane: ${String(resp.code)}: ${String(resp.message)}`));
|
|
420
|
+
}
|
|
421
|
+
// any other kind: not a terminal response to THIS request -- ignored,
|
|
422
|
+
// matching pollGrant()'s own "keep waiting" posture above.
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
socket.on("error", (err) => {
|
|
426
|
+
if (settled) return;
|
|
427
|
+
settled = true;
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
reject(err);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// BROKER-CONTROL-CLIENT REGION START (plan 06, task 1)
|
|
436
|
+
//
|
|
437
|
+
// Completed by plan 07 (the file protocol beside it is now gone).
|
|
438
|
+
// openBrokerControl() is the container-side half of D-01: session shape,
|
|
439
|
+
// all five request kinds, one discovery-record read, real per-request
|
|
440
|
+
// deadlines, and a distinct broker-gone outcome. Lives alongside
|
|
441
|
+
// acquireOverControlPlane() above (plan 01's tracer, kept unchanged and
|
|
442
|
+
// still used by broker-e2e.test.ts/broker-kill.test.ts as their own one-shot
|
|
443
|
+
// acquire helper for exercising the SERVER side) -- the file protocol this
|
|
444
|
+
// region's own predecessor sat beside is gone (plan 07, D-12).
|
|
445
|
+
//
|
|
446
|
+
// Deliberately never REJECTS a promise: every failure -- deadline, a
|
|
447
|
+
// refused connection, a malformed line, the broker going away mid-request --
|
|
448
|
+
// resolves an `{ ok: false, kind, message }` value instead, matching this
|
|
449
|
+
// module's established never-throw posture toward untrusted input (the
|
|
450
|
+
// broker's response lines) and network conditions, and sidestepping any
|
|
451
|
+
// possibility of an unhandled rejection escaping this client.
|
|
452
|
+
//
|
|
453
|
+
// A structural test in vice-broker-client.test.ts extracts exactly the
|
|
454
|
+
// region between this marker and REGION END below (by these marker strings,
|
|
455
|
+
// not a whole-file scan) and asserts it contains no filesystem-write
|
|
456
|
+
// construct -- nothing in this region may reintroduce a second on-disk
|
|
457
|
+
// authority for "is this lease alive."
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
/** Same value as the tracer's own CONTROL_ACQUIRE_TIMEOUT_MS above --
|
|
461
|
+
* referenced directly (not re-computed from the env var a second time) so
|
|
462
|
+
* the two can never drift apart. This is "the relocated value of the
|
|
463
|
+
* retiring grant-poll timeout" per 01.6.2-01-PLAN.md's own environment
|
|
464
|
+
* variable table (VICE_BROKER_ACQUIRE_TIMEOUT_MS, default now 120000, raised
|
|
465
|
+
* from 25000 per P-08 / 01.6.2.1-04-PLAN.md, against the measured tool-call
|
|
466
|
+
* budget spike-003 established -- see the counter-evidence comment at
|
|
467
|
+
* CONTROL_ACQUIRE_TIMEOUT_MS's own declaration above). */
|
|
468
|
+
export const ACQUIRE_TIMEOUT_MS: number = CONTROL_ACQUIRE_TIMEOUT_MS;
|
|
469
|
+
|
|
470
|
+
/** The recycle bound. Plan 06 referenced the (now-deleted) retiring
|
|
471
|
+
* pollRecycleAck()'s own RECYCLE_ACK_TIMEOUT_MS directly, so the two could
|
|
472
|
+
* never drift apart while both existed; that predecessor is gone (plan 07,
|
|
473
|
+
* D-12), so this reads the SAME environment variable directly -- the value
|
|
474
|
+
* itself is unchanged (VICE_BROKER_RECYCLE_TIMEOUT_MS, default 30000, per
|
|
475
|
+
* 01.6.2-01-PLAN.md's own environment variable table). Final tuning is
|
|
476
|
+
* Phase 01.6.2.1's item. */
|
|
477
|
+
export const RECYCLE_TIMEOUT_MS: number = Number(process.env.VICE_BROKER_RECYCLE_TIMEOUT_MS || 30000);
|
|
478
|
+
|
|
479
|
+
/** Genuinely NEW: the file protocol never "connected" anywhere, so there is
|
|
480
|
+
* no retiring value to carry forward for this one. A conservative bound for
|
|
481
|
+
* a TCP connect over the docker bridge to a broker broker.json has already
|
|
482
|
+
* classified alive (never_started/stale are refused before a connection is
|
|
483
|
+
* ever attempted) -- deliberately not read from an environment variable,
|
|
484
|
+
* since 01.6.2-06-PLAN.md's own scope is "no new environment variables
|
|
485
|
+
* beyond the two deadline variables named in 01.6.2-01-PLAN.md" (the two
|
|
486
|
+
* above). Final tuning is Phase 01.6.2.1's item, same as the other two. */
|
|
487
|
+
export const CONTROL_CONNECT_TIMEOUT_MS = 5000;
|
|
488
|
+
|
|
489
|
+
/** Every way a session-level request can fail to produce its expected
|
|
490
|
+
* success line: the two pre-connect liveness refusals, a refused TCP
|
|
491
|
+
* connection, a per-request deadline, the broker dropping the connection
|
|
492
|
+
* mid-request, a malformed/non-object response line, and the broker's own
|
|
493
|
+
* ControlErrorCode vocabulary (broker-control.mts's own type, duplicated
|
|
494
|
+
* here as a plain string-literal union rather than imported -- this client
|
|
495
|
+
* and that host-side listener run in separate processes; the shared surface
|
|
496
|
+
* between them is the wire format, not a TypeScript type, exactly like
|
|
497
|
+
* AcquireGrant below already duplicates the wire's own field names rather
|
|
498
|
+
* than importing a shared interface). */
|
|
499
|
+
export type ControlFailureKind =
|
|
500
|
+
| "never_started"
|
|
501
|
+
| "stale"
|
|
502
|
+
| "unreachable_control_plane"
|
|
503
|
+
| "connect_refused"
|
|
504
|
+
| "deadline"
|
|
505
|
+
| "broker_gone"
|
|
506
|
+
| "protocol"
|
|
507
|
+
| "unauthorized"
|
|
508
|
+
| "bad_request"
|
|
509
|
+
| "denied"
|
|
510
|
+
| "no_free_port"
|
|
511
|
+
| "at_capacity"
|
|
512
|
+
| "internal";
|
|
513
|
+
|
|
514
|
+
export type ControlAcquireResult = { ok: true; grant: AcquireGrant } | { ok: false; kind: ControlFailureKind; message: string };
|
|
515
|
+
|
|
516
|
+
export type ControlReleaseResult = { ok: true };
|
|
517
|
+
|
|
518
|
+
interface ControlRecycleAck {
|
|
519
|
+
outcome: string;
|
|
520
|
+
kill_stage: string;
|
|
521
|
+
reason: string;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export type ControlRecycleResult = { ok: true; ack: ControlRecycleAck } | { ok: false; kind: ControlFailureKind; message: string };
|
|
525
|
+
|
|
526
|
+
interface ControlStatusInstanceEntry {
|
|
527
|
+
port: number;
|
|
528
|
+
url: string;
|
|
529
|
+
state: string;
|
|
530
|
+
reason: string;
|
|
531
|
+
epoch: number | null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export type ControlStatusResult =
|
|
535
|
+
| { ok: true; instances: ControlStatusInstanceEntry[] }
|
|
536
|
+
| { ok: false; kind: ControlFailureKind; message: string };
|
|
537
|
+
|
|
538
|
+
interface ControlHostStateFields {
|
|
539
|
+
pid: number;
|
|
540
|
+
started_at: string;
|
|
541
|
+
node_version: string;
|
|
542
|
+
vice_bin: string;
|
|
543
|
+
warm_floor: number;
|
|
544
|
+
max_instances: number;
|
|
545
|
+
base_port: number;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export type ControlHostStateResult =
|
|
549
|
+
| { ok: true; hostState: ControlHostStateFields }
|
|
550
|
+
| { ok: false; kind: ControlFailureKind; message: string };
|
|
551
|
+
|
|
552
|
+
/** Per-call deadline override -- matches PollOptions's own established shape
|
|
553
|
+
* above (pollGrant()/pollRecycleAck() already take an optional `timeoutMs`
|
|
554
|
+
* this same way). The MODULE-LEVEL constant (ACQUIRE_TIMEOUT_MS etc.) is the
|
|
555
|
+
* real, unchanged-from-the-retiring-poll default; a caller (chiefly this
|
|
556
|
+
* file's own tests, injecting a short bound to prove the deadline actually
|
|
557
|
+
* elapses without waiting out the real one) may override it per call. */
|
|
558
|
+
export interface ControlDeadlineOptions {
|
|
559
|
+
timeoutMs?: number;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The session opened by openBrokerControl(): one TCP connection, held for
|
|
563
|
+
* the session's lifetime -- the connection IS the lease (the tolerance
|
|
564
|
+
* decision recorded in broker-control-plane-over-tcp.md). Each method sends
|
|
565
|
+
* exactly one request line and resolves against its own deadline; none of
|
|
566
|
+
* them ever reject. */
|
|
567
|
+
export interface BrokerControlSession {
|
|
568
|
+
acquire(opts?: ControlDeadlineOptions): Promise<ControlAcquireResult>;
|
|
569
|
+
release(): Promise<ControlReleaseResult>;
|
|
570
|
+
recycle(targetId: string, opts?: ControlDeadlineOptions): Promise<ControlRecycleResult>;
|
|
571
|
+
status(opts?: ControlDeadlineOptions): Promise<ControlStatusResult>;
|
|
572
|
+
hostState(opts?: ControlDeadlineOptions): Promise<ControlHostStateResult>;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface OpenBrokerControlOptions {
|
|
576
|
+
connectTimeoutMs?: number;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export type OpenBrokerControlOutcome =
|
|
580
|
+
| { ok: true; session: BrokerControlSession }
|
|
581
|
+
| { ok: false; kind: ControlFailureKind; message: string; target?: string };
|
|
582
|
+
|
|
583
|
+
/** One in-flight request's settlement callback -- pushed onto the session's
|
|
584
|
+
* FIFO pending queue in sendAndAwaitLine() below, and shifted off it by
|
|
585
|
+
* EXACTLY ONE of: a response line arriving (createSession()'s own "data"
|
|
586
|
+
* handler), the per-request deadline elapsing, or the broker closing/erroring
|
|
587
|
+
* the connection (which drains and settles every entry still in the queue).
|
|
588
|
+
* FIFO order is sound here because every session method awaits its own
|
|
589
|
+
* sendAndAwaitLine() call to settle before this client ever writes a second
|
|
590
|
+
* request line -- responses can therefore never arrive out of the order
|
|
591
|
+
* their requests were sent in, so matching purely by arrival order (rather
|
|
592
|
+
* than by echoing the request id back, which several response kinds do not
|
|
593
|
+
* even carry) is correct. */
|
|
594
|
+
interface PendingLineEntry {
|
|
595
|
+
handle(line: Record<string, unknown> | null, brokerGone: boolean): void;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
type RawLineOutcome = { ok: true; line: Record<string, unknown> } | { ok: false; kind: ControlFailureKind; message: string };
|
|
599
|
+
|
|
600
|
+
/** Builds the session object wrapping an already-CONNECTED socket. Wires the
|
|
601
|
+
* newline framing (buffer, split on "\n", one entry-per-response FIFO
|
|
602
|
+
* dispatch -- structurally the same shape broker-control.mts's own
|
|
603
|
+
* attachControlProtocol() uses on the host side) and the broker-gone
|
|
604
|
+
* settlement on "close"/"error", then exposes the five typed request
|
|
605
|
+
* methods over it. */
|
|
606
|
+
function createSession(socket: Socket, token: string): BrokerControlSession {
|
|
607
|
+
let buffer = "";
|
|
608
|
+
let closed = false;
|
|
609
|
+
const pending: PendingLineEntry[] = [];
|
|
610
|
+
|
|
611
|
+
socket.on("data", (chunk: Buffer) => {
|
|
612
|
+
buffer += chunk.toString("utf8");
|
|
613
|
+
let newlineIdx: number;
|
|
614
|
+
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
615
|
+
const line = buffer.slice(0, newlineIdx);
|
|
616
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
617
|
+
if (line.trim() === "") continue;
|
|
618
|
+
const entry = pending.shift();
|
|
619
|
+
if (!entry) continue; // unsolicited line -- this protocol never pushes one; ignored defensively
|
|
620
|
+
|
|
621
|
+
let parsed: unknown;
|
|
622
|
+
try {
|
|
623
|
+
parsed = JSON.parse(line);
|
|
624
|
+
} catch {
|
|
625
|
+
entry.handle(null, false);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
629
|
+
entry.handle(null, false);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
entry.handle(parsed as Record<string, unknown>, false);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
function settleAllBrokerGone(): void {
|
|
637
|
+
closed = true;
|
|
638
|
+
const all = pending.splice(0, pending.length);
|
|
639
|
+
for (const entry of all) entry.handle(null, true);
|
|
640
|
+
}
|
|
641
|
+
socket.on("close", settleAllBrokerGone);
|
|
642
|
+
socket.on("error", settleAllBrokerGone);
|
|
643
|
+
|
|
644
|
+
/** Sends one JSON line carrying `token` and awaits the matching response,
|
|
645
|
+
* settling a typed failure rather than throwing on every failure mode:
|
|
646
|
+
* deadline, broker-gone, a malformed line, or the broker's own `error`
|
|
647
|
+
* response (whose `code` is forwarded verbatim as this outcome's `kind`).
|
|
648
|
+
* A success line is handed back UNINTERPRETED as `line` -- each public
|
|
649
|
+
* method below checks its own expected `kind` and extracts its own
|
|
650
|
+
* fields, so this shared helper carries none of that per-request-kind
|
|
651
|
+
* knowledge. */
|
|
652
|
+
function sendAndAwaitLine(payload: Record<string, unknown>, timeoutMs: number): Promise<RawLineOutcome> {
|
|
653
|
+
return new Promise((resolvePromise) => {
|
|
654
|
+
if (closed) {
|
|
655
|
+
resolvePromise({ ok: false, kind: "broker_gone", message: "openBrokerControl: session already closed" });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
let settled = false;
|
|
659
|
+
const entry: PendingLineEntry = {
|
|
660
|
+
handle(line, brokerGone) {
|
|
661
|
+
if (settled) return;
|
|
662
|
+
settled = true;
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
if (brokerGone) {
|
|
665
|
+
resolvePromise({
|
|
666
|
+
ok: false,
|
|
667
|
+
kind: "broker_gone",
|
|
668
|
+
message: "openBrokerControl: the broker closed the connection while this request was in flight",
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (line === null) {
|
|
673
|
+
resolvePromise({ ok: false, kind: "protocol", message: "openBrokerControl: malformed or non-object response line" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (line.kind === "error") {
|
|
677
|
+
const code = typeof line.code === "string" ? (line.code as ControlFailureKind) : "internal";
|
|
678
|
+
resolvePromise({
|
|
679
|
+
ok: false,
|
|
680
|
+
kind: code,
|
|
681
|
+
message: typeof line.message === "string" ? line.message : "openBrokerControl: broker reported an error",
|
|
682
|
+
});
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
resolvePromise({ ok: true, line });
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
const timer = setTimeout(() => {
|
|
689
|
+
if (settled) return;
|
|
690
|
+
settled = true;
|
|
691
|
+
const idx = pending.indexOf(entry);
|
|
692
|
+
if (idx !== -1) pending.splice(idx, 1);
|
|
693
|
+
resolvePromise({ ok: false, kind: "deadline", message: `openBrokerControl: no response within ${timeoutMs}ms` });
|
|
694
|
+
}, timeoutMs);
|
|
695
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
696
|
+
|
|
697
|
+
pending.push(entry);
|
|
698
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function acquire(opts: ControlDeadlineOptions = {}): Promise<ControlAcquireResult> {
|
|
703
|
+
const requestId = newRequestId();
|
|
704
|
+
const raw = await sendAndAwaitLine({ op: "acquire", id: requestId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
|
|
705
|
+
if (!raw.ok) return raw;
|
|
706
|
+
const line = raw.line;
|
|
707
|
+
if (line.kind !== "grant") {
|
|
708
|
+
return { ok: false, kind: "protocol", message: `openBrokerControl: acquire got unexpected response kind ${String(line.kind)}` };
|
|
709
|
+
}
|
|
710
|
+
const grant: AcquireGrant = {
|
|
711
|
+
id: String(line.id),
|
|
712
|
+
port: Number(line.port),
|
|
713
|
+
url: String(line.url),
|
|
714
|
+
epoch_file: String(line.epoch_file),
|
|
715
|
+
supervisor_dir: String(line.supervisor_dir),
|
|
716
|
+
};
|
|
717
|
+
return { ok: true, grant };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The connection IS the lease -- closing it is the ENTIRE release, no
|
|
721
|
+
* wire round trip needed (matches acquireOverControlPlane()'s own
|
|
722
|
+
* release() above). socket.destroy() is itself idempotent, so a second
|
|
723
|
+
* release() call is a silent no-op, matching the idempotent posture the
|
|
724
|
+
* retiring file-based releaseLease() already had. */
|
|
725
|
+
async function release(): Promise<ControlReleaseResult> {
|
|
726
|
+
if (!socket.destroyed) socket.destroy();
|
|
727
|
+
closed = true;
|
|
728
|
+
return { ok: true };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function recycle(targetId: string, opts: ControlDeadlineOptions = {}): Promise<ControlRecycleResult> {
|
|
732
|
+
const requestId = newRequestId();
|
|
733
|
+
const raw = await sendAndAwaitLine({ op: "recycle", id: requestId, target_id: targetId, token }, opts.timeoutMs ?? RECYCLE_TIMEOUT_MS);
|
|
734
|
+
if (!raw.ok) return raw;
|
|
735
|
+
const line = raw.line;
|
|
736
|
+
if (line.kind !== "recycle_ack") {
|
|
737
|
+
return { ok: false, kind: "protocol", message: `openBrokerControl: recycle got unexpected response kind ${String(line.kind)}` };
|
|
738
|
+
}
|
|
739
|
+
// Exactly the key set vice-proxy.ts's recycleAckOutcomeMessage() (lines
|
|
740
|
+
// 584-611) plus its caller (lines 707-713) read from the ack: outcome,
|
|
741
|
+
// kill_stage, reason -- documented at the point of use here rather than
|
|
742
|
+
// only in the plan, since this IS the point of use.
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
ack: {
|
|
746
|
+
outcome: typeof line.outcome === "string" ? line.outcome : "unknown",
|
|
747
|
+
kill_stage: typeof line.kill_stage === "string" ? line.kill_stage : "unknown",
|
|
748
|
+
reason: typeof line.reason === "string" ? line.reason : "",
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function status(opts: ControlDeadlineOptions = {}): Promise<ControlStatusResult> {
|
|
754
|
+
// Reuses ACQUIRE_TIMEOUT_MS as a shared bound -- status is a synchronous,
|
|
755
|
+
// in-memory read on the broker side (no launch, no kill involved), so it
|
|
756
|
+
// needs no timeout of its own scale; introducing a distinct constant (or
|
|
757
|
+
// environment variable) for it would be exactly the kind of new knob
|
|
758
|
+
// 01.6.2-06-PLAN.md's own scope excludes.
|
|
759
|
+
const raw = await sendAndAwaitLine({ op: "status", token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
|
|
760
|
+
if (!raw.ok) return raw;
|
|
761
|
+
const line = raw.line;
|
|
762
|
+
if (line.kind !== "status") {
|
|
763
|
+
return { ok: false, kind: "protocol", message: `openBrokerControl: status got unexpected response kind ${String(line.kind)}` };
|
|
764
|
+
}
|
|
765
|
+
const rawInstances = Array.isArray(line.instances) ? line.instances : [];
|
|
766
|
+
const instances: ControlStatusInstanceEntry[] = rawInstances.map((rawEntry) => {
|
|
767
|
+
const e = rawEntry && typeof rawEntry === "object" ? (rawEntry as Record<string, unknown>) : {};
|
|
768
|
+
return {
|
|
769
|
+
port: Number(e.port),
|
|
770
|
+
url: typeof e.url === "string" ? e.url : "",
|
|
771
|
+
state: typeof e.state === "string" ? e.state : "",
|
|
772
|
+
reason: typeof e.reason === "string" ? e.reason : "",
|
|
773
|
+
epoch: typeof e.epoch === "number" ? e.epoch : null,
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
return { ok: true, instances };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function hostState(opts: ControlDeadlineOptions = {}): Promise<ControlHostStateResult> {
|
|
780
|
+
// Same shared-bound reasoning as status() above.
|
|
781
|
+
const raw = await sendAndAwaitLine({ op: "host_state", token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
|
|
782
|
+
if (!raw.ok) return raw;
|
|
783
|
+
const line = raw.line;
|
|
784
|
+
if (line.kind !== "host_state") {
|
|
785
|
+
return { ok: false, kind: "protocol", message: `openBrokerControl: host_state got unexpected response kind ${String(line.kind)}` };
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
ok: true,
|
|
789
|
+
hostState: {
|
|
790
|
+
pid: Number(line.pid),
|
|
791
|
+
started_at: String(line.started_at),
|
|
792
|
+
node_version: String(line.node_version),
|
|
793
|
+
vice_bin: String(line.vice_bin),
|
|
794
|
+
warm_floor: Number(line.warm_floor),
|
|
795
|
+
max_instances: Number(line.max_instances),
|
|
796
|
+
base_port: Number(line.base_port),
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return { acquire, release, recycle, status, hostState };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Opens ONE session against the control plane: reads broker.json ONCE for
|
|
805
|
+
* control_host/control_port/control_token (and, from that SAME read,
|
|
806
|
+
* classifies liveness -- never a second file read for the same record),
|
|
807
|
+
* refuses to even attempt a connection when that classification is
|
|
808
|
+
* never_started or stale, then opens ONE TCP connection and holds it for
|
|
809
|
+
* the caller. Every failure mode resolves a typed `{ ok: false, kind,
|
|
810
|
+
* message }` outcome rather than rejecting -- see this region's own header
|
|
811
|
+
* comment for why. */
|
|
812
|
+
export function openBrokerControl(dir: string = brokerRootDir(), opts: OpenBrokerControlOptions = {}): Promise<OpenBrokerControlOutcome> {
|
|
813
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? CONTROL_CONNECT_TIMEOUT_MS;
|
|
814
|
+
return new Promise((resolvePromise) => {
|
|
815
|
+
const path = brokerJsonPath(dir);
|
|
816
|
+
const parsed = readJsonMaybe(path); // the ONE read of the discovery record for this whole session
|
|
817
|
+
const liveness = classifyLivenessFromRecord(parsed, path);
|
|
818
|
+
if (liveness.state === "never_started" || liveness.state === "stale") {
|
|
819
|
+
resolvePromise({
|
|
820
|
+
ok: false,
|
|
821
|
+
kind: liveness.state,
|
|
822
|
+
message: `openBrokerControl: broker.json classifies ${liveness.state} (${path}) -- refusing to attempt a connection`,
|
|
823
|
+
});
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (parsed === null) {
|
|
827
|
+
// Unreachable in practice -- classifyLivenessFromRecord() only ever
|
|
828
|
+
// answers "alive" when it was handed a non-null record -- but keeps
|
|
829
|
+
// the branch below soundly typed rather than asserting past the
|
|
830
|
+
// compiler.
|
|
831
|
+
resolvePromise({ ok: false, kind: "never_started", message: "openBrokerControl: broker.json unexpectedly absent" });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const controlHost = typeof parsed.control_host === "string" ? parsed.control_host : null;
|
|
835
|
+
const port = typeof parsed.control_port === "number" ? parsed.control_port : null;
|
|
836
|
+
const token = typeof parsed.control_token === "string" ? parsed.control_token : null;
|
|
837
|
+
if (controlHost === null || port === null || token === null) {
|
|
838
|
+
resolvePromise({
|
|
839
|
+
ok: false,
|
|
840
|
+
kind: "protocol",
|
|
841
|
+
message: "openBrokerControl: broker.json missing control_host/control_port/control_token",
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const targetResult = resolveControlTarget(parsed, port);
|
|
847
|
+
if (!targetResult.ok) {
|
|
848
|
+
resolvePromise({ ok: false, kind: targetResult.kind, message: targetResult.message, target: targetResult.target });
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const { host } = targetResult.target;
|
|
852
|
+
|
|
853
|
+
let settled = false;
|
|
854
|
+
const socket = connect({ host, port });
|
|
855
|
+
|
|
856
|
+
const connectTimer = setTimeout(() => {
|
|
857
|
+
if (settled) return;
|
|
858
|
+
settled = true;
|
|
859
|
+
socket.removeListener("connect", onConnect);
|
|
860
|
+
socket.removeListener("error", onError);
|
|
861
|
+
socket.destroy();
|
|
862
|
+
resolvePromise({
|
|
863
|
+
ok: false,
|
|
864
|
+
kind: "connect_refused",
|
|
865
|
+
message: `openBrokerControl: no connection to ${host}:${port} within ${connectTimeoutMs}ms`,
|
|
866
|
+
target: `${host}:${port}`,
|
|
867
|
+
});
|
|
868
|
+
}, connectTimeoutMs);
|
|
869
|
+
if (typeof connectTimer.unref === "function") connectTimer.unref();
|
|
870
|
+
|
|
871
|
+
function onConnect(): void {
|
|
872
|
+
if (settled) return;
|
|
873
|
+
settled = true;
|
|
874
|
+
clearTimeout(connectTimer);
|
|
875
|
+
socket.removeListener("error", onError);
|
|
876
|
+
resolvePromise({ ok: true, session: createSession(socket, token as string) });
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function onError(err: Error): void {
|
|
880
|
+
if (settled) return;
|
|
881
|
+
settled = true;
|
|
882
|
+
clearTimeout(connectTimer);
|
|
883
|
+
socket.removeListener("connect", onConnect);
|
|
884
|
+
resolvePromise({
|
|
885
|
+
ok: false,
|
|
886
|
+
kind: "connect_refused",
|
|
887
|
+
message: `openBrokerControl: connection to ${host}:${port} failed -- ${err.message}`,
|
|
888
|
+
target: `${host}:${port}`,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
socket.once("connect", onConnect);
|
|
893
|
+
socket.once("error", onError);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// BROKER-CONTROL-CLIENT REGION END (plan 06, task 1)
|
|
899
|
+
// ---------------------------------------------------------------------------
|