@cortexkit/subc-client 0.3.0 → 0.3.1

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/dist/client.js ADDED
@@ -0,0 +1,888 @@
1
+ // The consumer-facing subc client. Mirrors the canonical pure consumer
2
+ // (subc-core/src/bin/subc-probe.rs): authenticate -> catalog.list (optional) ->
3
+ // route.open -> request on the returned route channel. There is no client HELLO
4
+ // — HELLO is module-registration only.
5
+ //
6
+ // A single background loop reads every inbound frame and demuxes it by
7
+ // (channel, corr) to the matching in-flight request. Never assume positional
8
+ // read order: subc may interleave a control reply ahead of another exchange's
9
+ // response on the same connection, so frames are matched to their request by
10
+ // correlation id, not arrival order.
11
+ import { promises as fs } from "node:fs";
12
+ import { debuglog } from "node:util";
13
+ import { AuthError, authenticateClient } from "./auth.js";
14
+ import { ConnectionFileError, readConnectionFile } from "./connection-file.js";
15
+ import { buildFrame, buildFlags, decodeHeader, encodeFrame, FrameType, HEADER_LEN, Priority, } from "./envelope.js";
16
+ import { SocketClosedError, SocketTimeoutError, SocketWriteNotQueuedError, SocketWriteQueuedError, SubcSocket, } from "./socket.js";
17
+ const debug = debuglog("subc-client");
18
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
19
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
20
+ // When a request-timeout timer fires, its reply may already be sitting in the
21
+ // socket read buffer, unprocessed only because the event loop was starved (Node
22
+ // runs the TIMERS phase before the POLL phase, so an expired timer can beat an
23
+ // already-arrived frame). Rather than settle as a timeout immediately, arbitrate:
24
+ // yield one check-phase turn (setImmediate) so a fully-buffered reply dispatches
25
+ // and wins, and — only while the reader is actively draining the same socket —
26
+ // allow a small hard-capped grace for a reply whose header/body spans more than
27
+ // one loop turn. This is a demux tiebreak for a reply that RACED the deadline,
28
+ // NOT a deadline extension: an absent reply still settles right after the check
29
+ // phase. Capped so it can never approach BODY_READ_TIMEOUT_MS.
30
+ const TIMEOUT_ARBITRATION_GRACE_MS = 50;
31
+ // Internal marker set as the `code` on the SubcError a request-deadline timeout
32
+ // rejects with, so the managed classifier can tell a deadline (reply may simply
33
+ // not have been read in time) from an actual connection drop. Never surfaced to
34
+ // callers directly — it is refined into DEADLINE_NO_DROP_CODE by classifyFailure.
35
+ const REQUEST_DEADLINE_MARKER = "request_deadline";
36
+ // The consumer-facing code for a managed call whose deadline elapsed while its
37
+ // bytes were queued to the local socket and NO connection drop / GOODBYE was
38
+ // observed. Distinct from "connection_dropped" so a caller can skip a
39
+ // was-it-even-sent recovery path — but still kind=outcome_unknown (never safe to
40
+ // retry: "queued to the local socket" is NOT proof the daemon received or ran it).
41
+ const DEADLINE_NO_DROP_CODE = "deadline_exceeded_no_drop_observed";
42
+ // A retryable route.open rejection (target booting / reloading / momentarily
43
+ // absent) is retried in-place against the same connection up to this deadline
44
+ // before it is surfaced as not_sent. Mirrors subc-client-rs
45
+ // DEFAULT_ROUTE_RETRY_DEADLINE so a target that is briefly unavailable at daemon
46
+ // restart recovers without a misleading terminal error.
47
+ const ROUTE_OPEN_RETRY_DEADLINE_MS = 10_000;
48
+ // Once a header arrives, its body must follow promptly; bound it so a truncated
49
+ // frame cannot wedge the read loop forever.
50
+ const BODY_READ_TIMEOUT_MS = 30_000;
51
+ const EMPTY_BODY = new Uint8Array(0);
52
+ const DEFAULT_MANAGED_TARGET_KIND = "management_surface";
53
+ export const SUBC_MODULE_ID_ENV = "SUBC_MODULE_ID";
54
+ export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
55
+ export const DEFAULT_RECONNECT_BACKOFF = {
56
+ baseMs: 100,
57
+ capMs: 2_000,
58
+ maxAttempts: 6,
59
+ };
60
+ /**
61
+ * Managed call failure with send-outcome semantics.
62
+ *
63
+ * `not_sent` is intentionally narrow: the request bytes provably never left the
64
+ * local process (the connection was already closed, or net.Socket.write failed
65
+ * before queuing bytes). Managed call() may retry only this case.
66
+ *
67
+ * `outcome_unknown` is the safe default once bytes have been handed to the local
68
+ * socket. The daemon or module may have received the request before the response
69
+ * was lost, so call() never retries it automatically; the caller must decide
70
+ * whether the operation is idempotent or needs a check-then-act recovery.
71
+ *
72
+ * `terminal` covers protocol Error frames and non-retryable client/setup errors.
73
+ */
74
+ export class SubcCallError extends Error {
75
+ kind;
76
+ code;
77
+ cause;
78
+ constructor(kind, message, code, cause) {
79
+ super(message);
80
+ this.kind = kind;
81
+ this.code = code;
82
+ this.cause = cause;
83
+ this.name = "SubcCallError";
84
+ }
85
+ }
86
+ export class SubcError extends Error {
87
+ code;
88
+ constructor(message, code) {
89
+ super(message);
90
+ this.code = code;
91
+ }
92
+ }
93
+ export class SubcClient {
94
+ sock;
95
+ currentConn;
96
+ opts;
97
+ nextCorr = 1n;
98
+ pending = new Map();
99
+ routes = new Map();
100
+ closedErr = null;
101
+ closeStarted = false;
102
+ reconnecting = null;
103
+ generation = 1;
104
+ // True while the read loop is actively reading/dispatching a frame off the
105
+ // current socket (between reading a header and finishing its dispatch). The
106
+ // timeout arbitration reads it to decide whether a just-fired timeout should
107
+ // grant a reply mid-arrival a small grace window before settling.
108
+ readerActive = false;
109
+ constructor(sock, currentConn, opts) {
110
+ this.sock = sock;
111
+ this.currentConn = currentConn;
112
+ this.opts = opts;
113
+ void this.readLoop(sock, this.generation);
114
+ }
115
+ get conn() {
116
+ return this.currentConn;
117
+ }
118
+ /** Read the connection file, connect, authenticate, and start the read loop. */
119
+ static async connect(opts) {
120
+ const normalized = normalizeConnectOptions(opts);
121
+ const opened = await SubcClient.openConnection(normalized);
122
+ return new SubcClient(opened.sock, opened.conn, normalized);
123
+ }
124
+ /** List modules subc knows about (channel-0 catalog.list). */
125
+ async catalogList(moduleId) {
126
+ const body = this.encode(moduleId === undefined ? { op: "catalog.list" } : { op: "catalog.list", module_id: moduleId });
127
+ const reply = await this.controlRpc(body);
128
+ const parsed = this.parseJson(reply);
129
+ return parsed.modules ?? [];
130
+ }
131
+ /** Open a route to a provider (channel-0 route.open); returns the route channel. */
132
+ async routeOpen(target, identity, opts = {}) {
133
+ const consumerIdentity = routeOpenConsumerIdentity(opts);
134
+ const body = this.encode({
135
+ op: "route.open",
136
+ target,
137
+ identity,
138
+ ...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
139
+ });
140
+ const reply = await this.controlRpc(body);
141
+ const parsed = this.parseJson(reply);
142
+ if (typeof parsed.route_channel !== "number") {
143
+ throw new SubcError(`route.open returned no route_channel: ${JSON.stringify(parsed)}`);
144
+ }
145
+ return parsed.route_channel;
146
+ }
147
+ /** Send a data-plane request on a route channel and await its terminal reply. */
148
+ async request(routeChannel, body, opts = {}) {
149
+ const bytes = body instanceof Uint8Array ? body : this.encode(body);
150
+ const priority = opts.priority ?? Priority.Interactive;
151
+ const reply = await this.send(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
152
+ return this.parseJson(reply);
153
+ }
154
+ /**
155
+ * Managed route + request convenience. Opens and caches a route for the module,
156
+ * reconnecting and re-opening cached routes after connection drops.
157
+ */
158
+ async call(moduleId, method, params, opts = {}) {
159
+ const body = params === undefined ? { method } : { method, params };
160
+ for (;;) {
161
+ const routeChannel = await this.cachedRouteChannel(moduleId, opts);
162
+ try {
163
+ return (await this.managedRequest(routeChannel, body, opts));
164
+ }
165
+ catch (err) {
166
+ if (!(err instanceof SubcCallError))
167
+ throw this.terminalCallError("managed call failed", err);
168
+ if (err.kind === "not_sent") {
169
+ try {
170
+ await this.reconnectAfterDrop(err);
171
+ }
172
+ catch (reconnectErr) {
173
+ throw this.notSentRecoveryError("managed call was not sent", reconnectErr);
174
+ }
175
+ continue;
176
+ }
177
+ if (err.kind === "outcome_unknown" && err.code !== DEADLINE_NO_DROP_CODE) {
178
+ // A real drop schedules a reconnect. A deadline-with-no-drop does NOT:
179
+ // the socket was never observed to fail (the reply was likely just read
180
+ // late under load), so tearing it down would abandon a healthy connection
181
+ // and its other in-flight routes for nothing.
182
+ this.scheduleReconnectAfterDrop(err);
183
+ }
184
+ throw err;
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Open a held-open event subscription on a route channel. Sends one Request the
190
+ * provider keeps open, delivering each interim StreamData frame to `onEvent`; the
191
+ * returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
192
+ * GOODBYE (reject). Events ride this held-open request's correlation id — they are
193
+ * never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
194
+ */
195
+ subscribe(routeChannel, body, onEvent, opts = {}) {
196
+ const bytes = body instanceof Uint8Array ? body : this.encode(body);
197
+ const priority = opts.priority ?? Priority.Interactive;
198
+ const corr = this.nextCorr++;
199
+ const key = `${routeChannel}:${corr}`;
200
+ const closed = new Promise((resolve, reject) => {
201
+ if (this.closedErr) {
202
+ reject(this.closedErr);
203
+ return;
204
+ }
205
+ // No timeout: a subscription stays open indefinitely until StreamEnd, Error,
206
+ // route GOODBYE, or unsubscribe.
207
+ this.pending.set(key, {
208
+ channel: routeChannel,
209
+ resolve: () => resolve(),
210
+ reject,
211
+ onProgress: onEvent,
212
+ timer: null,
213
+ subscription: true,
214
+ });
215
+ const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), routeChannel, corr, bytes);
216
+ this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
217
+ const p = this.pending.get(key);
218
+ if (p)
219
+ this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
220
+ });
221
+ });
222
+ let cancelled = false;
223
+ const unsubscribe = () => {
224
+ if (cancelled)
225
+ return;
226
+ cancelled = true;
227
+ // Pure-header Cancel on the held-open (channel, corr): the provider aborts its
228
+ // handler and ends with StreamEnd, which settles `closed`.
229
+ const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
230
+ this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
231
+ // Best-effort: if the socket is already gone, the read loop fails the
232
+ // pending waiter and `closed` rejects on its own.
233
+ });
234
+ };
235
+ return { unsubscribe, closed };
236
+ }
237
+ /**
238
+ * Tear down ONE managed route (a route opened via `call()`), keyed by its
239
+ * (target, identity). Idempotent and never throws — callers over-call on
240
+ * session-end. The teardown:
241
+ * - flips a tombstone on the cached route and removes it from the cache, so an
242
+ * in-flight `openCachedRoute` for the same key will NOT install its channel
243
+ * (the generation guard: close beats a racing reopen), and a later `call()`
244
+ * opens a fresh route (this is NOT a permanent tombstone);
245
+ * - settles in-flight requests on the channel as RouteClosed (managed requests
246
+ * keep their at-most-once classification: outcome_unknown if already sent,
247
+ * not_sent otherwise; subscriptions always abort);
248
+ * - sends a best-effort route GOODBYE so subc releases the route and notifies
249
+ * the module to free per-session resources.
250
+ * `opts.drain` waits for in-flight UNARY requests to settle before tearing down.
251
+ */
252
+ async closeRoute(target, identity, opts = {}) {
253
+ const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
254
+ const cached = this.routes.get(key);
255
+ if (!cached)
256
+ return; // never opened / already closed — idempotent no-op.
257
+ // Generation guard: an in-flight openCachedRoute holds this same object and
258
+ // re-checks `closed` before installing its channel, so flipping it here makes
259
+ // close win over a racing reopen. Removing the map entry lets a later call()
260
+ // create a fresh route for the key (not a permanent tombstone).
261
+ cached.closed = true;
262
+ this.routes.delete(key);
263
+ const channel = cached.channel;
264
+ cached.channel = null;
265
+ // channel === null means the route was still opening (no channel installed yet);
266
+ // the racing open will see closed=true and GOODBYE whatever it opens, so there is
267
+ // nothing local to tear down here.
268
+ if (channel !== null)
269
+ await this.closeRouteChannel(channel, opts);
270
+ }
271
+ /**
272
+ * Tear down ONE route by its channel number — the primitive for callers that
273
+ * opened a route with `routeOpen` directly (e.g. a tool route carrying raw
274
+ * {name, arguments}) and hold the channel themselves. Idempotent, never throws.
275
+ * Settles in-flight requests on the channel as RouteClosed and sends a best-effort
276
+ * route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
277
+ * are always aborted (a held-open stream cannot be drained).
278
+ */
279
+ async closeRouteChannel(channel, opts = {}) {
280
+ if (channel === 0)
281
+ return; // channel 0 is the control plane, never a route.
282
+ if (opts.drain) {
283
+ // Wait only for in-flight UNARY requests on this channel; subscriptions are
284
+ // aborted below (a held-open stream has no natural completion to drain to).
285
+ await this.drainUnaryOnChannel(channel);
286
+ }
287
+ // Settle anything still in flight on the channel (all of it in abort mode; only
288
+ // subscriptions + late stragglers after a drain). Managed requests are classified
289
+ // at-most-once via their classifyFailure; raw requests/subscriptions get a plain
290
+ // RouteClosed error.
291
+ this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
292
+ // Best-effort GOODBYE: releases the route on the daemon and notifies the module.
293
+ this.sendRouteGoodbye(channel);
294
+ }
295
+ close() {
296
+ this.closeStarted = true;
297
+ this.fail(new SubcError("client closed"));
298
+ this.sock.close();
299
+ }
300
+ /** Resolve once every in-flight UNARY request on the channel (snapshot at call
301
+ * time) has settled. Subscriptions are excluded — they are aborted, not drained. */
302
+ drainUnaryOnChannel(channel) {
303
+ const waiters = [];
304
+ for (const pending of this.pending.values()) {
305
+ if (pending.channel === channel && !pending.subscription) {
306
+ waiters.push(new Promise((resolve) => {
307
+ const prev = pending.onSettle;
308
+ pending.onSettle = () => {
309
+ prev?.();
310
+ resolve();
311
+ };
312
+ }));
313
+ }
314
+ }
315
+ return Promise.all(waiters).then(() => undefined);
316
+ }
317
+ /** Send a best-effort header-only route GOODBYE for `channel`. One-way: the daemon
318
+ * releases the route and relays a route-gone GOODBYE to the module; no ack. */
319
+ sendRouteGoodbye(channel) {
320
+ if (this.closedErr)
321
+ return; // connection already gone — the route died with it.
322
+ const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), channel, 0n, EMPTY_BODY);
323
+ this.sock.write(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
324
+ // Best-effort: if the socket is already gone, the route is torn down anyway.
325
+ });
326
+ }
327
+ static async openConnection(opts) {
328
+ const conn = await readConnectionFile(opts.connectionFile);
329
+ const deadline = Date.now() + (opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);
330
+ const endpoint = conn.endpoints[0];
331
+ const sock = await SubcSocket.connect(endpoint.host, endpoint.port, deadline);
332
+ try {
333
+ await authenticateClient(sock, conn, deadline);
334
+ }
335
+ catch (err) {
336
+ sock.close();
337
+ throw err;
338
+ }
339
+ return { sock, conn };
340
+ }
341
+ async controlRpc(body) {
342
+ // Match the canonical probe: control requests go out Interactive on channel 0.
343
+ return this.send(0, body, Priority.Interactive, undefined, undefined);
344
+ }
345
+ send(channel, body, priority, timeoutMs, onProgress) {
346
+ if (this.closedErr)
347
+ return Promise.reject(this.closedErr);
348
+ const corr = this.nextCorr++;
349
+ const key = `${channel}:${corr}`;
350
+ const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
351
+ return new Promise((resolve, reject) => {
352
+ const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
353
+ const pending = {
354
+ channel,
355
+ resolve,
356
+ reject,
357
+ onProgress,
358
+ timer: null,
359
+ };
360
+ pending.timer = setTimeout(() => {
361
+ this.arbitrateTimeout(key, pending, channel, corr, ms);
362
+ }, ms);
363
+ this.pending.set(key, pending);
364
+ this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
365
+ const p = this.pending.get(key);
366
+ if (p)
367
+ this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
368
+ });
369
+ });
370
+ }
371
+ /**
372
+ * A request-deadline timer has fired. Before settling as a timeout, arbitrate
373
+ * the timer-vs-poll race: a reply may already be in the socket buffer, unread
374
+ * only because the loop was starved. Yield one check phase (setImmediate) so a
375
+ * fully-buffered reply dispatches and wins via settle()'s identity guard; then,
376
+ * only while the reader is actively draining THIS socket (a frame mid-arrival),
377
+ * grant a single hard-capped grace before finally settling. Absent replies
378
+ * still settle right after the check phase. The settle carries the deadline
379
+ * marker so the managed classifier reports deadline-not-drop.
380
+ */
381
+ arbitrateTimeout(key, pending, channel, corr, ms) {
382
+ const settleAsTimeout = () => {
383
+ this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms), REQUEST_DEADLINE_MARKER));
384
+ };
385
+ const graceDeadline = Date.now() + this.opts.timeoutArbitrationGraceMs;
386
+ const arbitrate = () => {
387
+ // Already settled (by dispatch, fail, GOODBYE, or close)? Nothing to do.
388
+ if (this.pending.get(key) !== pending)
389
+ return;
390
+ // A reply is mid-arrival on this socket (or bytes are buffered), and we are
391
+ // still inside the grace window: give the reader another turn to finish
392
+ // dispatching it. The generation guard in readLoop keeps this scoped to the
393
+ // live socket; the grace cap keeps it from approaching the body-read timeout.
394
+ const readerDraining = this.readerActive || this.sock.bufferedBytes() > 0;
395
+ if (readerDraining && Date.now() < graceDeadline) {
396
+ setImmediate(arbitrate);
397
+ return;
398
+ }
399
+ settleAsTimeout();
400
+ };
401
+ setImmediate(arbitrate);
402
+ }
403
+ async managedRequest(routeChannel, body, opts) {
404
+ const bytes = body instanceof Uint8Array ? body : this.encode(body);
405
+ const priority = opts.priority ?? Priority.Interactive;
406
+ try {
407
+ const reply = await this.sendManaged(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
408
+ return this.parseJson(reply);
409
+ }
410
+ catch (err) {
411
+ if (err instanceof SubcCallError)
412
+ throw err;
413
+ throw this.terminalCallError("managed call failed", err);
414
+ }
415
+ }
416
+ sendManaged(channel, body, priority, timeoutMs, onProgress) {
417
+ if (this.closedErr) {
418
+ return Promise.reject(this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr));
419
+ }
420
+ const corr = this.nextCorr++;
421
+ const key = `${channel}:${corr}`;
422
+ const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
423
+ let handedToSocket = false;
424
+ const classifyFailure = (err) => {
425
+ // This is the load-bearing asymmetry: only the pre-write paths are NotSent.
426
+ // As soon as writeTracked reports that bytes were queued to Node's socket,
427
+ // those bytes may already be in the OS buffer or at the daemon. Any later
428
+ // close, write callback error, route GOODBYE, or timeout before a response is
429
+ // therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
430
+ if (!handedToSocket) {
431
+ return this.notSentCallError("request bytes were not queued to the subc socket", err);
432
+ }
433
+ // A request-deadline timeout (arbitration expired without observing a drop)
434
+ // is refined from a real connection drop: the socket was NOT seen to fail, so
435
+ // the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
436
+ // — queued-to-local-socket is not proof the daemon received or ran it.
437
+ if (err instanceof SubcError && err.code === REQUEST_DEADLINE_MARKER) {
438
+ return new SubcCallError("outcome_unknown", `managed call deadline exceeded after request bytes were queued to the local socket; no terminal response was observed; outcome unknown${causeMessage(err)}`, DEADLINE_NO_DROP_CODE, err);
439
+ }
440
+ return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", err);
441
+ };
442
+ return new Promise((resolve, reject) => {
443
+ const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
444
+ const pending = {
445
+ channel,
446
+ resolve,
447
+ reject,
448
+ onProgress,
449
+ timer: null,
450
+ classifyFailure,
451
+ };
452
+ pending.timer = setTimeout(() => {
453
+ this.arbitrateTimeout(key, pending, channel, corr, ms);
454
+ }, ms);
455
+ this.pending.set(key, pending);
456
+ const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
457
+ handedToSocket = write.queued;
458
+ write.completed.catch((err) => {
459
+ const p = this.pending.get(key);
460
+ if (p)
461
+ this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
462
+ });
463
+ });
464
+ }
465
+ async cachedRouteChannel(moduleId, opts) {
466
+ const identity = opts.identity ?? this.opts.identity;
467
+ if (!identity) {
468
+ throw new SubcCallError("terminal", "managed call requires a BindIdentity in SubcClient.connect({ identity }) or call(..., { identity })", "missing_identity");
469
+ }
470
+ const target = { kind: opts.targetKind ?? this.opts.targetKind, module_id: moduleId };
471
+ const consumerIdentity = routeOpenConsumerIdentity(opts);
472
+ const key = routeCacheKey(target, identity, consumerIdentity);
473
+ let cached = this.routes.get(key);
474
+ if (!cached) {
475
+ cached = {
476
+ key,
477
+ moduleId,
478
+ target,
479
+ identity,
480
+ consumerIdentity,
481
+ channel: null,
482
+ generation: 0,
483
+ opening: null,
484
+ };
485
+ this.routes.set(key, cached);
486
+ }
487
+ if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
488
+ return cached.channel;
489
+ }
490
+ if (!cached.opening) {
491
+ cached.opening = this.openCachedRoute(cached).finally(() => {
492
+ cached.opening = null;
493
+ });
494
+ }
495
+ return cached.opening;
496
+ }
497
+ async openCachedRoute(cached) {
498
+ const routeRetryDeadline = Date.now() + ROUTE_OPEN_RETRY_DEADLINE_MS;
499
+ let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
500
+ let routeRetryAttempt = 0;
501
+ for (;;) {
502
+ if (cached.closed)
503
+ throw this.routeClosedDuringOpen();
504
+ try {
505
+ await this.ensureConnectedForManaged();
506
+ }
507
+ catch (err) {
508
+ throw this.notSentRecoveryError("route.open could not run because reconnect failed", err);
509
+ }
510
+ if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
511
+ return cached.channel;
512
+ }
513
+ try {
514
+ const channel = await this.routeOpen(cached.target, cached.identity, {
515
+ consumerIdentity: cached.consumerIdentity ?? null,
516
+ });
517
+ // Generation guard: a closeRoute may have flipped the tombstone WHILE this
518
+ // route.open was in flight. If so, close wins — do NOT install the channel
519
+ // into the (already-removed) cache entry; GOODBYE the channel we just opened
520
+ // so the daemon/module don't leak it, and fail as RouteClosed.
521
+ if (cached.closed) {
522
+ this.sendRouteGoodbye(channel);
523
+ throw this.routeClosedDuringOpen();
524
+ }
525
+ cached.channel = channel;
526
+ cached.generation = this.generation;
527
+ return channel;
528
+ }
529
+ catch (err) {
530
+ if (err instanceof SubcCallError && err.code === "route_closed")
531
+ throw err;
532
+ if (!this.closeStarted && isConsumerReconnectTransient(err)) {
533
+ try {
534
+ await this.reconnectAfterDrop(err);
535
+ }
536
+ catch (reconnectErr) {
537
+ throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectErr);
538
+ }
539
+ continue;
540
+ }
541
+ // A daemon-rejected route.open with a RETRYABLE code (target booting /
542
+ // reloading / momentarily absent) is retried IN-PLACE against the same live
543
+ // connection — never a socket reconnect, which would needlessly disrupt this
544
+ // connection's other routes — until the route-retry deadline. Past the
545
+ // deadline it surfaces as not_sent: provably pre-send (no data frame ever
546
+ // left the client) AND still transient, so the caller's own retry policy may
547
+ // safely re-attempt later. Reason and set kept in parity with subc-client-rs.
548
+ if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
549
+ routeRetryAttempt += 1;
550
+ if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
551
+ await this.opts.sleep(routeRetryDelay);
552
+ routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
553
+ continue;
554
+ }
555
+ throw this.notSentCallError(`route.open failed for module ${cached.moduleId}: ${err.code} (retry budget exhausted)`, err);
556
+ }
557
+ // A permanent route.open rejection (bad_consumer_identity, config_divergence,
558
+ // unknown_target, ...) is pre-send but would never succeed on retry, so it
559
+ // stays terminal — a not_sent class here would invite a retry storm against a
560
+ // request the daemon will always reject.
561
+ throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
562
+ }
563
+ }
564
+ }
565
+ async ensureConnectedForManaged() {
566
+ if (this.closeStarted)
567
+ throw new SubcError("client closed");
568
+ if (this.reconnecting)
569
+ await this.reconnecting;
570
+ if (this.closedErr)
571
+ await this.reconnectAfterDrop(this.closedErr);
572
+ }
573
+ scheduleReconnectAfterDrop(err) {
574
+ if (this.closeStarted || this.reconnecting)
575
+ return;
576
+ void this.reconnectAfterDrop(err).catch(() => {
577
+ // The originating call keeps its OutcomeUnknown classification. A later call
578
+ // will retry reconnect with the same closed connection state if this attempt
579
+ // cannot restore the daemon yet.
580
+ });
581
+ }
582
+ reconnectAfterDrop(trigger) {
583
+ if (this.closeStarted)
584
+ return Promise.reject(new SubcError("client closed"));
585
+ if (this.reconnecting)
586
+ return this.reconnecting;
587
+ const promise = this.reconnectWithRetry(trigger).finally(() => {
588
+ if (this.reconnecting === promise)
589
+ this.reconnecting = null;
590
+ });
591
+ this.reconnecting = promise;
592
+ return promise;
593
+ }
594
+ async reconnectWithRetry(_trigger) {
595
+ let attempt = 0;
596
+ let delay = this.opts.reconnectBackoff.baseMs;
597
+ for (;;) {
598
+ if (this.closeStarted)
599
+ throw new SubcError("client closed");
600
+ attempt += 1;
601
+ try {
602
+ const opened = await SubcClient.openConnection(this.opts);
603
+ if (this.closeStarted) {
604
+ opened.sock.close();
605
+ throw new SubcError("client closed");
606
+ }
607
+ this.replaceConnection(opened);
608
+ await this.reopenCachedRoutes();
609
+ return;
610
+ }
611
+ catch (err) {
612
+ if (!isConsumerReconnectTransient(err) || attempt >= this.opts.reconnectBackoff.maxAttempts) {
613
+ throw err;
614
+ }
615
+ await this.opts.sleep(delay);
616
+ delay = Math.min(delay * 2, this.opts.reconnectBackoff.capMs);
617
+ }
618
+ }
619
+ }
620
+ replaceConnection(opened) {
621
+ this.sock.close();
622
+ this.sock = opened.sock;
623
+ this.currentConn = opened.conn;
624
+ this.closedErr = null;
625
+ this.generation += 1;
626
+ void this.readLoop(opened.sock, this.generation);
627
+ }
628
+ async reopenCachedRoutes() {
629
+ for (const cached of this.routes.values()) {
630
+ cached.channel = null;
631
+ cached.generation = 0;
632
+ }
633
+ for (const cached of this.routes.values()) {
634
+ if (cached.closed)
635
+ continue; // closed concurrently with reconnect — don't reopen.
636
+ // Thread the route's consumer identity through the reopen, exactly as the
637
+ // lazy per-call path (openCachedRoute) does. Dropping it here would make a
638
+ // route reopened after a reconnect send route.open with no consumer_identity,
639
+ // so the daemon would re-stamp it with a different (weaker) principal than the
640
+ // one it was originally bound under — a silent post-reconnect trust downgrade.
641
+ const channel = await this.routeOpen(cached.target, cached.identity, {
642
+ consumerIdentity: cached.consumerIdentity ?? null,
643
+ });
644
+ // A closeRoute may have raced this reopen (flipping the tombstone during the
645
+ // route.open await). If so, GOODBYE the channel instead of installing it, so the
646
+ // closed route isn't silently re-established on the new connection.
647
+ if (cached.closed) {
648
+ this.sendRouteGoodbye(channel);
649
+ continue;
650
+ }
651
+ cached.channel = channel;
652
+ cached.generation = this.generation;
653
+ }
654
+ }
655
+ // A request timeout carries the local socket port and (channel, corr) so a
656
+ // packet capture can pinpoint the exact on-wire exchange — the decisive evidence
657
+ // for whether a "timed out" reply was actually delivered to this socket (a
658
+ // client-local demux problem) or never sent (a daemon/module problem).
659
+ timeoutMessage(channel, corr, ms) {
660
+ const port = this.sock.localPort();
661
+ const where = port === null ? "channel" : `local_port=${port} channel`;
662
+ return `request on ${where} ${channel} corr ${corr} timed out after ${ms}ms`;
663
+ }
664
+ routeClosedDuringOpen() {
665
+ return new SubcCallError("not_sent", "route was closed before route.open completed", "route_closed");
666
+ }
667
+ async readLoop(sock, generation) {
668
+ try {
669
+ for (;;) {
670
+ // Header read waits indefinitely — idle time between frames is normal, and
671
+ // the reader is NOT "active" while parked here (a racing timeout must not
672
+ // grant grace just because the connection is idle between frames).
673
+ const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
674
+ // A frame is now arriving. Mark the reader active THROUGH dispatch so a
675
+ // timeout that fires mid-arrival grants the reply its bounded grace window
676
+ // instead of settling as a spurious timeout.
677
+ this.readerActive = true;
678
+ try {
679
+ const header = decodeHeader(headerBytes);
680
+ const body = header.len === 0
681
+ ? new Uint8Array(0)
682
+ : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
683
+ // Drop a frame read off a socket this client has already replaced
684
+ // (reconnect): its pendings were settled by fail(), and dispatching it
685
+ // against the current pending map could match a re-used (channel, corr).
686
+ if (this.sock === sock && this.generation === generation) {
687
+ this.dispatch({ header, body });
688
+ }
689
+ }
690
+ finally {
691
+ this.readerActive = false;
692
+ }
693
+ }
694
+ }
695
+ catch (err) {
696
+ if (this.sock === sock && this.generation === generation) {
697
+ this.fail(err instanceof Error ? err : new SubcError(String(err)));
698
+ }
699
+ }
700
+ }
701
+ dispatch(frame) {
702
+ const key = `${frame.header.channel}:${frame.header.corr}`;
703
+ const pending = this.pending.get(key);
704
+ if (pending) {
705
+ switch (frame.header.ty) {
706
+ case FrameType.Push:
707
+ case FrameType.StreamData:
708
+ pending.onProgress?.(frame.body);
709
+ return;
710
+ case FrameType.Response:
711
+ case FrameType.StreamEnd:
712
+ this.settle(key, pending, () => pending.resolve(frame));
713
+ return;
714
+ case FrameType.Error:
715
+ this.settle(key, pending, () => pending.reject(this.errorFromFrame(frame)));
716
+ return;
717
+ default:
718
+ return;
719
+ }
720
+ }
721
+ if (frame.header.ty === FrameType.Goodbye) {
722
+ this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
723
+ return;
724
+ }
725
+ // A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
726
+ // a reply that arrived AFTER its request already settled — the fingerprint of a
727
+ // premature timeout under event-loop starvation (the reply raced the deadline
728
+ // and lost). Metadata-only debug log (never the body) so every future
729
+ // occurrence is a one-line diagnosis instead of an invisible drop. Enable with
730
+ // NODE_DEBUG=subc-client.
731
+ if (frame.header.ty === FrameType.Response ||
732
+ frame.header.ty === FrameType.Error ||
733
+ frame.header.ty === FrameType.StreamEnd) {
734
+ debug("dropped terminal frame with no waiter: type=%d channel=%d corr=%s port=%s", frame.header.ty, frame.header.channel, frame.header.corr, this.sock.localPort() ?? "?");
735
+ return;
736
+ }
737
+ // Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
738
+ // unsolicited-push consumers.
739
+ }
740
+ /**
741
+ * Settle a pending exactly once. The object-identity guard (the map still
742
+ * holds THIS pending under `key`) is the single-winner primitive: whichever of
743
+ * dispatch, a timeout, fail(), failChannel(), a GOODBYE, or a deferred timeout
744
+ * arbitration reaches it first wins, and every later caller no-ops. This is what
745
+ * makes the deferred-timeout arbitration safe — it cannot double-settle, reject
746
+ * an already-resolved promise, or delete a pending re-created for a later corr.
747
+ * Returns true when this call was the settler.
748
+ */
749
+ settle(key, pending, run) {
750
+ if (this.pending.get(key) !== pending)
751
+ return false;
752
+ this.pending.delete(key);
753
+ if (pending.timer)
754
+ clearTimeout(pending.timer);
755
+ run();
756
+ pending.onSettle?.();
757
+ return true;
758
+ }
759
+ rejectPending(key, pending, err) {
760
+ this.settle(key, pending, () => pending.reject(pending.classifyFailure?.(err) ?? err));
761
+ }
762
+ errorFromFrame(frame) {
763
+ try {
764
+ const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8"));
765
+ return new SubcError(parsed.message ?? "subc error", parsed.code);
766
+ }
767
+ catch {
768
+ return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
769
+ }
770
+ }
771
+ failChannel(channel, err) {
772
+ for (const [key, pending] of this.pending) {
773
+ if (pending.channel === channel) {
774
+ this.rejectPending(key, pending, err);
775
+ }
776
+ }
777
+ }
778
+ fail(err) {
779
+ if (!this.closedErr)
780
+ this.closedErr = err;
781
+ for (const [key, pending] of this.pending) {
782
+ this.rejectPending(key, pending, err);
783
+ }
784
+ }
785
+ notSentCallError(message, cause) {
786
+ return new SubcCallError("not_sent", `${message}${causeMessage(cause)}`, errorCode(cause), cause);
787
+ }
788
+ outcomeUnknownCallError(message, cause) {
789
+ return new SubcCallError("outcome_unknown", `${message}${causeMessage(cause)}`, errorCode(cause), cause);
790
+ }
791
+ terminalCallError(message, cause) {
792
+ if (cause instanceof SubcCallError)
793
+ return cause;
794
+ return new SubcCallError("terminal", `${message}${causeMessage(cause)}`, errorCode(cause), cause);
795
+ }
796
+ notSentRecoveryError(message, cause) {
797
+ if (cause instanceof SubcCallError)
798
+ return cause;
799
+ if (isConsumerReconnectTransient(cause))
800
+ return this.notSentCallError(message, cause);
801
+ return this.terminalCallError(message, cause);
802
+ }
803
+ encode(value) {
804
+ return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
805
+ }
806
+ parseJson(frame) {
807
+ return JSON.parse(Buffer.from(frame.body).toString("utf8"));
808
+ }
809
+ }
810
+ export function isConsumerReconnectTransient(err) {
811
+ if (err instanceof SocketClosedError || err instanceof SocketTimeoutError)
812
+ return true;
813
+ if (err instanceof SocketWriteNotQueuedError || err instanceof SocketWriteQueuedError)
814
+ return true;
815
+ if (err instanceof SubcCallError)
816
+ return err.kind === "not_sent" || err.kind === "outcome_unknown";
817
+ if (err instanceof SubcError || err instanceof ConnectionFileError || err instanceof AuthError)
818
+ return false;
819
+ const code = errorCode(err);
820
+ return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOENT";
821
+ }
822
+ /**
823
+ * The closed set of route.open rejection codes that mean "the target is
824
+ * momentarily unavailable but the request could succeed on retry" — the target
825
+ * is booting, mid-reload, transiently absent, or the bind relay timed out. A
826
+ * daemon-rejected route.open is provably pre-send (no data frame ever left the
827
+ * client), so these classify as not_sent; the managed path retries them in-place
828
+ * within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (bad_consumer_identity,
829
+ * config_divergence, unknown_target, ...) are excluded — they are pre-send but
830
+ * would never succeed, so retrying them would only storm the daemon. Kept
831
+ * byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
832
+ * classification parity.
833
+ */
834
+ export function isRetryableRouteOpenCode(code) {
835
+ return (code === "unknown_module" ||
836
+ code === "module_reloading" ||
837
+ code === "target_unavailable" ||
838
+ code === "module_timeout");
839
+ }
840
+ export async function connectionFileExists(path) {
841
+ try {
842
+ await fs.access(path);
843
+ return true;
844
+ }
845
+ catch {
846
+ return false;
847
+ }
848
+ }
849
+ function normalizeConnectOptions(opts) {
850
+ return {
851
+ connectionFile: opts.connectionFile,
852
+ handshakeTimeoutMs: opts.handshakeTimeoutMs,
853
+ identity: opts.identity,
854
+ targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
855
+ reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
856
+ sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
857
+ timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
858
+ };
859
+ }
860
+ function routeCacheKey(target, identity, consumerIdentity) {
861
+ const consumerPart = consumerIdentity
862
+ ? `${consumerIdentity.module_id}\0${consumerIdentity.launch_nonce}`
863
+ : "";
864
+ return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}`;
865
+ }
866
+ function routeOpenConsumerIdentity(opts = {}) {
867
+ if (opts.consumerIdentity !== undefined)
868
+ return opts.consumerIdentity ?? undefined;
869
+ const moduleId = process.env[SUBC_MODULE_ID_ENV];
870
+ const launchNonce = process.env[SUBC_LAUNCH_NONCE_ENV];
871
+ if (!moduleId || !launchNonce)
872
+ return undefined;
873
+ return { module_id: moduleId, launch_nonce: launchNonce };
874
+ }
875
+ function errorCode(err) {
876
+ if (typeof err === "object" && err !== null && "code" in err) {
877
+ const code = err.code;
878
+ if (typeof code === "string")
879
+ return code;
880
+ }
881
+ return undefined;
882
+ }
883
+ function causeMessage(cause) {
884
+ if (cause === undefined)
885
+ return "";
886
+ return `: ${cause instanceof Error ? cause.message : String(cause)}`;
887
+ }
888
+ //# sourceMappingURL=client.js.map