@cortexkit/subc-client 0.3.3 → 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
  /**
@@ -162,14 +196,25 @@ export class SubcClient {
162
196
  */
163
197
  async call(moduleId, method, params, opts = {}) {
164
198
  const body = params === undefined ? { method } : { method, params };
199
+ let retriedUnknownChannel = false;
165
200
  for (;;) {
166
- const routeChannel = await this.cachedRouteChannel(moduleId, opts);
201
+ const routeHandle = await this.cachedRouteHandle(moduleId, opts);
167
202
  try {
168
- return (await this.managedRequest(routeChannel, body, opts));
203
+ return (await this.managedRequest(routeHandle, body, opts));
169
204
  }
170
205
  catch (err) {
171
206
  if (!(err instanceof SubcCallError))
172
207
  throw this.terminalCallError("managed call failed", err);
208
+ // unknown_channel is the daemon ROUTER refusing an unrouted channel — the
209
+ // request provably never reached a module, so one in-place retry cannot
210
+ // double-execute anything. The cached bind is dead (module restarted and
211
+ // its route-gone GOODBYE raced or was missed); evict it so the retry
212
+ // re-opens the route instead of resending into the same dead channel.
213
+ if (err.code === "unknown_channel" && !retriedUnknownChannel && !this.closeStarted) {
214
+ retriedUnknownChannel = true;
215
+ this.evictRouteHandle(routeHandle);
216
+ continue;
217
+ }
173
218
  if (err.kind === "not_sent") {
174
219
  try {
175
220
  await this.reconnectAfterDrop(err);
@@ -190,38 +235,32 @@ export class SubcClient {
190
235
  }
191
236
  }
192
237
  }
193
- /**
194
- * Open a held-open event subscription on a route channel. Sends one Request the
195
- * provider keeps open, delivering each interim StreamData frame to `onEvent`; the
196
- * returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
197
- * GOODBYE (reject). Events ride this held-open request's correlation id — they are
198
- * never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
199
- */
200
- 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);
201
241
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
202
242
  const priority = opts.priority ?? Priority.Interactive;
203
- const corr = this.nextCorr++;
204
- const key = `${routeChannel}:${corr}`;
243
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
244
+ const corr = this.allocateCorr();
245
+ const key = pendingKey(handle, corr);
205
246
  const closed = new Promise((resolve, reject) => {
206
247
  if (this.closedErr) {
207
248
  reject(this.closedErr);
208
249
  return;
209
250
  }
210
- // No timeout: a subscription stays open indefinitely until StreamEnd, Error,
211
- // route GOODBYE, or unsubscribe.
212
251
  this.pending.set(key, {
213
- channel: routeChannel,
252
+ handle,
214
253
  resolve: () => resolve(),
215
254
  reject,
216
255
  onProgress: onEvent,
217
256
  timer: null,
218
257
  subscription: true,
219
258
  });
220
- 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);
221
260
  this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
222
- const p = this.pending.get(key);
223
- if (p)
224
- 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)));
225
264
  });
226
265
  });
227
266
  let cancelled = false;
@@ -229,89 +268,80 @@ export class SubcClient {
229
268
  if (cancelled)
230
269
  return;
231
270
  cancelled = true;
232
- // Pure-header Cancel on the held-open (channel, corr): the provider aborts its
233
- // handler and ends with StreamEnd, which settles `closed`.
234
- const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
235
- this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
236
- // Best-effort: if the socket is already gone, the read loop fails the
237
- // pending waiter and `closed` rejects on its own.
238
- });
271
+ this.cancel(handle, corr, priority);
239
272
  };
240
273
  return { unsubscribe, closed };
241
274
  }
242
- /**
243
- * Tear down ONE managed route (a route opened via `call()`), keyed by its
244
- * (target, identity). Idempotent and never throws — callers over-call on
245
- * session-end. The teardown:
246
- * - flips a tombstone on the cached route and removes it from the cache, so an
247
- * in-flight `openCachedRoute` for the same key will NOT install its channel
248
- * (the generation guard: close beats a racing reopen), and a later `call()`
249
- * opens a fresh route (this is NOT a permanent tombstone);
250
- * - settles in-flight requests on the channel as RouteClosed (managed requests
251
- * keep their at-most-once classification: outcome_unknown if already sent,
252
- * not_sent otherwise; subscriptions always abort);
253
- * - sends a best-effort route GOODBYE so subc releases the route and notifies
254
- * the module to free per-session resources.
255
- * `opts.drain` waits for in-flight UNARY requests to settle before tearing down.
256
- */
257
- 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 = {}) {
258
317
  const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
259
318
  const cached = this.routes.get(key);
260
319
  if (!cached)
261
- return; // never opened / already closed — idempotent no-op.
262
- // Generation guard: an in-flight openCachedRoute holds this same object and
263
- // re-checks `closed` before installing its channel, so flipping it here makes
264
- // close win over a racing reopen. Removing the map entry lets a later call()
265
- // create a fresh route for the key (not a permanent tombstone).
320
+ return;
266
321
  cached.closed = true;
267
322
  this.routes.delete(key);
268
- const channel = cached.channel;
269
- cached.channel = null;
270
- // channel === null means the route was still opening (no channel installed yet);
271
- // the racing open will see closed=true and GOODBYE whatever it opens, so there is
272
- // nothing local to tear down here.
273
- if (channel !== null)
274
- await this.closeRouteChannel(channel, opts);
323
+ const handle = cached.handle;
324
+ cached.handle = null;
325
+ if (handle)
326
+ await this.closeRoute(handle, opts);
275
327
  }
276
- /**
277
- * Tear down ONE route by its channel number — the primitive for callers that
278
- * opened a route with `routeOpen` directly (e.g. a tool route carrying raw
279
- * {name, arguments}) and hold the channel themselves. Idempotent, never throws.
280
- * Settles in-flight requests on the channel as RouteClosed and sends a best-effort
281
- * route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
282
- * are always aborted (a held-open stream cannot be drained).
283
- */
284
- async closeRouteChannel(channel, opts = {}) {
285
- if (channel === 0)
286
- return; // channel 0 is the control plane, never a route.
287
- if (opts.drain) {
288
- // Wait only for in-flight UNARY requests on this channel; subscriptions are
289
- // aborted below (a held-open stream has no natural completion to drain to).
290
- await this.drainUnaryOnChannel(channel);
291
- }
292
- // Settle anything still in flight on the channel (all of it in abort mode; only
293
- // subscriptions + late stragglers after a drain). Managed requests are classified
294
- // at-most-once via their classifyFailure; raw requests/subscriptions get a plain
295
- // RouteClosed error.
296
- this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
297
- // Best-effort GOODBYE: releases the route on the daemon and notifies the module.
298
- 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);
299
331
  }
300
332
  close() {
301
333
  this.closeStarted = true;
302
334
  this.fail(new SubcError("client closed"));
303
335
  this.sock.close();
304
336
  }
305
- /** Resolve once every in-flight UNARY request on the channel (snapshot at call
306
- * time) has settled. Subscriptions are excluded — they are aborted, not drained. */
307
- drainUnaryOnChannel(channel) {
337
+ drainUnaryOnHandle(handle) {
308
338
  const waiters = [];
309
339
  for (const pending of this.pending.values()) {
310
- if (pending.channel === channel && !pending.subscription) {
340
+ if (pending.handle === handle && !pending.subscription) {
311
341
  waiters.push(new Promise((resolve) => {
312
- const prev = pending.onSettle;
342
+ const previous = pending.onSettle;
313
343
  pending.onSettle = () => {
314
- prev?.();
344
+ previous?.();
315
345
  resolve();
316
346
  };
317
347
  }));
@@ -319,14 +349,20 @@ export class SubcClient {
319
349
  }
320
350
  return Promise.all(waiters).then(() => undefined);
321
351
  }
322
- /** Send a best-effort header-only route GOODBYE for `channel`. One-way: the daemon
323
- * releases the route and relays a route-gone GOODBYE to the module; no ack. */
324
- sendRouteGoodbye(channel) {
325
- if (this.closedErr)
326
- return; // connection already gone — the route died with it.
327
- const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), channel, 0n, EMPTY_BODY);
328
- this.sock.write(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
329
- // 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();
330
366
  });
331
367
  }
332
368
  static async openConnection(opts) {
@@ -343,33 +379,42 @@ export class SubcClient {
343
379
  }
344
380
  return { sock, conn };
345
381
  }
346
- async controlRpc(body) {
347
- // Match the canonical probe: control requests go out Interactive on channel 0.
348
- 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);
349
384
  }
350
- send(channel, body, priority, timeoutMs, onProgress) {
385
+ send(handle, body, priority, admission, timeoutMs, onProgress, acceptFrame, onLateResponse) {
386
+ if (handle)
387
+ this.assertLiveHandle(handle);
351
388
  if (this.closedErr)
352
389
  return Promise.reject(this.closedErr);
353
- const corr = this.nextCorr++;
354
- const key = `${channel}:${corr}`;
355
- 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);
356
401
  return new Promise((resolve, reject) => {
357
402
  const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
358
403
  const pending = {
359
- channel,
404
+ handle,
360
405
  resolve,
361
406
  reject,
362
407
  onProgress,
363
408
  timer: null,
409
+ acceptFrame,
410
+ onLateResponse,
364
411
  };
365
- pending.timer = setTimeout(() => {
366
- this.arbitrateTimeout(key, pending, channel, corr, ms);
367
- }, ms);
412
+ pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
368
413
  this.pending.set(key, pending);
369
- this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
370
- const p = this.pending.get(key);
371
- if (p)
372
- 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)));
373
418
  });
374
419
  });
375
420
  }
@@ -385,6 +430,8 @@ export class SubcClient {
385
430
  */
386
431
  arbitrateTimeout(key, pending, channel, corr, ms) {
387
432
  const settleAsTimeout = () => {
433
+ if (pending.onLateResponse)
434
+ this.lateResponses.set(key, pending.onLateResponse);
388
435
  this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms), REQUEST_DEADLINE_MARKER));
389
436
  };
390
437
  const graceDeadline = Date.now() + this.opts.timeoutArbitrationGraceMs;
@@ -405,69 +452,70 @@ export class SubcClient {
405
452
  };
406
453
  setImmediate(arbitrate);
407
454
  }
408
- async managedRequest(routeChannel, body, opts) {
455
+ async managedRequest(handle, body, opts) {
409
456
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
410
457
  const priority = opts.priority ?? Priority.Interactive;
458
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
411
459
  try {
412
- 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);
413
461
  return this.parseJson(reply);
414
462
  }
415
- catch (err) {
416
- if (err instanceof SubcCallError)
417
- throw err;
418
- 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);
419
467
  }
420
468
  }
421
- 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
+ }
422
476
  if (this.closedErr) {
423
477
  return Promise.reject(this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr));
424
478
  }
425
- const corr = this.nextCorr++;
426
- const key = `${channel}:${corr}`;
427
- 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);
428
488
  let handedToSocket = false;
429
- const classifyFailure = (err) => {
430
- // This is the load-bearing asymmetry: only the pre-write paths are NotSent.
431
- // As soon as writeTracked reports that bytes were queued to Node's socket,
432
- // those bytes may already be in the OS buffer or at the daemon. Any later
433
- // close, write callback error, route GOODBYE, or timeout before a response is
434
- // therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
435
- if (!handedToSocket) {
436
- return this.notSentCallError("request bytes were not queued to the subc socket", err);
437
- }
438
- // A request-deadline timeout (arbitration expired without observing a drop)
439
- // is refined from a real connection drop: the socket was NOT seen to fail, so
440
- // the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
441
- // — queued-to-local-socket is not proof the daemon received or ran it.
442
- if (err instanceof SubcError && err.code === REQUEST_DEADLINE_MARKER) {
443
- 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);
444
494
  }
445
- 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);
446
496
  };
447
497
  return new Promise((resolve, reject) => {
448
498
  const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
449
499
  const pending = {
450
- channel,
500
+ handle,
451
501
  resolve,
452
502
  reject,
453
503
  onProgress,
454
504
  timer: null,
455
505
  classifyFailure,
456
506
  };
457
- pending.timer = setTimeout(() => {
458
- this.arbitrateTimeout(key, pending, channel, corr, ms);
459
- }, ms);
507
+ pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
460
508
  this.pending.set(key, pending);
461
509
  const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
462
510
  handedToSocket = write.queued;
463
- write.completed.catch((err) => {
464
- const p = this.pending.get(key);
465
- if (p)
466
- 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)));
467
515
  });
468
516
  });
469
517
  }
470
- async cachedRouteChannel(moduleId, opts) {
518
+ async cachedRouteHandle(moduleId, opts) {
471
519
  const identity = opts.identity ?? this.opts.identity;
472
520
  if (!identity) {
473
521
  throw new SubcCallError("terminal", "managed call requires a BindIdentity in SubcClient.connect({ identity }) or call(..., { identity })", "missing_identity");
@@ -483,15 +531,13 @@ export class SubcClient {
483
531
  target,
484
532
  identity,
485
533
  consumerIdentity,
486
- channel: null,
487
- generation: 0,
534
+ handle: null,
488
535
  opening: null,
489
536
  };
490
537
  this.routes.set(key, cached);
491
538
  }
492
- if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
493
- return cached.channel;
494
- }
539
+ if (cached.handle && this.isLiveHandle(cached.handle))
540
+ return cached.handle;
495
541
  if (!cached.opening) {
496
542
  cached.opening = this.openCachedRoute(cached).finally(() => {
497
543
  cached.opening = null;
@@ -509,61 +555,45 @@ export class SubcClient {
509
555
  try {
510
556
  await this.ensureConnectedForManaged();
511
557
  }
512
- catch (err) {
513
- throw this.notSentRecoveryError("route.open could not run because reconnect failed", err);
514
- }
515
- if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
516
- return cached.channel;
558
+ catch (error) {
559
+ throw this.notSentRecoveryError("route.open could not run because reconnect failed", error);
517
560
  }
561
+ if (cached.handle && this.isLiveHandle(cached.handle))
562
+ return cached.handle;
518
563
  try {
519
- const channel = await this.routeOpen(cached.target, cached.identity, {
564
+ const handle = await this.routeOpen(cached.target, cached.identity, {
520
565
  consumerIdentity: cached.consumerIdentity ?? null,
521
566
  });
522
- // Generation guard: a closeRoute may have flipped the tombstone WHILE this
523
- // route.open was in flight. If so, close wins — do NOT install the channel
524
- // into the (already-removed) cache entry; GOODBYE the channel we just opened
525
- // so the daemon/module don't leak it, and fail as RouteClosed.
526
567
  if (cached.closed) {
527
- this.sendRouteGoodbye(channel);
568
+ this.liveRoutes.delete(handle.channel);
569
+ this.sendRouteGoodbye(handle);
528
570
  throw this.routeClosedDuringOpen();
529
571
  }
530
- cached.channel = channel;
531
- cached.generation = this.generation;
532
- return channel;
572
+ cached.handle = handle;
573
+ return handle;
533
574
  }
534
- catch (err) {
535
- if (err instanceof SubcCallError && err.code === "route_closed")
536
- throw err;
537
- 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)) {
538
579
  try {
539
- await this.reconnectAfterDrop(err);
580
+ await this.reconnectAfterDrop(error);
540
581
  }
541
- catch (reconnectErr) {
542
- 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);
543
584
  }
544
585
  continue;
545
586
  }
546
- // A daemon-rejected route.open with a RETRYABLE code (target booting /
547
- // reloading / momentarily absent) is retried IN-PLACE against the same live
548
- // connection — never a socket reconnect, which would needlessly disrupt this
549
- // connection's other routes — until the route-retry deadline. Past the
550
- // deadline it surfaces as not_sent: provably pre-send (no data frame ever
551
- // left the client) AND still transient, so the caller's own retry policy may
552
- // safely re-attempt later. Reason and set kept in parity with subc-client-rs.
553
- if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
587
+ if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
554
588
  routeRetryAttempt += 1;
555
589
  if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
556
590
  await this.opts.sleep(routeRetryDelay);
557
591
  routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
558
592
  continue;
559
593
  }
560
- 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);
561
595
  }
562
- // A permanent route.open rejection (bad_consumer_identity, config_divergence,
563
- // unknown_target, ...) is pre-send but would never succeed on retry, so it
564
- // stays terminal — a not_sent class here would invite a retry storm against a
565
- // request the daemon will always reject.
566
- throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
596
+ throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
567
597
  }
568
598
  }
569
599
  }
@@ -638,33 +668,27 @@ export class SubcClient {
638
668
  this.currentConn = opened.conn;
639
669
  this.closedErr = null;
640
670
  this.generation += 1;
671
+ this.connectionToken = newConnectionToken();
672
+ this.liveRoutes.clear();
673
+ this.lateResponses.clear();
674
+ this.nextCorr = 1n;
641
675
  void this.readLoop(opened.sock, this.generation);
642
676
  }
643
677
  async reopenCachedRoutes() {
644
- for (const cached of this.routes.values()) {
645
- cached.channel = null;
646
- cached.generation = 0;
647
- }
678
+ for (const cached of this.routes.values())
679
+ cached.handle = null;
648
680
  for (const cached of this.routes.values()) {
649
681
  if (cached.closed)
650
- continue; // closed concurrently with reconnect — don't reopen.
651
- // Thread the route's consumer identity through the reopen, exactly as the
652
- // lazy per-call path (openCachedRoute) does. Dropping it here would make a
653
- // route reopened after a reconnect send route.open with no consumer_identity,
654
- // so the daemon would re-stamp it with a different (weaker) principal than the
655
- // one it was originally bound under — a silent post-reconnect trust downgrade.
656
- const channel = await this.routeOpen(cached.target, cached.identity, {
682
+ continue;
683
+ const handle = await this.routeOpen(cached.target, cached.identity, {
657
684
  consumerIdentity: cached.consumerIdentity ?? null,
658
685
  });
659
- // A closeRoute may have raced this reopen (flipping the tombstone during the
660
- // route.open await). If so, GOODBYE the channel instead of installing it, so the
661
- // closed route isn't silently re-established on the new connection.
662
686
  if (cached.closed) {
663
- this.sendRouteGoodbye(channel);
687
+ this.liveRoutes.delete(handle.channel);
688
+ this.sendRouteGoodbye(handle);
664
689
  continue;
665
690
  }
666
- cached.channel = channel;
667
- cached.generation = this.generation;
691
+ cached.handle = handle;
668
692
  }
669
693
  }
670
694
  // A request timeout carries the local socket port and (channel, corr) so a
@@ -682,41 +706,39 @@ export class SubcClient {
682
706
  async readLoop(sock, generation) {
683
707
  try {
684
708
  for (;;) {
685
- // Header read waits indefinitely — idle time between frames is normal, and
686
- // the reader is NOT "active" while parked here (a racing timeout must not
687
- // grant grace just because the connection is idle between frames).
688
- const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
689
- // A frame is now arriving. Mark the reader active THROUGH dispatch so a
690
- // timeout that fires mid-arrival grants the reply its bounded grace window
691
- // instead of settling as a spurious timeout.
692
- 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
+ });
693
713
  try {
694
- const header = decodeHeader(headerBytes);
695
- const body = header.len === 0
696
- ? new Uint8Array(0)
697
- : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
698
- // Drop a frame read off a socket this client has already replaced
699
- // (reconnect): its pendings were settled by fail(), and dispatching it
700
- // against the current pending map could match a re-used (channel, corr).
701
- if (this.sock === sock && this.generation === generation) {
702
- this.dispatch({ header, body });
703
- }
714
+ if (this.sock === sock && this.generation === generation)
715
+ this.dispatch(frame);
704
716
  }
705
717
  finally {
706
718
  this.readerActive = false;
707
719
  }
708
720
  }
709
721
  }
710
- catch (err) {
722
+ catch (error) {
711
723
  if (this.sock === sock && this.generation === generation) {
712
- this.fail(err instanceof Error ? err : new SubcError(String(err)));
724
+ this.fail(error instanceof Error ? error : new SubcError(String(error)));
713
725
  }
714
726
  }
715
727
  }
716
728
  dispatch(frame) {
717
- 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);
718
738
  const pending = this.pending.get(key);
719
739
  if (pending) {
740
+ if (pending.acceptFrame && !pending.acceptFrame(frame))
741
+ return;
720
742
  switch (frame.header.ty) {
721
743
  case FrameType.Push:
722
744
  case FrameType.StreamData:
@@ -733,24 +755,24 @@ export class SubcClient {
733
755
  return;
734
756
  }
735
757
  }
736
- if (frame.header.ty === FrameType.Goodbye) {
737
- this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
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);
738
769
  return;
739
770
  }
740
- // A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
741
- // a reply that arrived AFTER its request already settled — the fingerprint of a
742
- // premature timeout under event-loop starvation (the reply raced the deadline
743
- // and lost). Metadata-only debug log (never the body) so every future
744
- // occurrence is a one-line diagnosis instead of an invisible drop. Enable with
745
- // NODE_DEBUG=subc-client.
746
771
  if (frame.header.ty === FrameType.Response ||
747
772
  frame.header.ty === FrameType.Error ||
748
773
  frame.header.ty === FrameType.StreamEnd) {
749
- 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() ?? "?");
750
- 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() ?? "?");
751
775
  }
752
- // Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
753
- // unsolicited-push consumers.
754
776
  }
755
777
  /**
756
778
  * Settle a pending exactly once. The object-identity guard (the map still
@@ -783,11 +805,16 @@ export class SubcClient {
783
805
  return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
784
806
  }
785
807
  }
786
- failChannel(channel, err) {
808
+ evictRouteHandle(handle) {
809
+ for (const cached of this.routes.values()) {
810
+ if (cached.handle && sameRouteHandle(cached.handle, handle))
811
+ cached.handle = null;
812
+ }
813
+ }
814
+ failHandle(handle, error) {
787
815
  for (const [key, pending] of this.pending) {
788
- if (pending.channel === channel) {
789
- this.rejectPending(key, pending, err);
790
- }
816
+ if (pending.handle && sameRouteHandle(pending.handle, handle))
817
+ this.rejectPending(key, pending, error);
791
818
  }
792
819
  }
793
820
  fail(err) {
@@ -815,6 +842,45 @@ export class SubcClient {
815
842
  return this.notSentCallError(message, cause);
816
843
  return this.terminalCallError(message, cause);
817
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
+ }
818
884
  encode(value) {
819
885
  return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
820
886
  }
@@ -911,4 +977,7 @@ function causeMessage(cause) {
911
977
  return "";
912
978
  return `: ${cause instanceof Error ? cause.message : String(cause)}`;
913
979
  }
980
+ function pendingKey(handle, corr) {
981
+ return handle ? `${handle.channel}:${handle.epoch}:${corr}` : `0:0:${corr}`;
982
+ }
914
983
  //# sourceMappingURL=client.js.map