@cortexkit/subc-client 0.3.4 → 0.4.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/dist/client.js CHANGED
@@ -12,7 +12,8 @@ import { promises as fs } from "node:fs";
12
12
  import { debuglog } from "node:util";
13
13
  import { AuthError, authenticateClient } from "./auth.js";
14
14
  import { ConnectionFileError, readConnectionFile } from "./connection-file.js";
15
- import { buildFrame, buildFlags, decodeHeader, encodeFrame, FrameType, HEADER_LEN, Priority, } from "./envelope.js";
15
+ import { AdmissionClass, buildFrame, buildFlags, encodeFrame, FrameType, Priority, } from "./envelope.js";
16
+ import { belongsToConnection, createRouteHandle, newConnectionToken, sameRouteHandle, StaleRouteHandleError, } from "./route-handle.js";
16
17
  import { SocketClosedError, SocketTimeoutError, SocketWriteNotQueuedError, SocketWriteQueuedError, SubcSocket, } from "./socket.js";
17
18
  const debug = debuglog("subc-client");
18
19
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
@@ -101,7 +102,11 @@ export class SubcClient {
101
102
  opts;
102
103
  nextCorr = 1n;
103
104
  pending = new Map();
105
+ lateResponses = new Map();
104
106
  routes = new Map();
107
+ liveRoutes = new Map();
108
+ connectionToken = newConnectionToken();
109
+ ingressEpochDropCount = 0;
105
110
  closedErr = null;
106
111
  closeStarted = false;
107
112
  reconnecting = null;
@@ -133,27 +138,56 @@ export class SubcClient {
133
138
  const parsed = this.parseJson(reply);
134
139
  return parsed.modules ?? [];
135
140
  }
136
- /** Open a route to a provider (channel-0 route.open); returns the route channel. */
141
+ /** Open a route and return its connection-bound immutable handle. */
137
142
  async routeOpen(target, identity, opts = {}) {
138
143
  const consumerIdentity = routeOpenConsumerIdentity(opts);
144
+ const consumerCapabilities = opts.consumerCapabilities;
139
145
  const body = this.encode({
140
146
  op: "route.open",
141
147
  target,
142
148
  identity,
143
149
  ...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
150
+ ...(consumerCapabilities !== undefined ? { consumer_capabilities: consumerCapabilities } : {}),
144
151
  });
145
- const reply = await this.controlRpc(body);
146
- const parsed = this.parseJson(reply);
147
- if (typeof parsed.route_channel !== "number") {
148
- throw new SubcError(`route.open returned no route_channel: ${JSON.stringify(parsed)}`);
149
- }
150
- return parsed.route_channel;
151
- }
152
- /** Send a data-plane request on a route channel and await its terminal reply. */
153
- async request(routeChannel, body, opts = {}) {
152
+ let installed = null;
153
+ const install = (frame) => {
154
+ if (frame.header.ty !== FrameType.Response)
155
+ return true;
156
+ const parsed = this.parseJson(frame);
157
+ if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") {
158
+ throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
159
+ }
160
+ installed = this.installRoute(parsed.route_channel, parsed.route_epoch);
161
+ return true;
162
+ };
163
+ const closeLateRoute = (frame) => {
164
+ if (frame.header.ty !== FrameType.Response)
165
+ return;
166
+ try {
167
+ const parsed = this.parseJson(frame);
168
+ if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number")
169
+ return;
170
+ const lateHandle = this.installRoute(parsed.route_channel, parsed.route_epoch);
171
+ this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed"));
172
+ this.liveRoutes.delete(lateHandle.channel);
173
+ this.sendRouteGoodbye(lateHandle, true);
174
+ }
175
+ catch {
176
+ this.closeConnectionAfterCleanupFailure();
177
+ }
178
+ };
179
+ await this.controlRpc(body, install, closeLateRoute);
180
+ if (!installed)
181
+ throw new SubcError("route.open response was not installed");
182
+ return installed;
183
+ }
184
+ /** Send a data-plane request on exactly the supplied route generation. */
185
+ async request(handle, body, opts = {}) {
186
+ this.assertLiveHandle(handle);
154
187
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
155
188
  const priority = opts.priority ?? Priority.Interactive;
156
- const reply = await this.send(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
189
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
190
+ const reply = await this.send(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
157
191
  return this.parseJson(reply);
158
192
  }
159
193
  /**
@@ -164,9 +198,9 @@ export class SubcClient {
164
198
  const body = params === undefined ? { method } : { method, params };
165
199
  let retriedUnknownChannel = false;
166
200
  for (;;) {
167
- const routeChannel = await this.cachedRouteChannel(moduleId, opts);
201
+ const routeHandle = await this.cachedRouteHandle(moduleId, opts);
168
202
  try {
169
- return (await this.managedRequest(routeChannel, body, opts));
203
+ return (await this.managedRequest(routeHandle, body, opts));
170
204
  }
171
205
  catch (err) {
172
206
  if (!(err instanceof SubcCallError))
@@ -178,7 +212,7 @@ export class SubcClient {
178
212
  // re-opens the route instead of resending into the same dead channel.
179
213
  if (err.code === "unknown_channel" && !retriedUnknownChannel && !this.closeStarted) {
180
214
  retriedUnknownChannel = true;
181
- this.evictRouteChannel(routeChannel);
215
+ this.evictRouteHandle(routeHandle);
182
216
  continue;
183
217
  }
184
218
  if (err.kind === "not_sent") {
@@ -201,38 +235,32 @@ export class SubcClient {
201
235
  }
202
236
  }
203
237
  }
204
- /**
205
- * Open a held-open event subscription on a route channel. Sends one Request the
206
- * provider keeps open, delivering each interim StreamData frame to `onEvent`; the
207
- * returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
208
- * GOODBYE (reject). Events ride this held-open request's correlation id — they are
209
- * never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
210
- */
211
- subscribe(routeChannel, body, onEvent, opts = {}) {
238
+ /** Open a held request on exactly one route generation. */
239
+ subscribe(handle, body, onEvent, opts = {}) {
240
+ this.assertLiveHandle(handle);
212
241
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
213
242
  const priority = opts.priority ?? Priority.Interactive;
214
- const corr = this.nextCorr++;
215
- const key = `${routeChannel}:${corr}`;
243
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
244
+ const corr = this.allocateCorr();
245
+ const key = pendingKey(handle, corr);
216
246
  const closed = new Promise((resolve, reject) => {
217
247
  if (this.closedErr) {
218
248
  reject(this.closedErr);
219
249
  return;
220
250
  }
221
- // No timeout: a subscription stays open indefinitely until StreamEnd, Error,
222
- // route GOODBYE, or unsubscribe.
223
251
  this.pending.set(key, {
224
- channel: routeChannel,
252
+ handle,
225
253
  resolve: () => resolve(),
226
254
  reject,
227
255
  onProgress: onEvent,
228
256
  timer: null,
229
257
  subscription: true,
230
258
  });
231
- const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), routeChannel, corr, bytes);
259
+ const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false, admission), handle.channel, handle.epoch, corr, bytes);
232
260
  this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
233
- const p = this.pending.get(key);
234
- if (p)
235
- this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
261
+ const pending = this.pending.get(key);
262
+ if (pending)
263
+ this.rejectPending(key, pending, err instanceof Error ? err : new SubcError(String(err)));
236
264
  });
237
265
  });
238
266
  let cancelled = false;
@@ -240,89 +268,80 @@ export class SubcClient {
240
268
  if (cancelled)
241
269
  return;
242
270
  cancelled = true;
243
- // Pure-header Cancel on the held-open (channel, corr): the provider aborts its
244
- // handler and ends with StreamEnd, which settles `closed`.
245
- const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
246
- this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
247
- // Best-effort: if the socket is already gone, the read loop fails the
248
- // pending waiter and `closed` rejects on its own.
249
- });
271
+ this.cancel(handle, corr, priority);
250
272
  };
251
273
  return { unsubscribe, closed };
252
274
  }
253
- /**
254
- * Tear down ONE managed route (a route opened via `call()`), keyed by its
255
- * (target, identity). Idempotent and never throws — callers over-call on
256
- * session-end. The teardown:
257
- * - flips a tombstone on the cached route and removes it from the cache, so an
258
- * in-flight `openCachedRoute` for the same key will NOT install its channel
259
- * (the generation guard: close beats a racing reopen), and a later `call()`
260
- * opens a fresh route (this is NOT a permanent tombstone);
261
- * - settles in-flight requests on the channel as RouteClosed (managed requests
262
- * keep their at-most-once classification: outcome_unknown if already sent,
263
- * not_sent otherwise; subscriptions always abort);
264
- * - sends a best-effort route GOODBYE so subc releases the route and notifies
265
- * the module to free per-session resources.
266
- * `opts.drain` waits for in-flight UNARY requests to settle before tearing down.
267
- */
268
- async closeRoute(target, identity, opts = {}) {
275
+ /** Send a pure-header cancellation for an in-flight request. */
276
+ cancel(handle, corr, priority = Priority.Interactive) {
277
+ this.assertLiveHandle(handle);
278
+ const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), handle.channel, handle.epoch, corr, EMPTY_BODY);
279
+ this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => undefined);
280
+ }
281
+ /** Poll status or liveness for exactly the supplied route generation. */
282
+ async routePoll(handle, kind) {
283
+ this.assertLiveHandle(handle);
284
+ const body = this.encode({
285
+ op: "route.poll",
286
+ route_channel: handle.channel,
287
+ route_epoch: handle.epoch,
288
+ kind,
289
+ });
290
+ const reply = await this.controlRpc(body, (frame) => {
291
+ if (frame.header.ty !== FrameType.Response)
292
+ return true;
293
+ const parsed = this.parseJson(frame);
294
+ return parsed.route_channel === handle.channel && parsed.route_epoch === handle.epoch;
295
+ });
296
+ return this.parseJson(reply);
297
+ }
298
+ /** Tear down exactly the supplied route generation. */
299
+ async closeRoute(handle, opts = {}) {
300
+ this.assertLiveHandle(handle);
301
+ for (const [key, cached] of this.routes) {
302
+ if (cached.handle && sameRouteHandle(cached.handle, handle)) {
303
+ cached.closed = true;
304
+ cached.handle = null;
305
+ this.routes.delete(key);
306
+ }
307
+ }
308
+ if (opts.drain)
309
+ await this.drainUnaryOnHandle(handle);
310
+ this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed"));
311
+ if (this.liveRoutes.get(handle.channel) === handle)
312
+ this.liveRoutes.delete(handle.channel);
313
+ this.sendRouteGoodbye(handle);
314
+ }
315
+ /** Close a cached managed route by its route-open identity tuple. */
316
+ async closeManagedRoute(target, identity, opts = {}) {
269
317
  const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
270
318
  const cached = this.routes.get(key);
271
319
  if (!cached)
272
- return; // never opened / already closed — idempotent no-op.
273
- // Generation guard: an in-flight openCachedRoute holds this same object and
274
- // re-checks `closed` before installing its channel, so flipping it here makes
275
- // close win over a racing reopen. Removing the map entry lets a later call()
276
- // create a fresh route for the key (not a permanent tombstone).
320
+ return;
277
321
  cached.closed = true;
278
322
  this.routes.delete(key);
279
- const channel = cached.channel;
280
- cached.channel = null;
281
- // channel === null means the route was still opening (no channel installed yet);
282
- // the racing open will see closed=true and GOODBYE whatever it opens, so there is
283
- // nothing local to tear down here.
284
- if (channel !== null)
285
- await this.closeRouteChannel(channel, opts);
323
+ const handle = cached.handle;
324
+ cached.handle = null;
325
+ if (handle)
326
+ await this.closeRoute(handle, opts);
286
327
  }
287
- /**
288
- * Tear down ONE route by its channel number — the primitive for callers that
289
- * opened a route with `routeOpen` directly (e.g. a tool route carrying raw
290
- * {name, arguments}) and hold the channel themselves. Idempotent, never throws.
291
- * Settles in-flight requests on the channel as RouteClosed and sends a best-effort
292
- * route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
293
- * are always aborted (a held-open stream cannot be drained).
294
- */
295
- async closeRouteChannel(channel, opts = {}) {
296
- if (channel === 0)
297
- return; // channel 0 is the control plane, never a route.
298
- if (opts.drain) {
299
- // Wait only for in-flight UNARY requests on this channel; subscriptions are
300
- // aborted below (a held-open stream has no natural completion to drain to).
301
- await this.drainUnaryOnChannel(channel);
302
- }
303
- // Settle anything still in flight on the channel (all of it in abort mode; only
304
- // subscriptions + late stragglers after a drain). Managed requests are classified
305
- // at-most-once via their classifyFailure; raw requests/subscriptions get a plain
306
- // RouteClosed error.
307
- this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
308
- // Best-effort GOODBYE: releases the route on the daemon and notifies the module.
309
- this.sendRouteGoodbye(channel);
328
+ /** Alias retained for callers that name the operation by protocol channel; it still requires a full handle. */
329
+ async closeRouteChannel(handle, opts = {}) {
330
+ await this.closeRoute(handle, opts);
310
331
  }
311
332
  close() {
312
333
  this.closeStarted = true;
313
334
  this.fail(new SubcError("client closed"));
314
335
  this.sock.close();
315
336
  }
316
- /** Resolve once every in-flight UNARY request on the channel (snapshot at call
317
- * time) has settled. Subscriptions are excluded — they are aborted, not drained. */
318
- drainUnaryOnChannel(channel) {
337
+ drainUnaryOnHandle(handle) {
319
338
  const waiters = [];
320
339
  for (const pending of this.pending.values()) {
321
- if (pending.channel === channel && !pending.subscription) {
340
+ if (pending.handle === handle && !pending.subscription) {
322
341
  waiters.push(new Promise((resolve) => {
323
- const prev = pending.onSettle;
342
+ const previous = pending.onSettle;
324
343
  pending.onSettle = () => {
325
- prev?.();
344
+ previous?.();
326
345
  resolve();
327
346
  };
328
347
  }));
@@ -330,14 +349,20 @@ export class SubcClient {
330
349
  }
331
350
  return Promise.all(waiters).then(() => undefined);
332
351
  }
333
- /** Send a best-effort header-only route GOODBYE for `channel`. One-way: the daemon
334
- * releases the route and relays a route-gone GOODBYE to the module; no ack. */
335
- sendRouteGoodbye(channel) {
336
- if (this.closedErr)
337
- return; // connection already gone — the route died with it.
338
- const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), channel, 0n, EMPTY_BODY);
339
- this.sock.write(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
340
- // Best-effort: if the socket is already gone, the route is torn down anyway.
352
+ sendRouteGoodbye(handle, closeOnQueueFailure = false) {
353
+ this.assertLiveConnection(handle);
354
+ if (this.closedErr) {
355
+ if (closeOnQueueFailure)
356
+ this.closeConnectionAfterCleanupFailure();
357
+ return;
358
+ }
359
+ const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), handle.channel, handle.epoch, 0n, EMPTY_BODY);
360
+ const write = this.sock.writeTracked(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS);
361
+ if (!write.queued && closeOnQueueFailure)
362
+ this.closeConnectionAfterCleanupFailure();
363
+ write.completed.catch(() => {
364
+ if (closeOnQueueFailure && !write.queued)
365
+ this.closeConnectionAfterCleanupFailure();
341
366
  });
342
367
  }
343
368
  static async openConnection(opts) {
@@ -354,33 +379,42 @@ export class SubcClient {
354
379
  }
355
380
  return { sock, conn };
356
381
  }
357
- async controlRpc(body) {
358
- // Match the canonical probe: control requests go out Interactive on channel 0.
359
- return this.send(0, body, Priority.Interactive, undefined, undefined);
382
+ async controlRpc(body, acceptFrame, onLateResponse) {
383
+ return this.send(null, body, Priority.Interactive, AdmissionClass.Normal, undefined, undefined, acceptFrame, onLateResponse);
360
384
  }
361
- send(channel, body, priority, timeoutMs, onProgress) {
385
+ send(handle, body, priority, admission, timeoutMs, onProgress, acceptFrame, onLateResponse) {
386
+ if (handle)
387
+ this.assertLiveHandle(handle);
362
388
  if (this.closedErr)
363
389
  return Promise.reject(this.closedErr);
364
- const corr = this.nextCorr++;
365
- const key = `${channel}:${corr}`;
366
- const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
390
+ let corr;
391
+ try {
392
+ corr = this.allocateCorr();
393
+ }
394
+ catch (error) {
395
+ return Promise.reject(error);
396
+ }
397
+ const key = pendingKey(handle, corr);
398
+ const channel = handle?.channel ?? 0;
399
+ const epoch = handle?.epoch ?? 0;
400
+ const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false, admission), channel, epoch, corr, body);
367
401
  return new Promise((resolve, reject) => {
368
402
  const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
369
403
  const pending = {
370
- channel,
404
+ handle,
371
405
  resolve,
372
406
  reject,
373
407
  onProgress,
374
408
  timer: null,
409
+ acceptFrame,
410
+ onLateResponse,
375
411
  };
376
- pending.timer = setTimeout(() => {
377
- this.arbitrateTimeout(key, pending, channel, corr, ms);
378
- }, ms);
412
+ pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
379
413
  this.pending.set(key, pending);
380
- this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
381
- const p = this.pending.get(key);
382
- if (p)
383
- this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
414
+ this.sock.write(encodeFrame(frame), Date.now() + ms).catch((error) => {
415
+ const current = this.pending.get(key);
416
+ if (current)
417
+ this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
384
418
  });
385
419
  });
386
420
  }
@@ -396,6 +430,8 @@ export class SubcClient {
396
430
  */
397
431
  arbitrateTimeout(key, pending, channel, corr, ms) {
398
432
  const settleAsTimeout = () => {
433
+ if (pending.onLateResponse)
434
+ this.lateResponses.set(key, pending.onLateResponse);
399
435
  this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms), REQUEST_DEADLINE_MARKER));
400
436
  };
401
437
  const graceDeadline = Date.now() + this.opts.timeoutArbitrationGraceMs;
@@ -416,69 +452,70 @@ export class SubcClient {
416
452
  };
417
453
  setImmediate(arbitrate);
418
454
  }
419
- async managedRequest(routeChannel, body, opts) {
455
+ async managedRequest(handle, body, opts) {
420
456
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
421
457
  const priority = opts.priority ?? Priority.Interactive;
458
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
422
459
  try {
423
- const reply = await this.sendManaged(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
460
+ const reply = await this.sendManaged(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
424
461
  return this.parseJson(reply);
425
462
  }
426
- catch (err) {
427
- if (err instanceof SubcCallError)
428
- throw err;
429
- throw this.terminalCallError("managed call failed", err);
463
+ catch (error) {
464
+ if (error instanceof SubcCallError)
465
+ throw error;
466
+ throw this.terminalCallError("managed call failed", error);
430
467
  }
431
468
  }
432
- sendManaged(channel, body, priority, timeoutMs, onProgress) {
469
+ sendManaged(handle, body, priority, admission, timeoutMs, onProgress) {
470
+ try {
471
+ this.assertLiveHandle(handle);
472
+ }
473
+ catch (error) {
474
+ return Promise.reject(this.notSentCallError("request used a stale route handle", error));
475
+ }
433
476
  if (this.closedErr) {
434
477
  return Promise.reject(this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr));
435
478
  }
436
- const corr = this.nextCorr++;
437
- const key = `${channel}:${corr}`;
438
- const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
479
+ let corr;
480
+ try {
481
+ corr = this.allocateCorr();
482
+ }
483
+ catch (error) {
484
+ return Promise.reject(this.notSentCallError("request correlation allocator was exhausted", error));
485
+ }
486
+ const key = pendingKey(handle, corr);
487
+ const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false, admission), handle.channel, handle.epoch, corr, body);
439
488
  let handedToSocket = false;
440
- const classifyFailure = (err) => {
441
- // This is the load-bearing asymmetry: only the pre-write paths are NotSent.
442
- // As soon as writeTracked reports that bytes were queued to Node's socket,
443
- // those bytes may already be in the OS buffer or at the daemon. Any later
444
- // close, write callback error, route GOODBYE, or timeout before a response is
445
- // therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
446
- if (!handedToSocket) {
447
- return this.notSentCallError("request bytes were not queued to the subc socket", err);
448
- }
449
- // A request-deadline timeout (arbitration expired without observing a drop)
450
- // is refined from a real connection drop: the socket was NOT seen to fail, so
451
- // the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
452
- // — queued-to-local-socket is not proof the daemon received or ran it.
453
- if (err instanceof SubcError && err.code === REQUEST_DEADLINE_MARKER) {
454
- 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);
489
+ const classifyFailure = (error) => {
490
+ if (!handedToSocket)
491
+ return this.notSentCallError("request bytes were not queued to the subc socket", error);
492
+ if (error instanceof SubcError && error.code === REQUEST_DEADLINE_MARKER) {
493
+ 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(error)}`, DEADLINE_NO_DROP_CODE, error);
455
494
  }
456
- return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", err);
495
+ return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", error);
457
496
  };
458
497
  return new Promise((resolve, reject) => {
459
498
  const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
460
499
  const pending = {
461
- channel,
500
+ handle,
462
501
  resolve,
463
502
  reject,
464
503
  onProgress,
465
504
  timer: null,
466
505
  classifyFailure,
467
506
  };
468
- pending.timer = setTimeout(() => {
469
- this.arbitrateTimeout(key, pending, channel, corr, ms);
470
- }, ms);
507
+ pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
471
508
  this.pending.set(key, pending);
472
509
  const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
473
510
  handedToSocket = write.queued;
474
- write.completed.catch((err) => {
475
- const p = this.pending.get(key);
476
- if (p)
477
- this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
511
+ write.completed.catch((error) => {
512
+ const current = this.pending.get(key);
513
+ if (current)
514
+ this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
478
515
  });
479
516
  });
480
517
  }
481
- async cachedRouteChannel(moduleId, opts) {
518
+ async cachedRouteHandle(moduleId, opts) {
482
519
  const identity = opts.identity ?? this.opts.identity;
483
520
  if (!identity) {
484
521
  throw new SubcCallError("terminal", "managed call requires a BindIdentity in SubcClient.connect({ identity }) or call(..., { identity })", "missing_identity");
@@ -494,15 +531,13 @@ export class SubcClient {
494
531
  target,
495
532
  identity,
496
533
  consumerIdentity,
497
- channel: null,
498
- generation: 0,
534
+ handle: null,
499
535
  opening: null,
500
536
  };
501
537
  this.routes.set(key, cached);
502
538
  }
503
- if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
504
- return cached.channel;
505
- }
539
+ if (cached.handle && this.isLiveHandle(cached.handle))
540
+ return cached.handle;
506
541
  if (!cached.opening) {
507
542
  cached.opening = this.openCachedRoute(cached).finally(() => {
508
543
  cached.opening = null;
@@ -520,61 +555,45 @@ export class SubcClient {
520
555
  try {
521
556
  await this.ensureConnectedForManaged();
522
557
  }
523
- catch (err) {
524
- throw this.notSentRecoveryError("route.open could not run because reconnect failed", err);
525
- }
526
- if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
527
- return cached.channel;
558
+ catch (error) {
559
+ throw this.notSentRecoveryError("route.open could not run because reconnect failed", error);
528
560
  }
561
+ if (cached.handle && this.isLiveHandle(cached.handle))
562
+ return cached.handle;
529
563
  try {
530
- const channel = await this.routeOpen(cached.target, cached.identity, {
564
+ const handle = await this.routeOpen(cached.target, cached.identity, {
531
565
  consumerIdentity: cached.consumerIdentity ?? null,
532
566
  });
533
- // Generation guard: a closeRoute may have flipped the tombstone WHILE this
534
- // route.open was in flight. If so, close wins — do NOT install the channel
535
- // into the (already-removed) cache entry; GOODBYE the channel we just opened
536
- // so the daemon/module don't leak it, and fail as RouteClosed.
537
567
  if (cached.closed) {
538
- this.sendRouteGoodbye(channel);
568
+ this.liveRoutes.delete(handle.channel);
569
+ this.sendRouteGoodbye(handle);
539
570
  throw this.routeClosedDuringOpen();
540
571
  }
541
- cached.channel = channel;
542
- cached.generation = this.generation;
543
- return channel;
572
+ cached.handle = handle;
573
+ return handle;
544
574
  }
545
- catch (err) {
546
- if (err instanceof SubcCallError && err.code === "route_closed")
547
- throw err;
548
- if (!this.closeStarted && isConsumerReconnectTransient(err)) {
575
+ catch (error) {
576
+ if (error instanceof SubcCallError && error.code === "route_closed")
577
+ throw error;
578
+ if (!this.closeStarted && isConsumerReconnectTransient(error)) {
549
579
  try {
550
- await this.reconnectAfterDrop(err);
580
+ await this.reconnectAfterDrop(error);
551
581
  }
552
- catch (reconnectErr) {
553
- throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectErr);
582
+ catch (reconnectError) {
583
+ throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectError);
554
584
  }
555
585
  continue;
556
586
  }
557
- // A daemon-rejected route.open with a RETRYABLE code (target booting /
558
- // reloading / momentarily absent) is retried IN-PLACE against the same live
559
- // connection — never a socket reconnect, which would needlessly disrupt this
560
- // connection's other routes — until the route-retry deadline. Past the
561
- // deadline it surfaces as not_sent: provably pre-send (no data frame ever
562
- // left the client) AND still transient, so the caller's own retry policy may
563
- // safely re-attempt later. Reason and set kept in parity with subc-client-rs.
564
- if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
587
+ if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
565
588
  routeRetryAttempt += 1;
566
589
  if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
567
590
  await this.opts.sleep(routeRetryDelay);
568
591
  routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
569
592
  continue;
570
593
  }
571
- throw this.notSentCallError(`route.open failed for module ${cached.moduleId}: ${err.code} (retry budget exhausted)`, err);
594
+ throw this.notSentCallError(`route.open failed for module ${cached.moduleId}: ${error.code} (retry budget exhausted)`, error);
572
595
  }
573
- // A permanent route.open rejection (bad_consumer_identity, config_divergence,
574
- // unknown_target, ...) is pre-send but would never succeed on retry, so it
575
- // stays terminal — a not_sent class here would invite a retry storm against a
576
- // request the daemon will always reject.
577
- throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
596
+ throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
578
597
  }
579
598
  }
580
599
  }
@@ -649,33 +668,27 @@ export class SubcClient {
649
668
  this.currentConn = opened.conn;
650
669
  this.closedErr = null;
651
670
  this.generation += 1;
671
+ this.connectionToken = newConnectionToken();
672
+ this.liveRoutes.clear();
673
+ this.lateResponses.clear();
674
+ this.nextCorr = 1n;
652
675
  void this.readLoop(opened.sock, this.generation);
653
676
  }
654
677
  async reopenCachedRoutes() {
655
- for (const cached of this.routes.values()) {
656
- cached.channel = null;
657
- cached.generation = 0;
658
- }
678
+ for (const cached of this.routes.values())
679
+ cached.handle = null;
659
680
  for (const cached of this.routes.values()) {
660
681
  if (cached.closed)
661
- continue; // closed concurrently with reconnect — don't reopen.
662
- // Thread the route's consumer identity through the reopen, exactly as the
663
- // lazy per-call path (openCachedRoute) does. Dropping it here would make a
664
- // route reopened after a reconnect send route.open with no consumer_identity,
665
- // so the daemon would re-stamp it with a different (weaker) principal than the
666
- // one it was originally bound under — a silent post-reconnect trust downgrade.
667
- const channel = await this.routeOpen(cached.target, cached.identity, {
682
+ continue;
683
+ const handle = await this.routeOpen(cached.target, cached.identity, {
668
684
  consumerIdentity: cached.consumerIdentity ?? null,
669
685
  });
670
- // A closeRoute may have raced this reopen (flipping the tombstone during the
671
- // route.open await). If so, GOODBYE the channel instead of installing it, so the
672
- // closed route isn't silently re-established on the new connection.
673
686
  if (cached.closed) {
674
- this.sendRouteGoodbye(channel);
687
+ this.liveRoutes.delete(handle.channel);
688
+ this.sendRouteGoodbye(handle);
675
689
  continue;
676
690
  }
677
- cached.channel = channel;
678
- cached.generation = this.generation;
691
+ cached.handle = handle;
679
692
  }
680
693
  }
681
694
  // A request timeout carries the local socket port and (channel, corr) so a
@@ -693,41 +706,39 @@ export class SubcClient {
693
706
  async readLoop(sock, generation) {
694
707
  try {
695
708
  for (;;) {
696
- // Header read waits indefinitely — idle time between frames is normal, and
697
- // the reader is NOT "active" while parked here (a racing timeout must not
698
- // grant grace just because the connection is idle between frames).
699
- const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
700
- // A frame is now arriving. Mark the reader active THROUGH dispatch so a
701
- // timeout that fires mid-arrival grants the reply its bounded grace window
702
- // instead of settling as a spurious timeout.
703
- this.readerActive = true;
709
+ this.readerActive = false;
710
+ const frame = await sock.readFrame(Number.POSITIVE_INFINITY, Date.now() + BODY_READ_TIMEOUT_MS, () => {
711
+ this.readerActive = true;
712
+ });
704
713
  try {
705
- const header = decodeHeader(headerBytes);
706
- const body = header.len === 0
707
- ? new Uint8Array(0)
708
- : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
709
- // Drop a frame read off a socket this client has already replaced
710
- // (reconnect): its pendings were settled by fail(), and dispatching it
711
- // against the current pending map could match a re-used (channel, corr).
712
- if (this.sock === sock && this.generation === generation) {
713
- this.dispatch({ header, body });
714
- }
714
+ if (this.sock === sock && this.generation === generation)
715
+ this.dispatch(frame);
715
716
  }
716
717
  finally {
717
718
  this.readerActive = false;
718
719
  }
719
720
  }
720
721
  }
721
- catch (err) {
722
+ catch (error) {
722
723
  if (this.sock === sock && this.generation === generation) {
723
- this.fail(err instanceof Error ? err : new SubcError(String(err)));
724
+ this.fail(error instanceof Error ? error : new SubcError(String(error)));
724
725
  }
725
726
  }
726
727
  }
727
728
  dispatch(frame) {
728
- const key = `${frame.header.channel}:${frame.header.corr}`;
729
+ let handle = null;
730
+ if (frame.header.channel !== 0) {
731
+ handle = this.liveRoutes.get(frame.header.channel) ?? null;
732
+ if (!handle || handle.epoch !== frame.header.epoch) {
733
+ this.ingressEpochDropCount += 1;
734
+ return;
735
+ }
736
+ }
737
+ const key = pendingKey(handle, frame.header.corr);
729
738
  const pending = this.pending.get(key);
730
739
  if (pending) {
740
+ if (pending.acceptFrame && !pending.acceptFrame(frame))
741
+ return;
731
742
  switch (frame.header.ty) {
732
743
  case FrameType.Push:
733
744
  case FrameType.StreamData:
@@ -744,28 +755,24 @@ export class SubcClient {
744
755
  return;
745
756
  }
746
757
  }
747
- if (frame.header.ty === FrameType.Goodbye) {
748
- this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
749
- // The route is gone daemon-side (module restart, drain, or provider close).
750
- // Evict the cached bind so the NEXT managed call re-opens instead of
751
- // resending into the dead channel and dying on a terminal unknown_channel.
752
- this.evictRouteChannel(frame.header.channel);
758
+ const late = this.lateResponses.get(key);
759
+ if (late && (frame.header.ty === FrameType.Response || frame.header.ty === FrameType.Error)) {
760
+ this.lateResponses.delete(key);
761
+ late(frame);
762
+ return;
763
+ }
764
+ if (frame.header.ty === FrameType.Goodbye && handle) {
765
+ this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)"));
766
+ if (this.liveRoutes.get(handle.channel) === handle)
767
+ this.liveRoutes.delete(handle.channel);
768
+ this.evictRouteHandle(handle);
753
769
  return;
754
770
  }
755
- // A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
756
- // a reply that arrived AFTER its request already settled — the fingerprint of a
757
- // premature timeout under event-loop starvation (the reply raced the deadline
758
- // and lost). Metadata-only debug log (never the body) so every future
759
- // occurrence is a one-line diagnosis instead of an invisible drop. Enable with
760
- // NODE_DEBUG=subc-client.
761
771
  if (frame.header.ty === FrameType.Response ||
762
772
  frame.header.ty === FrameType.Error ||
763
773
  frame.header.ty === FrameType.StreamEnd) {
764
- 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() ?? "?");
765
- return;
774
+ debug("dropped terminal frame with no waiter: type=%d channel=%d epoch=%d corr=%s port=%s", frame.header.ty, frame.header.channel, frame.header.epoch, frame.header.corr, this.sock.localPort() ?? "?");
766
775
  }
767
- // Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
768
- // unsolicited-push consumers.
769
776
  }
770
777
  /**
771
778
  * Settle a pending exactly once. The object-identity guard (the map still
@@ -798,24 +805,16 @@ export class SubcClient {
798
805
  return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
799
806
  }
800
807
  }
801
- /**
802
- * Drop a dead channel from the managed-route cache so the next call re-opens.
803
- * Only clears entries still pointing at THIS channel in the CURRENT socket
804
- * generation; a route already re-opened (new channel) or re-created after a
805
- * reconnect (new generation) is left alone.
806
- */
807
- evictRouteChannel(channel) {
808
+ evictRouteHandle(handle) {
808
809
  for (const cached of this.routes.values()) {
809
- if (cached.channel === channel && cached.generation === this.generation) {
810
- cached.channel = null;
811
- }
810
+ if (cached.handle && sameRouteHandle(cached.handle, handle))
811
+ cached.handle = null;
812
812
  }
813
813
  }
814
- failChannel(channel, err) {
814
+ failHandle(handle, error) {
815
815
  for (const [key, pending] of this.pending) {
816
- if (pending.channel === channel) {
817
- this.rejectPending(key, pending, err);
818
- }
816
+ if (pending.handle && sameRouteHandle(pending.handle, handle))
817
+ this.rejectPending(key, pending, error);
819
818
  }
820
819
  }
821
820
  fail(err) {
@@ -843,6 +842,45 @@ export class SubcClient {
843
842
  return this.notSentCallError(message, cause);
844
843
  return this.terminalCallError(message, cause);
845
844
  }
845
+ /** Number of nonzero-channel ingress frames dropped by endpoint epoch validation. */
846
+ get droppedIngressFrames() {
847
+ return this.ingressEpochDropCount;
848
+ }
849
+ installRoute(channel, epoch) {
850
+ const handle = createRouteHandle(channel, epoch, this.connectionToken);
851
+ this.liveRoutes.set(channel, handle);
852
+ return handle;
853
+ }
854
+ isLiveHandle(handle) {
855
+ return belongsToConnection(handle, this.connectionToken) && this.liveRoutes.get(handle.channel) === handle;
856
+ }
857
+ assertLiveConnection(handle) {
858
+ if (!belongsToConnection(handle, this.connectionToken))
859
+ throw new StaleRouteHandleError(handle);
860
+ }
861
+ assertLiveHandle(handle) {
862
+ if (!this.isLiveHandle(handle))
863
+ throw new StaleRouteHandleError(handle);
864
+ }
865
+ allocateCorr() {
866
+ const maximum = 0xffffffffffffffffn;
867
+ if (this.nextCorr > maximum) {
868
+ const error = new SubcError("channel-0 correlation id allocator exhausted", "corr_exhausted");
869
+ this.fail(error);
870
+ this.sock.close();
871
+ this.scheduleReconnectAfterDrop(error);
872
+ throw error;
873
+ }
874
+ const corr = this.nextCorr;
875
+ this.nextCorr += 1n;
876
+ return corr;
877
+ }
878
+ closeConnectionAfterCleanupFailure() {
879
+ const error = new SubcError("late route cleanup could not be queued", "late_route_cleanup_failed");
880
+ this.fail(error);
881
+ this.sock.close();
882
+ this.scheduleReconnectAfterDrop(error);
883
+ }
846
884
  encode(value) {
847
885
  return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
848
886
  }
@@ -939,4 +977,7 @@ function causeMessage(cause) {
939
977
  return "";
940
978
  return `: ${cause instanceof Error ? cause.message : String(cause)}`;
941
979
  }
980
+ function pendingKey(handle, corr) {
981
+ return handle ? `${handle.channel}:${handle.epoch}:${corr}` : `0:0:${corr}`;
982
+ }
942
983
  //# sourceMappingURL=client.js.map