@cortexkit/subc-client 0.2.1 → 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/auth.d.ts +19 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +84 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +265 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +888 -0
- package/dist/client.js.map +1 -0
- package/dist/connection-file.d.ts +22 -0
- package/dist/connection-file.d.ts.map +1 -0
- package/dist/connection-file.js +80 -0
- package/dist/connection-file.js.map +1 -0
- package/dist/envelope.d.ts +59 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +132 -0
- package/dist/envelope.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +278 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +640 -0
- package/dist/provider.js.map +1 -0
- package/dist/socket.d.ts +45 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +208 -0
- package/dist/socket.js.map +1 -0
- package/package.json +6 -3
- package/src/client.ts +202 -11
- package/src/socket.ts +7 -0
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
|