@cortexkit/subc-client 0.1.0
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 +89 -0
- package/package.json +30 -0
- package/src/auth.ts +120 -0
- package/src/client.ts +903 -0
- package/src/connection-file.ts +109 -0
- package/src/envelope.ts +168 -0
- package/src/index.ts +92 -0
- package/src/provider.ts +1005 -0
- package/src/socket.ts +239 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
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
|
+
|
|
12
|
+
import { promises as fs } from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { AuthError, authenticateClient } from "./auth.js";
|
|
15
|
+
import { ConnectionFileError, readConnectionFile, type ConnectionInfo } from "./connection-file.js";
|
|
16
|
+
import {
|
|
17
|
+
buildFrame,
|
|
18
|
+
buildFlags,
|
|
19
|
+
decodeHeader,
|
|
20
|
+
encodeFrame,
|
|
21
|
+
FrameType,
|
|
22
|
+
HEADER_LEN,
|
|
23
|
+
Priority,
|
|
24
|
+
type Frame,
|
|
25
|
+
} from "./envelope.js";
|
|
26
|
+
import {
|
|
27
|
+
SocketClosedError,
|
|
28
|
+
SocketTimeoutError,
|
|
29
|
+
SocketWriteNotQueuedError,
|
|
30
|
+
SocketWriteQueuedError,
|
|
31
|
+
SubcSocket,
|
|
32
|
+
} from "./socket.js";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
35
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
36
|
+
// Once a header arrives, its body must follow promptly; bound it so a truncated
|
|
37
|
+
// frame cannot wedge the read loop forever.
|
|
38
|
+
const BODY_READ_TIMEOUT_MS = 30_000;
|
|
39
|
+
const EMPTY_BODY = new Uint8Array(0);
|
|
40
|
+
const DEFAULT_MANAGED_TARGET_KIND: ManagedRouteKind = "management_surface";
|
|
41
|
+
|
|
42
|
+
export interface BindIdentity {
|
|
43
|
+
project_root: string;
|
|
44
|
+
harness: string;
|
|
45
|
+
session: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type RouteTarget =
|
|
49
|
+
| { kind: "tool_provider"; module_id: string }
|
|
50
|
+
| { kind: "management_surface"; module_id: string }
|
|
51
|
+
| { kind: "internal_service"; module_id: string; service_id: string };
|
|
52
|
+
|
|
53
|
+
export type ManagedRouteKind = "management_surface" | "tool_provider";
|
|
54
|
+
|
|
55
|
+
export interface CatalogEntry {
|
|
56
|
+
module_id: string;
|
|
57
|
+
roles: unknown[];
|
|
58
|
+
control_ops: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RequestOptions {
|
|
62
|
+
priority?: Priority;
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
/** Called for each interim PUSH / StreamData frame before the terminal reply. */
|
|
65
|
+
onProgress?: (body: Uint8Array) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ManagedCallOptions extends RequestOptions {
|
|
69
|
+
/** Overrides the per-client identity used for route.open before this call. */
|
|
70
|
+
identity?: BindIdentity;
|
|
71
|
+
/** Defaults to management_surface, matching the store/host management APIs. */
|
|
72
|
+
targetKind?: ManagedRouteKind;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SubscribeOptions {
|
|
76
|
+
priority?: Priority;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CloseRouteOptions {
|
|
80
|
+
/**
|
|
81
|
+
* Await in-flight UNARY requests on the route to settle before tearing it down.
|
|
82
|
+
* Subscriptions are always aborted (a held-open stream cannot be drained).
|
|
83
|
+
* Defaults to false: close immediately, aborting everything in flight.
|
|
84
|
+
*/
|
|
85
|
+
drain?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Capped exponential reconnect backoff. maxAttempts includes the first immediate
|
|
90
|
+
* reconnect attempt; sleeps happen only between failed transient attempts.
|
|
91
|
+
*/
|
|
92
|
+
export interface ReconnectBackoff {
|
|
93
|
+
/** First retry delay. */
|
|
94
|
+
baseMs: number;
|
|
95
|
+
/** Delay ceiling (doubling is capped here). */
|
|
96
|
+
capMs: number;
|
|
97
|
+
/** Max attempts (including the first) before giving up. */
|
|
98
|
+
maxAttempts: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoff = {
|
|
102
|
+
baseMs: 100,
|
|
103
|
+
capMs: 2_000,
|
|
104
|
+
maxAttempts: 6,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type SubcCallErrorKind = "not_sent" | "outcome_unknown" | "terminal";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Managed call failure with send-outcome semantics.
|
|
111
|
+
*
|
|
112
|
+
* `not_sent` is intentionally narrow: the request bytes provably never left the
|
|
113
|
+
* local process (the connection was already closed, or net.Socket.write failed
|
|
114
|
+
* before queuing bytes). Managed call() may retry only this case.
|
|
115
|
+
*
|
|
116
|
+
* `outcome_unknown` is the safe default once bytes have been handed to the local
|
|
117
|
+
* socket. The daemon or module may have received the request before the response
|
|
118
|
+
* was lost, so call() never retries it automatically; the caller must decide
|
|
119
|
+
* whether the operation is idempotent or needs a check-then-act recovery.
|
|
120
|
+
*
|
|
121
|
+
* `terminal` covers protocol Error frames and non-retryable client/setup errors.
|
|
122
|
+
*/
|
|
123
|
+
export class SubcCallError extends Error {
|
|
124
|
+
constructor(
|
|
125
|
+
readonly kind: SubcCallErrorKind,
|
|
126
|
+
message: string,
|
|
127
|
+
readonly code?: string,
|
|
128
|
+
readonly cause?: unknown,
|
|
129
|
+
) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = "SubcCallError";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A live subscription to a provider's event stream, riding a single held-open
|
|
137
|
+
* request. `onEvent` fires for each StreamData frame; `closed` resolves when the
|
|
138
|
+
* provider ends the stream (StreamEnd) and rejects on an Error terminal or a route
|
|
139
|
+
* GOODBYE. `unsubscribe` cancels the held-open request so the provider unwinds.
|
|
140
|
+
*/
|
|
141
|
+
export interface Subscription {
|
|
142
|
+
/** Cancel the subscription: sends Cancel on the held-open request; idempotent. */
|
|
143
|
+
unsubscribe(): void;
|
|
144
|
+
/** Resolves on StreamEnd; rejects on an Error terminal or route close. */
|
|
145
|
+
readonly closed: Promise<void>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class SubcError extends Error {
|
|
149
|
+
constructor(
|
|
150
|
+
message: string,
|
|
151
|
+
readonly code?: string,
|
|
152
|
+
) {
|
|
153
|
+
super(message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface Pending {
|
|
158
|
+
channel: number;
|
|
159
|
+
resolve: (frame: Frame) => void;
|
|
160
|
+
reject: (err: Error) => void;
|
|
161
|
+
onProgress?: (body: Uint8Array) => void;
|
|
162
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
163
|
+
classifyFailure?: (err: Error) => Error;
|
|
164
|
+
/** True for a held-open subscription (never drained — always aborted on close). */
|
|
165
|
+
subscription?: boolean;
|
|
166
|
+
/** Invoked when this pending settles (resolve or reject); used to await drain. */
|
|
167
|
+
onSettle?: () => void;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ConnectOptions {
|
|
171
|
+
connectionFile: string;
|
|
172
|
+
handshakeTimeoutMs?: number;
|
|
173
|
+
/** Default route identity used by managed call(); can be overridden per call. */
|
|
174
|
+
identity?: BindIdentity;
|
|
175
|
+
/** Default route target kind used by managed call(); defaults to management_surface. */
|
|
176
|
+
targetKind?: ManagedRouteKind;
|
|
177
|
+
/** Backoff for managed reconnect after a connection drop. */
|
|
178
|
+
reconnectBackoff?: ReconnectBackoff;
|
|
179
|
+
/** Injectable sleep for timer-free reconnect tests. */
|
|
180
|
+
sleep?: (ms: number) => Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface NormalizedConnectOptions {
|
|
184
|
+
connectionFile: string;
|
|
185
|
+
handshakeTimeoutMs?: number;
|
|
186
|
+
identity?: BindIdentity;
|
|
187
|
+
targetKind: ManagedRouteKind;
|
|
188
|
+
reconnectBackoff: ReconnectBackoff;
|
|
189
|
+
sleep: (ms: number) => Promise<void>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface OpenedConnection {
|
|
193
|
+
sock: SubcSocket;
|
|
194
|
+
conn: ConnectionInfo;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface CachedRoute {
|
|
198
|
+
key: string;
|
|
199
|
+
moduleId: string;
|
|
200
|
+
target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
|
|
201
|
+
identity: BindIdentity;
|
|
202
|
+
channel: number | null;
|
|
203
|
+
generation: number;
|
|
204
|
+
opening: Promise<number> | null;
|
|
205
|
+
/**
|
|
206
|
+
* Tombstone set by closeRoute. An in-flight openCachedRoute holds this exact
|
|
207
|
+
* object across its routeOpen await; if closeRoute flips this while the open is in
|
|
208
|
+
* flight, the open must NOT install its channel (it GOODBYEs the channel it opened
|
|
209
|
+
* and yields RouteClosed) — so a close can never be resurrected by a racing reopen.
|
|
210
|
+
* Not a permanent tombstone: the map entry is deleted, so a later call for the same
|
|
211
|
+
* key creates a fresh object and opens legitimately.
|
|
212
|
+
*/
|
|
213
|
+
closed?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export class SubcClient {
|
|
217
|
+
private nextCorr = 1n;
|
|
218
|
+
private readonly pending = new Map<string, Pending>();
|
|
219
|
+
private readonly routes = new Map<string, CachedRoute>();
|
|
220
|
+
private closedErr: Error | null = null;
|
|
221
|
+
private closeStarted = false;
|
|
222
|
+
private reconnecting: Promise<void> | null = null;
|
|
223
|
+
private generation = 1;
|
|
224
|
+
|
|
225
|
+
private constructor(
|
|
226
|
+
private sock: SubcSocket,
|
|
227
|
+
private currentConn: ConnectionInfo,
|
|
228
|
+
private readonly opts: NormalizedConnectOptions,
|
|
229
|
+
) {
|
|
230
|
+
void this.readLoop(sock, this.generation);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get conn(): ConnectionInfo {
|
|
234
|
+
return this.currentConn;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Read the connection file, connect, authenticate, and start the read loop. */
|
|
238
|
+
static async connect(opts: ConnectOptions): Promise<SubcClient> {
|
|
239
|
+
const normalized = normalizeConnectOptions(opts);
|
|
240
|
+
const opened = await SubcClient.openConnection(normalized);
|
|
241
|
+
return new SubcClient(opened.sock, opened.conn, normalized);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** List modules subc knows about (channel-0 catalog.list). */
|
|
245
|
+
async catalogList(moduleId?: string): Promise<CatalogEntry[]> {
|
|
246
|
+
const body = this.encode(
|
|
247
|
+
moduleId === undefined ? { op: "catalog.list" } : { op: "catalog.list", module_id: moduleId },
|
|
248
|
+
);
|
|
249
|
+
const reply = await this.controlRpc(body);
|
|
250
|
+
const parsed = this.parseJson(reply) as { op: string; modules?: CatalogEntry[] };
|
|
251
|
+
return parsed.modules ?? [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Open a route to a provider (channel-0 route.open); returns the route channel. */
|
|
255
|
+
async routeOpen(target: RouteTarget, identity: BindIdentity): Promise<number> {
|
|
256
|
+
const body = this.encode({ op: "route.open", target, identity });
|
|
257
|
+
const reply = await this.controlRpc(body);
|
|
258
|
+
const parsed = this.parseJson(reply) as { op: string; route_channel?: number };
|
|
259
|
+
if (typeof parsed.route_channel !== "number") {
|
|
260
|
+
throw new SubcError(`route.open returned no route_channel: ${JSON.stringify(parsed)}`);
|
|
261
|
+
}
|
|
262
|
+
return parsed.route_channel;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Send a data-plane request on a route channel and await its terminal reply. */
|
|
266
|
+
async request(routeChannel: number, body: unknown, opts: RequestOptions = {}): Promise<unknown> {
|
|
267
|
+
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
268
|
+
const priority = opts.priority ?? Priority.Interactive;
|
|
269
|
+
const reply = await this.send(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
|
|
270
|
+
return this.parseJson(reply);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Managed route + request convenience. Opens and caches a route for the module,
|
|
275
|
+
* reconnecting and re-opening cached routes after connection drops.
|
|
276
|
+
*/
|
|
277
|
+
async call<Response = unknown>(
|
|
278
|
+
moduleId: string,
|
|
279
|
+
method: string,
|
|
280
|
+
params?: unknown,
|
|
281
|
+
opts: ManagedCallOptions = {},
|
|
282
|
+
): Promise<Response> {
|
|
283
|
+
const body = params === undefined ? { method } : { method, params };
|
|
284
|
+
|
|
285
|
+
for (;;) {
|
|
286
|
+
const routeChannel = await this.cachedRouteChannel(moduleId, opts);
|
|
287
|
+
try {
|
|
288
|
+
return (await this.managedRequest(routeChannel, body, opts)) as Response;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
|
|
291
|
+
if (err.kind === "not_sent") {
|
|
292
|
+
try {
|
|
293
|
+
await this.reconnectAfterDrop(err);
|
|
294
|
+
} catch (reconnectErr) {
|
|
295
|
+
throw this.notSentRecoveryError("managed call was not sent", reconnectErr);
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (err.kind === "outcome_unknown") {
|
|
300
|
+
this.scheduleReconnectAfterDrop(err);
|
|
301
|
+
}
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Open a held-open event subscription on a route channel. Sends one Request the
|
|
309
|
+
* provider keeps open, delivering each interim StreamData frame to `onEvent`; the
|
|
310
|
+
* returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
|
|
311
|
+
* GOODBYE (reject). Events ride this held-open request's correlation id — they are
|
|
312
|
+
* never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
|
|
313
|
+
*/
|
|
314
|
+
subscribe(
|
|
315
|
+
routeChannel: number,
|
|
316
|
+
body: unknown,
|
|
317
|
+
onEvent: (event: Uint8Array) => void,
|
|
318
|
+
opts: SubscribeOptions = {},
|
|
319
|
+
): Subscription {
|
|
320
|
+
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
321
|
+
const priority = opts.priority ?? Priority.Interactive;
|
|
322
|
+
const corr = this.nextCorr++;
|
|
323
|
+
const key = `${routeChannel}:${corr}`;
|
|
324
|
+
|
|
325
|
+
const closed = new Promise<void>((resolve, reject) => {
|
|
326
|
+
if (this.closedErr) {
|
|
327
|
+
reject(this.closedErr);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// No timeout: a subscription stays open indefinitely until StreamEnd, Error,
|
|
331
|
+
// route GOODBYE, or unsubscribe.
|
|
332
|
+
this.pending.set(key, {
|
|
333
|
+
channel: routeChannel,
|
|
334
|
+
resolve: () => resolve(),
|
|
335
|
+
reject,
|
|
336
|
+
onProgress: onEvent,
|
|
337
|
+
timer: null,
|
|
338
|
+
subscription: true,
|
|
339
|
+
});
|
|
340
|
+
const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), routeChannel, corr, bytes);
|
|
341
|
+
this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
|
|
342
|
+
const p = this.pending.get(key);
|
|
343
|
+
if (p) this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
let cancelled = false;
|
|
348
|
+
const unsubscribe = (): void => {
|
|
349
|
+
if (cancelled) return;
|
|
350
|
+
cancelled = true;
|
|
351
|
+
// Pure-header Cancel on the held-open (channel, corr): the provider aborts its
|
|
352
|
+
// handler and ends with StreamEnd, which settles `closed`.
|
|
353
|
+
const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
|
|
354
|
+
this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
|
|
355
|
+
// Best-effort: if the socket is already gone, the read loop fails the
|
|
356
|
+
// pending waiter and `closed` rejects on its own.
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return { unsubscribe, closed };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Tear down ONE managed route (a route opened via `call()`), keyed by its
|
|
365
|
+
* (target, identity). Idempotent and never throws — callers over-call on
|
|
366
|
+
* session-end. The teardown:
|
|
367
|
+
* - flips a tombstone on the cached route and removes it from the cache, so an
|
|
368
|
+
* in-flight `openCachedRoute` for the same key will NOT install its channel
|
|
369
|
+
* (the generation guard: close beats a racing reopen), and a later `call()`
|
|
370
|
+
* opens a fresh route (this is NOT a permanent tombstone);
|
|
371
|
+
* - settles in-flight requests on the channel as RouteClosed (managed requests
|
|
372
|
+
* keep their at-most-once classification: outcome_unknown if already sent,
|
|
373
|
+
* not_sent otherwise; subscriptions always abort);
|
|
374
|
+
* - sends a best-effort route GOODBYE so subc releases the route and notifies
|
|
375
|
+
* the module to free per-session resources.
|
|
376
|
+
* `opts.drain` waits for in-flight UNARY requests to settle before tearing down.
|
|
377
|
+
*/
|
|
378
|
+
async closeRoute(
|
|
379
|
+
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
380
|
+
identity: BindIdentity,
|
|
381
|
+
opts: CloseRouteOptions = {},
|
|
382
|
+
): Promise<void> {
|
|
383
|
+
const key = routeCacheKey(target, identity);
|
|
384
|
+
const cached = this.routes.get(key);
|
|
385
|
+
if (!cached) return; // never opened / already closed — idempotent no-op.
|
|
386
|
+
// Generation guard: an in-flight openCachedRoute holds this same object and
|
|
387
|
+
// re-checks `closed` before installing its channel, so flipping it here makes
|
|
388
|
+
// close win over a racing reopen. Removing the map entry lets a later call()
|
|
389
|
+
// create a fresh route for the key (not a permanent tombstone).
|
|
390
|
+
cached.closed = true;
|
|
391
|
+
this.routes.delete(key);
|
|
392
|
+
const channel = cached.channel;
|
|
393
|
+
cached.channel = null;
|
|
394
|
+
// channel === null means the route was still opening (no channel installed yet);
|
|
395
|
+
// the racing open will see closed=true and GOODBYE whatever it opens, so there is
|
|
396
|
+
// nothing local to tear down here.
|
|
397
|
+
if (channel !== null) await this.closeRouteChannel(channel, opts);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Tear down ONE route by its channel number — the primitive for callers that
|
|
402
|
+
* opened a route with `routeOpen` directly (e.g. a tool route carrying raw
|
|
403
|
+
* {name, arguments}) and hold the channel themselves. Idempotent, never throws.
|
|
404
|
+
* Settles in-flight requests on the channel as RouteClosed and sends a best-effort
|
|
405
|
+
* route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
|
|
406
|
+
* are always aborted (a held-open stream cannot be drained).
|
|
407
|
+
*/
|
|
408
|
+
async closeRouteChannel(channel: number, opts: CloseRouteOptions = {}): Promise<void> {
|
|
409
|
+
if (channel === 0) return; // channel 0 is the control plane, never a route.
|
|
410
|
+
if (opts.drain) {
|
|
411
|
+
// Wait only for in-flight UNARY requests on this channel; subscriptions are
|
|
412
|
+
// aborted below (a held-open stream has no natural completion to drain to).
|
|
413
|
+
await this.drainUnaryOnChannel(channel);
|
|
414
|
+
}
|
|
415
|
+
// Settle anything still in flight on the channel (all of it in abort mode; only
|
|
416
|
+
// subscriptions + late stragglers after a drain). Managed requests are classified
|
|
417
|
+
// at-most-once via their classifyFailure; raw requests/subscriptions get a plain
|
|
418
|
+
// RouteClosed error.
|
|
419
|
+
this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
|
|
420
|
+
// Best-effort GOODBYE: releases the route on the daemon and notifies the module.
|
|
421
|
+
this.sendRouteGoodbye(channel);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
close(): void {
|
|
425
|
+
this.closeStarted = true;
|
|
426
|
+
this.fail(new SubcError("client closed"));
|
|
427
|
+
this.sock.close();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Resolve once every in-flight UNARY request on the channel (snapshot at call
|
|
431
|
+
* time) has settled. Subscriptions are excluded — they are aborted, not drained. */
|
|
432
|
+
private drainUnaryOnChannel(channel: number): Promise<void> {
|
|
433
|
+
const waiters: Promise<void>[] = [];
|
|
434
|
+
for (const pending of this.pending.values()) {
|
|
435
|
+
if (pending.channel === channel && !pending.subscription) {
|
|
436
|
+
waiters.push(
|
|
437
|
+
new Promise<void>((resolve) => {
|
|
438
|
+
const prev = pending.onSettle;
|
|
439
|
+
pending.onSettle = () => {
|
|
440
|
+
prev?.();
|
|
441
|
+
resolve();
|
|
442
|
+
};
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return Promise.all(waiters).then(() => undefined);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Send a best-effort header-only route GOODBYE for `channel`. One-way: the daemon
|
|
451
|
+
* releases the route and relays a route-gone GOODBYE to the module; no ack. */
|
|
452
|
+
private sendRouteGoodbye(channel: number): void {
|
|
453
|
+
if (this.closedErr) return; // connection already gone — the route died with it.
|
|
454
|
+
const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), channel, 0n, EMPTY_BODY);
|
|
455
|
+
this.sock.write(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
|
|
456
|
+
// Best-effort: if the socket is already gone, the route is torn down anyway.
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private static async openConnection(opts: NormalizedConnectOptions): Promise<OpenedConnection> {
|
|
461
|
+
const conn = await readConnectionFile(opts.connectionFile);
|
|
462
|
+
const deadline = Date.now() + (opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);
|
|
463
|
+
const endpoint = conn.endpoints[0]!;
|
|
464
|
+
const sock = await SubcSocket.connect(endpoint.host, endpoint.port, deadline);
|
|
465
|
+
try {
|
|
466
|
+
await authenticateClient(sock, conn, deadline);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
sock.close();
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
return { sock, conn };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private async controlRpc(body: Uint8Array): Promise<Frame> {
|
|
475
|
+
// Match the canonical probe: control requests go out Interactive on channel 0.
|
|
476
|
+
return this.send(0, body, Priority.Interactive, undefined, undefined);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private send(
|
|
480
|
+
channel: number,
|
|
481
|
+
body: Uint8Array,
|
|
482
|
+
priority: Priority,
|
|
483
|
+
timeoutMs: number | undefined,
|
|
484
|
+
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
485
|
+
): Promise<Frame> {
|
|
486
|
+
if (this.closedErr) return Promise.reject(this.closedErr);
|
|
487
|
+
const corr = this.nextCorr++;
|
|
488
|
+
const key = `${channel}:${corr}`;
|
|
489
|
+
const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
|
|
490
|
+
|
|
491
|
+
return new Promise<Frame>((resolve, reject) => {
|
|
492
|
+
const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
493
|
+
const pending: Pending = {
|
|
494
|
+
channel,
|
|
495
|
+
resolve,
|
|
496
|
+
reject,
|
|
497
|
+
onProgress,
|
|
498
|
+
timer: null,
|
|
499
|
+
};
|
|
500
|
+
pending.timer = setTimeout(() => {
|
|
501
|
+
this.rejectPending(key, pending, new SubcError(`request on channel ${channel} timed out after ${ms}ms`));
|
|
502
|
+
}, ms);
|
|
503
|
+
this.pending.set(key, pending);
|
|
504
|
+
this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
|
|
505
|
+
const p = this.pending.get(key);
|
|
506
|
+
if (p) this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async managedRequest(
|
|
512
|
+
routeChannel: number,
|
|
513
|
+
body: unknown,
|
|
514
|
+
opts: ManagedCallOptions,
|
|
515
|
+
): Promise<unknown> {
|
|
516
|
+
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
517
|
+
const priority = opts.priority ?? Priority.Interactive;
|
|
518
|
+
try {
|
|
519
|
+
const reply = await this.sendManaged(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
|
|
520
|
+
return this.parseJson(reply);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (err instanceof SubcCallError) throw err;
|
|
523
|
+
throw this.terminalCallError("managed call failed", err);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private sendManaged(
|
|
528
|
+
channel: number,
|
|
529
|
+
body: Uint8Array,
|
|
530
|
+
priority: Priority,
|
|
531
|
+
timeoutMs: number | undefined,
|
|
532
|
+
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
533
|
+
): Promise<Frame> {
|
|
534
|
+
if (this.closedErr) {
|
|
535
|
+
return Promise.reject(this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const corr = this.nextCorr++;
|
|
539
|
+
const key = `${channel}:${corr}`;
|
|
540
|
+
const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
|
|
541
|
+
let handedToSocket = false;
|
|
542
|
+
|
|
543
|
+
const classifyFailure = (err: Error): SubcCallError => {
|
|
544
|
+
// This is the load-bearing asymmetry: only the pre-write paths are NotSent.
|
|
545
|
+
// As soon as writeTracked reports that bytes were queued to Node's socket,
|
|
546
|
+
// those bytes may already be in the OS buffer or at the daemon. Any later
|
|
547
|
+
// close, write callback error, route GOODBYE, or timeout before a response is
|
|
548
|
+
// therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
|
|
549
|
+
if (!handedToSocket) {
|
|
550
|
+
return this.notSentCallError("request bytes were not queued to the subc socket", err);
|
|
551
|
+
}
|
|
552
|
+
return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", err);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
return new Promise<Frame>((resolve, reject) => {
|
|
556
|
+
const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
557
|
+
const pending: Pending = {
|
|
558
|
+
channel,
|
|
559
|
+
resolve,
|
|
560
|
+
reject,
|
|
561
|
+
onProgress,
|
|
562
|
+
timer: null,
|
|
563
|
+
classifyFailure,
|
|
564
|
+
};
|
|
565
|
+
pending.timer = setTimeout(() => {
|
|
566
|
+
this.rejectPending(key, pending, new SubcError(`request on channel ${channel} timed out after ${ms}ms`));
|
|
567
|
+
}, ms);
|
|
568
|
+
this.pending.set(key, pending);
|
|
569
|
+
|
|
570
|
+
const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
|
|
571
|
+
handedToSocket = write.queued;
|
|
572
|
+
write.completed.catch((err) => {
|
|
573
|
+
const p = this.pending.get(key);
|
|
574
|
+
if (p) this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private async cachedRouteChannel(moduleId: string, opts: ManagedCallOptions): Promise<number> {
|
|
580
|
+
const identity = opts.identity ?? this.opts.identity;
|
|
581
|
+
if (!identity) {
|
|
582
|
+
throw new SubcCallError(
|
|
583
|
+
"terminal",
|
|
584
|
+
"managed call requires a BindIdentity in SubcClient.connect({ identity }) or call(..., { identity })",
|
|
585
|
+
"missing_identity",
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const target = { kind: opts.targetKind ?? this.opts.targetKind, module_id: moduleId } as Extract<
|
|
590
|
+
RouteTarget,
|
|
591
|
+
{ kind: ManagedRouteKind }
|
|
592
|
+
>;
|
|
593
|
+
const key = routeCacheKey(target, identity);
|
|
594
|
+
let cached = this.routes.get(key);
|
|
595
|
+
if (!cached) {
|
|
596
|
+
cached = {
|
|
597
|
+
key,
|
|
598
|
+
moduleId,
|
|
599
|
+
target,
|
|
600
|
+
identity,
|
|
601
|
+
channel: null,
|
|
602
|
+
generation: 0,
|
|
603
|
+
opening: null,
|
|
604
|
+
};
|
|
605
|
+
this.routes.set(key, cached);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
|
|
609
|
+
return cached.channel;
|
|
610
|
+
}
|
|
611
|
+
if (!cached.opening) {
|
|
612
|
+
cached.opening = this.openCachedRoute(cached).finally(() => {
|
|
613
|
+
cached.opening = null;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return cached.opening;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private async openCachedRoute(cached: CachedRoute): Promise<number> {
|
|
620
|
+
for (;;) {
|
|
621
|
+
if (cached.closed) throw this.routeClosedDuringOpen();
|
|
622
|
+
try {
|
|
623
|
+
await this.ensureConnectedForManaged();
|
|
624
|
+
} catch (err) {
|
|
625
|
+
throw this.notSentRecoveryError("route.open could not run because reconnect failed", err);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
|
|
629
|
+
return cached.channel;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const channel = await this.routeOpen(cached.target, cached.identity);
|
|
634
|
+
// Generation guard: a closeRoute may have flipped the tombstone WHILE this
|
|
635
|
+
// route.open was in flight. If so, close wins — do NOT install the channel
|
|
636
|
+
// into the (already-removed) cache entry; GOODBYE the channel we just opened
|
|
637
|
+
// so the daemon/module don't leak it, and fail as RouteClosed.
|
|
638
|
+
if (cached.closed) {
|
|
639
|
+
this.sendRouteGoodbye(channel);
|
|
640
|
+
throw this.routeClosedDuringOpen();
|
|
641
|
+
}
|
|
642
|
+
cached.channel = channel;
|
|
643
|
+
cached.generation = this.generation;
|
|
644
|
+
return channel;
|
|
645
|
+
} catch (err) {
|
|
646
|
+
if (err instanceof SubcCallError && err.code === "route_closed") throw err;
|
|
647
|
+
if (!this.closeStarted && isConsumerReconnectTransient(err)) {
|
|
648
|
+
try {
|
|
649
|
+
await this.reconnectAfterDrop(err);
|
|
650
|
+
} catch (reconnectErr) {
|
|
651
|
+
throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectErr);
|
|
652
|
+
}
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async ensureConnectedForManaged(): Promise<void> {
|
|
661
|
+
if (this.closeStarted) throw new SubcError("client closed");
|
|
662
|
+
if (this.reconnecting) await this.reconnecting;
|
|
663
|
+
if (this.closedErr) await this.reconnectAfterDrop(this.closedErr);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private scheduleReconnectAfterDrop(err: unknown): void {
|
|
667
|
+
if (this.closeStarted || this.reconnecting) return;
|
|
668
|
+
void this.reconnectAfterDrop(err).catch(() => {
|
|
669
|
+
// The originating call keeps its OutcomeUnknown classification. A later call
|
|
670
|
+
// will retry reconnect with the same closed connection state if this attempt
|
|
671
|
+
// cannot restore the daemon yet.
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private reconnectAfterDrop(trigger: unknown): Promise<void> {
|
|
676
|
+
if (this.closeStarted) return Promise.reject(new SubcError("client closed"));
|
|
677
|
+
if (this.reconnecting) return this.reconnecting;
|
|
678
|
+
|
|
679
|
+
const promise = this.reconnectWithRetry(trigger).finally(() => {
|
|
680
|
+
if (this.reconnecting === promise) this.reconnecting = null;
|
|
681
|
+
});
|
|
682
|
+
this.reconnecting = promise;
|
|
683
|
+
return promise;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private async reconnectWithRetry(_trigger: unknown): Promise<void> {
|
|
687
|
+
let attempt = 0;
|
|
688
|
+
let delay = this.opts.reconnectBackoff.baseMs;
|
|
689
|
+
|
|
690
|
+
for (;;) {
|
|
691
|
+
if (this.closeStarted) throw new SubcError("client closed");
|
|
692
|
+
attempt += 1;
|
|
693
|
+
try {
|
|
694
|
+
const opened = await SubcClient.openConnection(this.opts);
|
|
695
|
+
if (this.closeStarted) {
|
|
696
|
+
opened.sock.close();
|
|
697
|
+
throw new SubcError("client closed");
|
|
698
|
+
}
|
|
699
|
+
this.replaceConnection(opened);
|
|
700
|
+
await this.reopenCachedRoutes();
|
|
701
|
+
return;
|
|
702
|
+
} catch (err) {
|
|
703
|
+
if (!isConsumerReconnectTransient(err) || attempt >= this.opts.reconnectBackoff.maxAttempts) {
|
|
704
|
+
throw err;
|
|
705
|
+
}
|
|
706
|
+
await this.opts.sleep(delay);
|
|
707
|
+
delay = Math.min(delay * 2, this.opts.reconnectBackoff.capMs);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private replaceConnection(opened: OpenedConnection): void {
|
|
713
|
+
this.sock.close();
|
|
714
|
+
this.sock = opened.sock;
|
|
715
|
+
this.currentConn = opened.conn;
|
|
716
|
+
this.closedErr = null;
|
|
717
|
+
this.generation += 1;
|
|
718
|
+
void this.readLoop(opened.sock, this.generation);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private async reopenCachedRoutes(): Promise<void> {
|
|
722
|
+
for (const cached of this.routes.values()) {
|
|
723
|
+
cached.channel = null;
|
|
724
|
+
cached.generation = 0;
|
|
725
|
+
}
|
|
726
|
+
for (const cached of this.routes.values()) {
|
|
727
|
+
if (cached.closed) continue; // closed concurrently with reconnect — don't reopen.
|
|
728
|
+
const channel = await this.routeOpen(cached.target, cached.identity);
|
|
729
|
+
// A closeRoute may have raced this reopen (flipping the tombstone during the
|
|
730
|
+
// route.open await). If so, GOODBYE the channel instead of installing it, so the
|
|
731
|
+
// closed route isn't silently re-established on the new connection.
|
|
732
|
+
if (cached.closed) {
|
|
733
|
+
this.sendRouteGoodbye(channel);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
cached.channel = channel;
|
|
737
|
+
cached.generation = this.generation;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private routeClosedDuringOpen(): SubcCallError {
|
|
742
|
+
return new SubcCallError("not_sent", "route was closed before route.open completed", "route_closed");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
746
|
+
try {
|
|
747
|
+
for (;;) {
|
|
748
|
+
// Header read waits indefinitely — idle time between frames is normal.
|
|
749
|
+
const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
|
|
750
|
+
const header = decodeHeader(headerBytes);
|
|
751
|
+
const body =
|
|
752
|
+
header.len === 0
|
|
753
|
+
? new Uint8Array(0)
|
|
754
|
+
: await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
|
|
755
|
+
this.dispatch({ header, body });
|
|
756
|
+
}
|
|
757
|
+
} catch (err) {
|
|
758
|
+
if (this.sock === sock && this.generation === generation) {
|
|
759
|
+
this.fail(err instanceof Error ? err : new SubcError(String(err)));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private dispatch(frame: Frame): void {
|
|
765
|
+
const key = `${frame.header.channel}:${frame.header.corr}`;
|
|
766
|
+
const pending = this.pending.get(key);
|
|
767
|
+
if (pending) {
|
|
768
|
+
switch (frame.header.ty) {
|
|
769
|
+
case FrameType.Push:
|
|
770
|
+
case FrameType.StreamData:
|
|
771
|
+
pending.onProgress?.(frame.body);
|
|
772
|
+
return;
|
|
773
|
+
case FrameType.Response:
|
|
774
|
+
case FrameType.StreamEnd:
|
|
775
|
+
this.settle(key, pending, () => pending.resolve(frame));
|
|
776
|
+
return;
|
|
777
|
+
case FrameType.Error:
|
|
778
|
+
this.settle(key, pending, () => pending.reject(this.errorFromFrame(frame)));
|
|
779
|
+
return;
|
|
780
|
+
default:
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (frame.header.ty === FrameType.Goodbye) {
|
|
785
|
+
this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
|
|
789
|
+
// unsolicited-push consumers.
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private settle(key: string, pending: Pending, run: () => void): void {
|
|
793
|
+
this.pending.delete(key);
|
|
794
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
795
|
+
run();
|
|
796
|
+
pending.onSettle?.();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private rejectPending(key: string, pending: Pending, err: Error): void {
|
|
800
|
+
this.settle(key, pending, () => pending.reject(pending.classifyFailure?.(err) ?? err));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private errorFromFrame(frame: Frame): SubcError {
|
|
804
|
+
try {
|
|
805
|
+
const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8")) as {
|
|
806
|
+
code?: string;
|
|
807
|
+
message?: string;
|
|
808
|
+
};
|
|
809
|
+
return new SubcError(parsed.message ?? "subc error", parsed.code);
|
|
810
|
+
} catch {
|
|
811
|
+
return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private failChannel(channel: number, err: Error): void {
|
|
816
|
+
for (const [key, pending] of this.pending) {
|
|
817
|
+
if (pending.channel === channel) {
|
|
818
|
+
this.rejectPending(key, pending, err);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private fail(err: Error): void {
|
|
824
|
+
if (!this.closedErr) this.closedErr = err;
|
|
825
|
+
for (const [key, pending] of this.pending) {
|
|
826
|
+
this.rejectPending(key, pending, err);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private notSentCallError(message: string, cause?: unknown): SubcCallError {
|
|
831
|
+
return new SubcCallError("not_sent", `${message}${causeMessage(cause)}`, errorCode(cause), cause);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private outcomeUnknownCallError(message: string, cause?: unknown): SubcCallError {
|
|
835
|
+
return new SubcCallError("outcome_unknown", `${message}${causeMessage(cause)}`, errorCode(cause), cause);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private terminalCallError(message: string, cause?: unknown): SubcCallError {
|
|
839
|
+
if (cause instanceof SubcCallError) return cause;
|
|
840
|
+
return new SubcCallError("terminal", `${message}${causeMessage(cause)}`, errorCode(cause), cause);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private notSentRecoveryError(message: string, cause?: unknown): SubcCallError {
|
|
844
|
+
if (cause instanceof SubcCallError) return cause;
|
|
845
|
+
if (isConsumerReconnectTransient(cause)) return this.notSentCallError(message, cause);
|
|
846
|
+
return this.terminalCallError(message, cause);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private encode(value: unknown): Uint8Array {
|
|
850
|
+
return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private parseJson(frame: Frame): unknown {
|
|
854
|
+
return JSON.parse(Buffer.from(frame.body).toString("utf8"));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export function isConsumerReconnectTransient(err: unknown): boolean {
|
|
859
|
+
if (err instanceof SocketClosedError || err instanceof SocketTimeoutError) return true;
|
|
860
|
+
if (err instanceof SocketWriteNotQueuedError || err instanceof SocketWriteQueuedError) return true;
|
|
861
|
+
if (err instanceof SubcCallError) return err.kind === "not_sent" || err.kind === "outcome_unknown";
|
|
862
|
+
if (err instanceof SubcError || err instanceof ConnectionFileError || err instanceof AuthError) return false;
|
|
863
|
+
|
|
864
|
+
const code = errorCode(err);
|
|
865
|
+
return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOENT";
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export async function connectionFileExists(path: string): Promise<boolean> {
|
|
869
|
+
try {
|
|
870
|
+
await fs.access(path);
|
|
871
|
+
return true;
|
|
872
|
+
} catch {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions {
|
|
878
|
+
return {
|
|
879
|
+
connectionFile: opts.connectionFile,
|
|
880
|
+
handshakeTimeoutMs: opts.handshakeTimeoutMs,
|
|
881
|
+
identity: opts.identity,
|
|
882
|
+
targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
|
|
883
|
+
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
884
|
+
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function routeCacheKey(target: Extract<RouteTarget, { kind: ManagedRouteKind }>, identity: BindIdentity): string {
|
|
889
|
+
return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function errorCode(err: unknown): string | undefined {
|
|
893
|
+
if (typeof err === "object" && err !== null && "code" in err) {
|
|
894
|
+
const code = (err as { code?: unknown }).code;
|
|
895
|
+
if (typeof code === "string") return code;
|
|
896
|
+
}
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function causeMessage(cause: unknown): string {
|
|
901
|
+
if (cause === undefined) return "";
|
|
902
|
+
return `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
903
|
+
}
|