@inkbox/sdk 0.5.13 → 0.5.15

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.
Files changed (47) hide show
  1. package/README.md +77 -7
  2. package/dist/_http.d.ts +3 -1
  3. package/dist/_http.d.ts.map +1 -1
  4. package/dist/_http.js +6 -2
  5. package/dist/_http.js.map +1 -1
  6. package/dist/a2a/index.d.ts +1 -0
  7. package/dist/a2a/index.d.ts.map +1 -1
  8. package/dist/a2a/index.js +1 -0
  9. package/dist/a2a/index.js.map +1 -1
  10. package/dist/a2a/invitations.d.ts +79 -0
  11. package/dist/a2a/invitations.d.ts.map +1 -0
  12. package/dist/a2a/invitations.js +165 -0
  13. package/dist/a2a/invitations.js.map +1 -0
  14. package/dist/agent_signup/types.d.ts +21 -0
  15. package/dist/agent_signup/types.d.ts.map +1 -1
  16. package/dist/agent_signup/types.js +14 -0
  17. package/dist/agent_signup/types.js.map +1 -1
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/inkbox.d.ts +9 -2
  23. package/dist/inkbox.d.ts.map +1 -1
  24. package/dist/inkbox.js +44 -10
  25. package/dist/inkbox.js.map +1 -1
  26. package/dist/tunnels/client/_listener.d.ts +12 -3
  27. package/dist/tunnels/client/_listener.d.ts.map +1 -1
  28. package/dist/tunnels/client/_listener.js +13 -23
  29. package/dist/tunnels/client/_listener.js.map +1 -1
  30. package/dist/tunnels/client/_runtime.d.ts +34 -2
  31. package/dist/tunnels/client/_runtime.d.ts.map +1 -1
  32. package/dist/tunnels/client/_runtime.js +335 -157
  33. package/dist/tunnels/client/_runtime.js.map +1 -1
  34. package/dist/tunnels/client/_ws.d.ts +6 -0
  35. package/dist/tunnels/client/_ws.d.ts.map +1 -1
  36. package/dist/tunnels/client/_ws.js +9 -0
  37. package/dist/tunnels/client/_ws.js.map +1 -1
  38. package/dist/tunnels/client/_ws_url_edge_bridge.d.ts.map +1 -1
  39. package/dist/tunnels/client/_ws_url_edge_bridge.js +6 -7
  40. package/dist/tunnels/client/_ws_url_edge_bridge.js.map +1 -1
  41. package/dist/tunnels/client/index.d.ts +2 -2
  42. package/dist/tunnels/client/index.d.ts.map +1 -1
  43. package/dist/tunnels/client/index.js +1 -1
  44. package/dist/tunnels/client/index.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
@@ -17,6 +17,7 @@
17
17
  * stack against a real flow-control sequence.
18
18
  */
19
19
  import * as http2 from "node:http2";
20
+ import { AsyncLocalStorage } from "node:async_hooks";
20
21
  import { setTimeout as setTimeoutPromise } from "node:timers/promises";
21
22
  import { BRIDGE_CLEANUP_SEND_TIMEOUT_MS, BRIDGE_CLOSE_CODE, BRIDGE_HALF_CLOSE_GRACE_MS, BRIDGE_STATUS_TIMEOUT_MS, BridgeProtocolError, BridgeStreamReset, makeBridgeStats, } from "./_bridge.js";
22
23
  import { filterResponseHeaders, parseEnvelope, } from "./_envelope.js";
@@ -25,7 +26,7 @@ import { ControlHeaders, ControlPaths, HOP_BY_HOP_RESPONSE, INKBOX_FORWARDED_HEA
25
26
  import { validateEnvelopePath } from "./_validation.js";
26
27
  import { createUndiciAgentCache, forwardEnvelopeToUrl, } from "./_url_forward.js";
27
28
  import { WS_OPCODE_BINARY, WS_OPCODE_CLOSE, WS_OPCODE_CONTINUATION, WS_OPCODE_PING, WS_OPCODE_PONG, WS_OPCODE_TEXT, WsFrameDecoder, encodeWsEnvelope, encodeWsFrame, } from "./_wsframe.js";
28
- import { dispatchWsUpgradeInProcess, WsServerDraining, } from "./_ws.js";
29
+ import { dispatchWsUpgradeInProcess, WsConnectionLost, WsServerDraining, } from "./_ws.js";
29
30
  const HTTP2_HEADER_METHOD = http2.constants.HTTP2_HEADER_METHOD;
30
31
  const HTTP2_HEADER_PATH = http2.constants.HTTP2_HEADER_PATH;
31
32
  const HTTP2_HEADER_SCHEME = http2.constants.HTTP2_HEADER_SCHEME;
@@ -38,6 +39,9 @@ export const PING_INTERVAL_MS = 20_000;
38
39
  * a dead TCP doesn't strand the runtime past the next intake.
39
40
  */
40
41
  export const PING_ACK_TIMEOUT_MS = 10_000;
42
+ export const CONNECT_TIMEOUT_MS = 10_000;
43
+ export const HELLO_TIMEOUT_MS = 15_000;
44
+ export const INITIAL_BACKOFF_MS = 1_000;
41
45
  export const BACKOFF_CAP_SEC = 30.0;
42
46
  export const BACKOFF_JITTER = 0.25;
43
47
  // On drain, keep a post-GOAWAY connection alive for its in-flight bridges
@@ -77,6 +81,14 @@ export class TunnelSupersededError extends Error {
77
81
  this.name = "TunnelSupersededError";
78
82
  }
79
83
  }
84
+ class TunnelPhaseTimeoutError extends Error {
85
+ phase;
86
+ constructor(phase, timeoutMs) {
87
+ super(`tunnel ${phase} timed out after ${timeoutMs}ms`);
88
+ this.phase = phase;
89
+ this.name = "TunnelPhaseTimeoutError";
90
+ }
91
+ }
80
92
  class OwnerTokenInvalidError extends Error {
81
93
  constructor(message) {
82
94
  super(message);
@@ -145,9 +157,17 @@ class Connection {
145
157
  streams = new Map();
146
158
  bridgeStreamIds = new Set();
147
159
  pingHandle = null;
148
- pingAbort = null;
160
+ pingAckHandles = new Set();
161
+ closed;
162
+ resolveClosed;
163
+ closePublished = false;
164
+ detachTransportListeners = null;
165
+ cancelConnectWait = null;
149
166
  constructor(id) {
150
167
  this.id = id;
168
+ this.closed = new Promise((resolve) => {
169
+ this.resolveClosed = resolve;
170
+ });
151
171
  }
152
172
  /** Live WS/TCP bridge count — drives the drain-quiescent check. */
153
173
  get liveBridges() {
@@ -174,6 +194,12 @@ export class TunnelRuntime {
174
194
  onStatus;
175
195
  rng;
176
196
  http2Connect;
197
+ connectTimeoutMs;
198
+ helloTimeoutMs;
199
+ pingIntervalMs;
200
+ pingAckTimeoutMs;
201
+ handoffRedialBudgetMs;
202
+ sleep;
177
203
  // The pool that parks new intakes. Swapped atomically on handoff.
178
204
  active = null;
179
205
  // Post-GOAWAY connections finishing in-flight work before close.
@@ -203,6 +229,14 @@ export class TunnelRuntime {
203
229
  // in aclose().
204
230
  undiciAgentCache = createUndiciAgentCache();
205
231
  shutdownAbort = new AbortController();
232
+ runtimeStatus = "idle";
233
+ lastConnectedAtMs = null;
234
+ hasConnected = false;
235
+ coldAttempt = 0;
236
+ terminalError = null;
237
+ pendingConnection = null;
238
+ coldGeneration = 0;
239
+ dispatchGeneration = new AsyncLocalStorage();
206
240
  constructor(opts) {
207
241
  this.tunnelId = opts.tunnelId;
208
242
  this.apiKey = opts.apiKey;
@@ -218,21 +252,44 @@ export class TunnelRuntime {
218
252
  this.onStatus = opts.onStatus;
219
253
  this.rng = opts.rng ?? Math.random;
220
254
  this.http2Connect = opts.http2Connect ?? http2.connect.bind(http2);
255
+ this.connectTimeoutMs = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
256
+ this.helloTimeoutMs = opts.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
257
+ this.pingIntervalMs = opts.pingIntervalMs ?? PING_INTERVAL_MS;
258
+ this.pingAckTimeoutMs = opts.pingAckTimeoutMs ?? PING_ACK_TIMEOUT_MS;
259
+ this.handoffRedialBudgetMs =
260
+ opts.handoffRedialBudgetMs ?? HANDOFF_REDIAL_BUDGET_MS;
261
+ this.sleep = opts.sleep ?? ((delayMs, signal) => setTimeoutPromise(delayMs, undefined, { signal }));
221
262
  }
222
263
  // --- public lifecycle ---------------------------------------------------
264
+ get status() {
265
+ return this.runtimeStatus;
266
+ }
267
+ get isConnected() {
268
+ return this.runtimeStatus === "connected";
269
+ }
270
+ get lastConnectedAt() {
271
+ return this.lastConnectedAtMs === null
272
+ ? null
273
+ : new Date(this.lastConnectedAtMs);
274
+ }
223
275
  /**
224
276
  * Drive the runtime forever. Reconnects with jittered exponential
225
277
  * backoff; rejects only on permanent auth failure (rotate the
226
278
  * secret) or after `aclose()`.
227
279
  */
228
280
  async serveForever() {
229
- let backoff = 1.0;
281
+ let backoff = INITIAL_BACKOFF_MS / 1000;
230
282
  let consecutiveFailures = 0;
231
- this.notifyStatus("connecting");
232
283
  while (!this.stop) {
284
+ this.coldAttempt += 1;
285
+ const attempt = this.coldAttempt;
286
+ if (attempt === 1)
287
+ this.notifyStatus("connecting");
288
+ // eslint-disable-next-line no-console
289
+ console.info(`tunnel runtime: connection attempt #${attempt}`);
233
290
  try {
234
291
  await this.runOnce();
235
- backoff = 1.0;
292
+ backoff = INITIAL_BACKOFF_MS / 1000;
236
293
  consecutiveFailures = 0;
237
294
  }
238
295
  catch (err) {
@@ -249,9 +306,21 @@ export class TunnelRuntime {
249
306
  if (this.superseded) {
250
307
  this.stopSuperseded();
251
308
  }
309
+ if (this.stop) {
310
+ if (this.runtimeStatus !== "superseded")
311
+ this.notifyStatus("closed");
312
+ return;
313
+ }
252
314
  consecutiveFailures += 1;
253
- // eslint-disable-next-line no-console
254
- console.warn(`tunnel runtime: connection error (#${consecutiveFailures}); reconnecting`, err);
315
+ if (err instanceof TunnelPhaseTimeoutError) {
316
+ // eslint-disable-next-line no-console
317
+ console.warn(`tunnel runtime: ${err.phase} timeout on attempt #${attempt}; reconnecting`);
318
+ }
319
+ else {
320
+ // eslint-disable-next-line no-console
321
+ console.warn(`tunnel runtime: connection error on attempt #${attempt} ` +
322
+ `(consecutive failure #${consecutiveFailures}); reconnecting`, err);
323
+ }
255
324
  this.notifyStatus("reconnecting");
256
325
  }
257
326
  if (this.stop) {
@@ -264,10 +333,11 @@ export class TunnelRuntime {
264
333
  // backoff = min(backoff * 2, 30.0)
265
334
  const jitter = backoff * BACKOFF_JITTER * (2 * this.rng() - 1);
266
335
  const sleepFor = Math.max(0.1, backoff + jitter);
336
+ const delayMs = sleepFor * 1000;
337
+ // eslint-disable-next-line no-console
338
+ console.info(`tunnel runtime: retrying with attempt #${attempt + 1} in ${delayMs}ms`);
267
339
  try {
268
- await setTimeoutPromise(sleepFor * 1000, undefined, {
269
- signal: this.shutdownAbort.signal,
270
- });
340
+ await this.sleep(delayMs, this.shutdownAbort.signal);
271
341
  }
272
342
  catch {
273
343
  // aborted by aclose()
@@ -281,6 +351,9 @@ export class TunnelRuntime {
281
351
  /** Graceful shutdown. Signals all loops to exit; closes every conn. */
282
352
  async aclose() {
283
353
  this.stop = true;
354
+ this.coldGeneration += 1;
355
+ if (this.runtimeStatus !== "superseded")
356
+ this.notifyStatus("closed");
284
357
  this.shutdownAbort.abort();
285
358
  if (this.passthroughDispatch !== null) {
286
359
  try {
@@ -299,10 +372,12 @@ export class TunnelRuntime {
299
372
  }
300
373
  // Close active + every draining conn; stop each one's ping loop so no
301
374
  // ping loop leaks across the handoff set.
302
- const conns = [this.active, ...this.draining].filter((c) => c !== null);
303
- for (const conn of conns) {
375
+ const conns = [this.active, this.pendingConnection, ...this.draining].filter((c) => c !== null);
376
+ for (const conn of new Set(conns)) {
304
377
  this.stopPingLoop(conn);
305
378
  await this.closeConnection(conn);
379
+ this.publishConnectionClosed(conn);
380
+ conn.session = null;
306
381
  }
307
382
  }
308
383
  /**
@@ -310,55 +385,118 @@ export class TunnelRuntime {
310
385
  * short grace. The intake pool parks streams indefinitely, so a plain
311
386
  * `close()` would never resolve; we GOAWAY then destroy after 250ms.
312
387
  */
313
- async closeConnection(conn) {
388
+ async closeConnection(conn, graceMs = 250) {
314
389
  const session = conn.session;
315
- conn.session = null;
316
- if (session === null || session.closed)
390
+ if (session === null || session.closed || session.destroyed) {
391
+ this.publishConnectionClosed(conn);
317
392
  return;
393
+ }
318
394
  try {
319
395
  session.goaway();
320
396
  }
321
397
  catch {
322
398
  /* swallow */
323
399
  }
400
+ if (graceMs <= 0) {
401
+ this.destroyConnection(conn);
402
+ return;
403
+ }
324
404
  await new Promise((resolve) => {
325
405
  const t = setTimeout(() => {
326
406
  try {
327
407
  session.destroy();
328
408
  }
329
- catch {
330
- /* swallow */
331
- }
332
- resolve();
333
- }, 250);
334
- session.once("close", () => {
409
+ catch { /* swallow */ }
410
+ setImmediate(() => {
411
+ this.publishConnectionClosed(conn);
412
+ });
413
+ }, graceMs);
414
+ void conn.closed.then(() => {
335
415
  clearTimeout(t);
336
- resolve();
416
+ setTimeout(resolve, 10);
337
417
  });
338
418
  try {
339
419
  session.close();
340
420
  }
341
421
  catch {
342
422
  clearTimeout(t);
343
- try {
344
- session.destroy();
345
- }
346
- catch {
347
- /* swallow */
348
- }
349
- resolve();
423
+ this.destroyConnection(conn);
350
424
  }
351
425
  });
352
426
  }
427
+ publishConnectionClosed(conn) {
428
+ if (!conn.closePublished) {
429
+ conn.closePublished = true;
430
+ for (const [, bus] of conn.streams) {
431
+ if (!bus.ended) {
432
+ bus.events.push({ kind: "reset", code: 0 });
433
+ bus.ended = true;
434
+ this.wake(bus);
435
+ }
436
+ }
437
+ conn.resolveClosed();
438
+ }
439
+ conn.detachTransportListeners?.();
440
+ conn.detachTransportListeners = null;
441
+ conn.cancelConnectWait?.();
442
+ conn.cancelConnectWait = null;
443
+ }
444
+ destroyConnection(conn) {
445
+ const session = conn.session;
446
+ this.publishConnectionClosed(conn);
447
+ try {
448
+ session?.destroy();
449
+ }
450
+ catch { /* swallow */ }
451
+ }
353
452
  // --- per-connection lifecycle -----------------------------------------
453
+ async withPhaseTimeout(conn, phase, timeoutMs, operation) {
454
+ let timer = null;
455
+ let timeoutError = null;
456
+ const timeout = new Promise((_, reject) => {
457
+ timer = setTimeout(() => {
458
+ timeoutError = new TunnelPhaseTimeoutError(phase, timeoutMs);
459
+ this.destroyConnection(conn);
460
+ reject(timeoutError);
461
+ }, Math.max(1, timeoutMs));
462
+ });
463
+ void operation.catch(() => undefined);
464
+ try {
465
+ return await Promise.race([operation, timeout]);
466
+ }
467
+ catch (err) {
468
+ if (timeoutError !== null &&
469
+ !(err instanceof TunnelAuthError) &&
470
+ !(err instanceof TunnelSupersededError)) {
471
+ throw timeoutError;
472
+ }
473
+ throw err;
474
+ }
475
+ finally {
476
+ if (timer !== null)
477
+ clearTimeout(timer);
478
+ }
479
+ }
480
+ markConnected(emitStatus) {
481
+ const recovered = this.hasConnected;
482
+ this.hasConnected = true;
483
+ this.lastConnectedAtMs = Date.now();
484
+ if (emitStatus) {
485
+ this.notifyStatus("connected");
486
+ // eslint-disable-next-line no-console
487
+ console.info(recovered
488
+ ? "tunnel runtime: connection recovered"
489
+ : "tunnel runtime: initial connection established");
490
+ }
491
+ }
354
492
  async runOnce() {
355
493
  const first = new Connection(this.nextConnId++);
356
494
  let conn = first;
357
495
  this.active = first;
358
496
  try {
359
- await this.openConnection(first);
360
- await this.sendHello(first);
361
- this.notifyStatus("connected");
497
+ await this.withPhaseTimeout(first, "connect", this.connectTimeoutMs, this.openConnection(first));
498
+ await this.withPhaseTimeout(first, "hello", this.helloTimeoutMs, this.sendHello(first));
499
+ this.markConnected(true);
362
500
  this.startServing(first);
363
501
  // Supervise the active connection. A GOAWAY handoff swaps in a new
364
502
  // active conn out-of-band; follow it without going through the
@@ -374,6 +512,8 @@ export class TunnelRuntime {
374
512
  if (this.handoffInFlight && this.handoffPromise !== null) {
375
513
  await this.handoffPromise;
376
514
  }
515
+ if (this.terminalError !== null)
516
+ throw this.terminalError;
377
517
  const next = this.active;
378
518
  if (next !== null && next !== conn && !next.draining) {
379
519
  conn = next;
@@ -387,11 +527,20 @@ export class TunnelRuntime {
387
527
  if (this.superseded) {
388
528
  throw new TunnelSupersededError("another client connected to this tunnel; not reconnecting");
389
529
  }
530
+ if (!this.stop) {
531
+ // eslint-disable-next-line no-console
532
+ console.warn("tunnel runtime: connected session lost; reconnecting");
533
+ this.notifyStatus("reconnecting");
534
+ }
390
535
  }
391
536
  finally {
537
+ if (!this.stop)
538
+ this.coldGeneration += 1;
392
539
  this.stopPingLoop(conn);
393
540
  conn.streams.clear();
394
541
  conn.bridgeStreamIds.clear();
542
+ await this.closeConnection(conn);
543
+ this.publishConnectionClosed(conn);
395
544
  conn.session = null;
396
545
  if (this.active === conn)
397
546
  this.active = null;
@@ -409,18 +558,10 @@ export class TunnelRuntime {
409
558
  }
410
559
  /** Resolve once the supervised conn closes OR a handoff swaps active. */
411
560
  waitCloseOrHandoff(conn) {
412
- const closed = new Promise((resolve) => {
413
- const session = conn.session;
414
- if (session === null || session.closed) {
415
- resolve();
416
- return;
417
- }
418
- session.once("close", () => resolve());
419
- });
420
561
  const woken = new Promise((resolve) => {
421
562
  this.wakeSupervisor = resolve;
422
563
  });
423
- return Promise.race([closed, woken]).finally(() => {
564
+ return Promise.race([conn.closed, woken]).finally(() => {
424
565
  this.wakeSupervisor = null;
425
566
  });
426
567
  }
@@ -507,6 +648,7 @@ export class TunnelRuntime {
507
648
  try {
508
649
  const newConn = await this.makeReplacementConnection();
509
650
  this.active = newConn;
651
+ this.markConnected(false);
510
652
  // Supervisor was watching oldConn; wake it to follow newConn.
511
653
  this.signalSupervisor();
512
654
  }
@@ -515,10 +657,12 @@ export class TunnelRuntime {
515
657
  // External takeover during the handoff hello: `superseded` is set, so
516
658
  // end the old conn and wake the supervisor to stop terminally (no cold
517
659
  // redial, which would boot the client that replaced us).
518
- try {
519
- oldConn.session?.destroy();
520
- }
521
- catch { /* swallow */ }
660
+ this.destroyConnection(oldConn);
661
+ this.signalSupervisor();
662
+ }
663
+ else if (err instanceof TunnelAuthError) {
664
+ this.terminalError = err;
665
+ this.destroyConnection(oldConn);
522
666
  this.signalSupervisor();
523
667
  }
524
668
  else {
@@ -527,10 +671,7 @@ export class TunnelRuntime {
527
671
  // the old session closed so the supervisor returns.
528
672
  // eslint-disable-next-line no-console
529
673
  console.warn("tunnel runtime: handoff failed; reconnecting cold", err);
530
- try {
531
- oldConn.session?.destroy();
532
- }
533
- catch { /* swallow */ }
674
+ this.destroyConnection(oldConn);
534
675
  this.signalSupervisor();
535
676
  }
536
677
  }
@@ -544,20 +685,33 @@ export class TunnelRuntime {
544
685
  /** Dial + hello + park a replacement, retrying transient hello failures. */
545
686
  async makeReplacementConnection() {
546
687
  let backoff = 0.1;
547
- const start = Date.now();
688
+ const deadline = performance.now() + this.handoffRedialBudgetMs;
548
689
  while (!this.stop) {
690
+ let remaining = deadline - performance.now();
691
+ if (remaining <= 0)
692
+ throw new Error("handoff redial budget exhausted");
549
693
  const conn = new Connection(this.nextConnId++);
694
+ this.pendingConnection = conn;
550
695
  try {
551
- await this.openConnection(conn);
552
- await this.sendHello(conn);
696
+ await this.withPhaseTimeout(conn, "connect", Math.min(this.connectTimeoutMs, remaining), this.openConnection(conn));
697
+ remaining = deadline - performance.now();
698
+ if (remaining <= 0)
699
+ throw new Error("handoff redial budget exhausted");
700
+ await this.withPhaseTimeout(conn, "hello", Math.min(this.helloTimeoutMs, remaining), this.sendHello(conn));
553
701
  this.startServing(conn);
702
+ this.pendingConnection = null;
554
703
  return conn;
555
704
  }
556
705
  catch (err) {
706
+ remaining = deadline - performance.now();
557
707
  try {
558
- await this.closeConnection(conn);
708
+ await this.closeConnection(conn, Math.min(250, Math.max(0, remaining)));
559
709
  }
560
710
  catch { /* swallow */ }
711
+ this.publishConnectionClosed(conn);
712
+ conn.session = null;
713
+ if (this.pendingConnection === conn)
714
+ this.pendingConnection = null;
561
715
  // A rejected key or a takeover is terminal; never retry either
562
716
  // (retrying a takeover would boot the client that replaced us).
563
717
  if (err instanceof TunnelAuthError || err instanceof TunnelSupersededError) {
@@ -569,15 +723,15 @@ export class TunnelRuntime {
569
723
  if (this.superseded) {
570
724
  throw new TunnelSupersededError("another client connected to this tunnel during handoff");
571
725
  }
572
- if (Date.now() - start > HANDOFF_REDIAL_BUDGET_MS) {
726
+ remaining = deadline - performance.now();
727
+ if (remaining <= 0) {
573
728
  throw new Error("handoff redial budget exhausted");
574
729
  }
575
730
  // A drain 503 on the new hello means the NLB landed us back on the
576
731
  // draining task; back off (jittered) so it re-routes us elsewhere.
577
732
  const jitter = backoff * BACKOFF_JITTER * (2 * this.rng() - 1);
578
- await setTimeoutPromise(Math.max(50, (backoff + jitter) * 1000), undefined, {
579
- signal: this.shutdownAbort.signal,
580
- }).catch(() => undefined);
733
+ const delayMs = Math.min(Math.max(50, (backoff + jitter) * 1000), remaining);
734
+ await this.sleep(delayMs, this.shutdownAbort.signal).catch(() => undefined);
581
735
  backoff = Math.min(backoff * 2, 5.0);
582
736
  }
583
737
  }
@@ -592,6 +746,8 @@ export class TunnelRuntime {
592
746
  await setTimeoutPromise(250).catch(() => undefined);
593
747
  }
594
748
  await this.closeConnection(oldConn);
749
+ this.publishConnectionClosed(oldConn);
750
+ oldConn.session = null;
595
751
  oldConn.streams.clear();
596
752
  oldConn.bridgeStreamIds.clear();
597
753
  this.draining.delete(oldConn);
@@ -607,27 +763,27 @@ export class TunnelRuntime {
607
763
  // (Spike 1) — the setting line doesn't translate.
608
764
  });
609
765
  conn.session = session;
610
- session.on("close", () => {
766
+ const socket = (() => {
767
+ try {
768
+ return session.socket ?? null;
769
+ }
770
+ catch {
771
+ return null;
772
+ }
773
+ })();
774
+ const onSessionClose = () => {
611
775
  // eslint-disable-next-line no-console
612
776
  console.info("tunnel runtime: h2 session closed");
613
- // Drain all open streams with a synthetic reset event so any
614
- // awaiters wake up.
615
- for (const [, bus] of conn.streams) {
616
- if (!bus.ended) {
617
- bus.events.push({ kind: "reset", code: 0 });
618
- bus.ended = true;
619
- this.wake(bus);
620
- }
621
- }
622
- });
623
- session.on("error", (err) => {
777
+ this.publishConnectionClosed(conn);
778
+ };
779
+ const onSessionError = (err) => {
624
780
  // Visibility into session-fatal errors. Stream-level errors
625
781
  // surface separately via stream events; this is genuinely
626
782
  // session-terminal.
627
783
  // eslint-disable-next-line no-console
628
784
  console.warn("tunnel runtime: h2 session error", err);
629
- });
630
- session.on("goaway", (errorCode, lastStreamId, opaqueData) => {
785
+ };
786
+ const onGoaway = (errorCode, lastStreamId, opaqueData) => {
631
787
  // eslint-disable-next-line no-console
632
788
  console.info(`tunnel runtime: GOAWAY received error_code=${errorCode} last_stream_id=${lastStreamId}`);
633
789
  // NO_ERROR GOAWAY = drain (make-before-break handoff). A non-zero code
@@ -640,51 +796,69 @@ export class TunnelRuntime {
640
796
  else {
641
797
  this.maybeMarkSupersededGoaway(conn, errorCode, opaqueData);
642
798
  }
643
- });
644
- // Watch the underlying TCP/TLS socket directly. Node's h2 client
645
- // sometimes loses the connection without emitting ``error`` or
646
- // ``close`` on the session itself — the underlying socket reliably
647
- // emits them. Force-destroy the session on either so
648
- // ``waitForSessionClose`` resolves promptly and ``serveForever``
649
- // reconnects without waiting for the ``PING_ACK_TIMEOUT_MS`` window.
650
- try {
651
- const sock = session.socket;
652
- const onSocketDeath = (label, err) => {
799
+ };
800
+ const onSocketClose = (hadError) => {
801
+ onSocketDeath(`closed hadError=${hadError}`);
802
+ };
803
+ const onSocketError = (err) => {
804
+ onSocketDeath("error", err);
805
+ };
806
+ const onSocketDeath = (label, err) => {
807
+ // eslint-disable-next-line no-console
808
+ console.info(`tunnel runtime: underlying socket ${label}` +
809
+ (err !== undefined ? ` err=${err.message}` : ""));
810
+ if (!session.closed && !session.destroyed) {
653
811
  // eslint-disable-next-line no-console
654
- console.info(`tunnel runtime: underlying socket ${label}` +
655
- (err !== undefined ? ` err=${err.message}` : ""));
656
- if (!session.closed && !session.destroyed) {
657
- // Forensic — log the stack so we can see which path
658
- // triggered this destroy in production.
659
- // eslint-disable-next-line no-console
660
- console.warn("tunnel runtime: forcing session.destroy() from socket-death", new Error("trace").stack);
661
- try {
662
- session.destroy();
663
- }
664
- catch { /* swallow */ }
665
- }
666
- };
667
- sock?.once?.("close", (hadError) => {
668
- onSocketDeath(`closed hadError=${hadError}`);
669
- });
670
- sock?.once?.("error", (err) => {
671
- onSocketDeath("error", err);
672
- });
673
- }
674
- catch {
675
- /* swallow */
676
- }
812
+ console.warn("tunnel runtime: forcing session.destroy() from socket-death");
813
+ this.destroyConnection(conn);
814
+ }
815
+ };
816
+ session.on("close", onSessionClose);
817
+ session.on("error", onSessionError);
818
+ session.on("goaway", onGoaway);
819
+ socket?.once("close", onSocketClose);
820
+ socket?.once("error", onSocketError);
821
+ conn.detachTransportListeners = () => {
822
+ session.off("close", onSessionClose);
823
+ session.off("error", onSessionError);
824
+ session.off("goaway", onGoaway);
825
+ try {
826
+ socket?.off("close", onSocketClose);
827
+ socket?.off("error", onSocketError);
828
+ }
829
+ catch {
830
+ /* the HTTP/2 socket proxy becomes inaccessible after disconnect */
831
+ }
832
+ };
677
833
  await new Promise((resolve, reject) => {
678
- const onConnect = () => {
834
+ let settled = false;
835
+ const cleanup = () => {
836
+ session.off("connect", onConnect);
679
837
  session.off("error", onError);
838
+ session.off("close", onClose);
839
+ conn.cancelConnectWait = null;
840
+ };
841
+ const succeed = () => {
842
+ if (settled)
843
+ return;
844
+ settled = true;
845
+ cleanup();
680
846
  resolve();
681
847
  };
682
- const onError = (err) => {
683
- session.off("connect", onConnect);
848
+ const fail = (err) => {
849
+ if (settled)
850
+ return;
851
+ settled = true;
852
+ cleanup();
684
853
  reject(err);
685
854
  };
855
+ const onConnect = () => succeed();
856
+ const onError = (err) => fail(err);
857
+ const onClose = () => fail(new Error("h2 session closed before connect completed"));
858
+ conn.cancelConnectWait = () => fail(new Error("h2 session closed before connect completed"));
686
859
  session.once("connect", onConnect);
687
860
  session.once("error", onError);
861
+ session.once("close", onClose);
688
862
  });
689
863
  // OS-level keepalive on the underlying TCP socket so a silently-
690
864
  // dropped connection (NAT timeout, NLB idle eviction, peer power-
@@ -693,25 +867,12 @@ export class TunnelRuntime {
693
867
  // tracking (see startPingLoop) is the load-bearing detector;
694
868
  // this is defense-in-depth.
695
869
  try {
696
- const sock = session.socket;
697
- sock?.setKeepAlive?.(true, 30_000);
870
+ socket?.setKeepAlive?.(true, 30_000);
698
871
  }
699
872
  catch {
700
873
  /* swallow */
701
874
  }
702
875
  }
703
- waitForSessionClose(conn) {
704
- const session = conn.session;
705
- if (session === null)
706
- return Promise.resolve();
707
- return new Promise((resolve) => {
708
- if (session.closed) {
709
- resolve();
710
- return;
711
- }
712
- session.once("close", () => resolve());
713
- });
714
- }
715
876
  // --- handshake ---------------------------------------------------------
716
877
  async sendHello(conn) {
717
878
  conn.ownerToken = null;
@@ -731,7 +892,14 @@ export class TunnelRuntime {
731
892
  helloHeaders[ControlHeaders.POOL_SIZE] = String(this.poolSize);
732
893
  }
733
894
  const stream = this.openStream(conn, helloHeaders, { endStream: true });
734
- const { status, body } = await this.awaitResponse(conn, stream.streamId);
895
+ let status;
896
+ let body;
897
+ try {
898
+ ({ status, body } = await this.awaitResponse(conn, stream.streamId));
899
+ }
900
+ finally {
901
+ conn.streams.delete(stream.streamId);
902
+ }
735
903
  if (status === 401 || status === 403) {
736
904
  throw new TunnelAuthError(`${ControlPaths.HELLO} returned ${status}; the API key was rejected (check the key matches the tunnel's identity scope, or use an admin-scoped key in the tunnel's org)`);
737
905
  }
@@ -881,7 +1049,8 @@ export class TunnelRuntime {
881
1049
  while (!this.stop &&
882
1050
  !conn.draining &&
883
1051
  conn.session !== null &&
884
- !conn.session.closed) {
1052
+ !conn.session.closed &&
1053
+ !conn.session.destroyed) {
885
1054
  let envelope;
886
1055
  try {
887
1056
  envelope = await this.parkOneIntake(conn, slot);
@@ -891,17 +1060,14 @@ export class TunnelRuntime {
891
1060
  // Another client took over: force this conn down so the supervisor
892
1061
  // observes the terminal flag and stops (no reconnect).
893
1062
  this.markSuperseded();
894
- try {
895
- conn.session?.destroy();
896
- }
897
- catch { /* swallow */ }
1063
+ this.destroyConnection(conn);
898
1064
  return;
899
1065
  }
900
1066
  if (err instanceof OwnerTokenInvalidError) {
901
1067
  // eslint-disable-next-line no-console
902
1068
  console.warn(`intake slot ${slot}: owner_token rejected; ` +
903
1069
  `forcing session.destroy() and reconnecting`);
904
- conn.session?.destroy();
1070
+ this.destroyConnection(conn);
905
1071
  return;
906
1072
  }
907
1073
  if (isSessionTerminalError(err) || conn.session?.destroyed) {
@@ -916,10 +1082,7 @@ export class TunnelRuntime {
916
1082
  console.warn(`intake slot ${slot}: h2 session terminal (` +
917
1083
  `${err?.code ?? "no code"}); ` +
918
1084
  `exiting slot`, err);
919
- try {
920
- conn.session?.destroy();
921
- }
922
- catch { /* swallow */ }
1085
+ this.destroyConnection(conn);
923
1086
  return;
924
1087
  }
925
1088
  // eslint-disable-next-line no-console
@@ -932,7 +1095,10 @@ export class TunnelRuntime {
932
1095
  // Fire-and-forget dispatch; tracked on the runtime (not the conn) so
933
1096
  // an in-flight handler survives this conn draining and can post its
934
1097
  // reply on the new active conn during a handoff.
935
- const task = this.dispatchEnvelope(conn, envelope).catch((err) => {
1098
+ const generation = this.coldGeneration;
1099
+ const task = this.dispatchGeneration
1100
+ .run(generation, () => this.dispatchEnvelope(conn, envelope))
1101
+ .catch((err) => {
936
1102
  // eslint-disable-next-line no-console
937
1103
  console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
938
1104
  });
@@ -1003,10 +1169,9 @@ export class TunnelRuntime {
1003
1169
  }
1004
1170
  // --- ping loop ---------------------------------------------------------
1005
1171
  startPingLoop(conn) {
1006
- conn.pingAbort = new AbortController();
1007
1172
  conn.pingHandle = setInterval(() => {
1008
1173
  const session = conn.session;
1009
- if (session === null || session.closed)
1174
+ if (session === null || session.closed || session.destroyed)
1010
1175
  return;
1011
1176
  let ackTimer = null;
1012
1177
  let acked = false;
@@ -1015,27 +1180,24 @@ export class TunnelRuntime {
1015
1180
  acked = true;
1016
1181
  if (ackTimer !== null) {
1017
1182
  clearTimeout(ackTimer);
1183
+ conn.pingAckHandles.delete(ackTimer);
1018
1184
  ackTimer = null;
1019
1185
  }
1020
1186
  if (err !== null && err !== undefined) {
1021
1187
  // eslint-disable-next-line no-console
1022
1188
  console.warn("tunnel runtime: PING errored; forcing session.destroy()", err);
1023
- try {
1024
- session.destroy();
1025
- }
1026
- catch { /* swallow */ }
1189
+ this.destroyConnection(conn);
1027
1190
  }
1028
1191
  });
1029
1192
  }
1030
1193
  catch (err) {
1031
1194
  // eslint-disable-next-line no-console
1032
1195
  console.warn("tunnel runtime: session.ping() threw synchronously; forcing destroy", err);
1033
- try {
1034
- session.destroy();
1035
- }
1036
- catch { /* swallow */ }
1196
+ this.destroyConnection(conn);
1037
1197
  return;
1038
1198
  }
1199
+ if (acked)
1200
+ return;
1039
1201
  // Application-level liveness check: if the ack doesn't come
1040
1202
  // back within PING_ACK_TIMEOUT_MS, the underlying TCP is gone
1041
1203
  // (kernel send buffer absorbing writes silently is the typical
@@ -1043,18 +1205,18 @@ export class TunnelRuntime {
1043
1205
  // without our help). Force-destroy the session; serveForever
1044
1206
  // observes the close and reconnects.
1045
1207
  ackTimer = setTimeout(() => {
1208
+ if (ackTimer !== null)
1209
+ conn.pingAckHandles.delete(ackTimer);
1046
1210
  if (acked)
1047
1211
  return;
1048
1212
  // eslint-disable-next-line no-console
1049
1213
  console.warn(`tunnel runtime: PING ack not received within ` +
1050
- `${PING_ACK_TIMEOUT_MS}ms; assuming dead connection, ` +
1214
+ `${this.pingAckTimeoutMs}ms; assuming dead connection, ` +
1051
1215
  `forcing reconnect`);
1052
- try {
1053
- session.destroy();
1054
- }
1055
- catch { /* swallow */ }
1056
- }, PING_ACK_TIMEOUT_MS);
1057
- }, PING_INTERVAL_MS);
1216
+ this.destroyConnection(conn);
1217
+ }, this.pingAckTimeoutMs);
1218
+ conn.pingAckHandles.add(ackTimer);
1219
+ }, this.pingIntervalMs);
1058
1220
  // Do NOT unref(): explicit cancellation in stopPingLoop().
1059
1221
  }
1060
1222
  stopPingLoop(conn) {
@@ -1062,8 +1224,9 @@ export class TunnelRuntime {
1062
1224
  clearInterval(conn.pingHandle);
1063
1225
  conn.pingHandle = null;
1064
1226
  }
1065
- conn.pingAbort?.abort();
1066
- conn.pingAbort = null;
1227
+ for (const handle of conn.pingAckHandles)
1228
+ clearTimeout(handle);
1229
+ conn.pingAckHandles.clear();
1067
1230
  }
1068
1231
  // --- envelope dispatch -------------------------------------------------
1069
1232
  async dispatchEnvelope(conn, envelope) {
@@ -1514,13 +1677,13 @@ export class TunnelRuntime {
1514
1677
  if (id === null) {
1515
1678
  if (conn.draining)
1516
1679
  throw new WsServerDraining();
1517
- return;
1680
+ throw new WsConnectionLost();
1518
1681
  }
1519
1682
  const ev = await self.nextEvent(conn, id);
1520
1683
  if (ev === null) {
1521
1684
  if (conn.draining)
1522
1685
  throw new WsServerDraining();
1523
- return;
1686
+ throw new WsConnectionLost();
1524
1687
  }
1525
1688
  if (ev.kind === "data") {
1526
1689
  yield ev.data;
@@ -1528,14 +1691,14 @@ export class TunnelRuntime {
1528
1691
  else if (ev.kind === "end") {
1529
1692
  if (conn.draining)
1530
1693
  throw new WsServerDraining();
1531
- return;
1694
+ throw new WsConnectionLost();
1532
1695
  }
1533
1696
  else if (ev.kind === "reset") {
1534
1697
  // A reset while the conn is draining is the redeploy drain, not
1535
1698
  // a peer error — surface it typed so the handler can reconnect.
1536
1699
  if (conn.draining)
1537
1700
  throw new WsServerDraining();
1538
- throw new Error(`bridge stream reset code=${ev.code}`);
1701
+ throw new WsConnectionLost(`bridge stream reset code=${ev.code}`);
1539
1702
  }
1540
1703
  }
1541
1704
  })();
@@ -1958,6 +2121,8 @@ export class TunnelRuntime {
1958
2121
  setTimeoutPromise(POST_ACTIVE_WAIT_MS),
1959
2122
  ]);
1960
2123
  }
2124
+ if (!this.dispatchIsCurrent())
2125
+ return;
1961
2126
  const target = this.pickReplyConnection(origin);
1962
2127
  if (target === null) {
1963
2128
  // eslint-disable-next-line no-console
@@ -1968,7 +2133,11 @@ export class TunnelRuntime {
1968
2133
  }
1969
2134
  /** The active conn if it can take new streams, else the origin if it can. */
1970
2135
  pickReplyConnection(origin) {
1971
- const usable = (c) => c !== null && !c.draining && c.session !== null && !c.session.closed;
2136
+ const usable = (c) => c !== null &&
2137
+ !c.draining &&
2138
+ c.session !== null &&
2139
+ !c.session.closed &&
2140
+ !c.session.destroyed;
1972
2141
  if (usable(this.active))
1973
2142
  return this.active;
1974
2143
  if (usable(origin))
@@ -1976,6 +2145,8 @@ export class TunnelRuntime {
1976
2145
  return null;
1977
2146
  }
1978
2147
  async postResponse(conn, requestId, status, userHeaders, body) {
2148
+ if (!this.dispatchIsCurrent())
2149
+ return;
1979
2150
  const reqHeaders = {
1980
2151
  [HTTP2_HEADER_METHOD]: "POST",
1981
2152
  [HTTP2_HEADER_SCHEME]: "https",
@@ -2039,7 +2210,14 @@ export class TunnelRuntime {
2039
2210
  }
2040
2211
  }
2041
2212
  // --- utilities ---------------------------------------------------------
2213
+ dispatchIsCurrent() {
2214
+ const generation = this.dispatchGeneration.getStore();
2215
+ return generation === undefined || generation === this.coldGeneration;
2216
+ }
2042
2217
  notifyStatus(status) {
2218
+ if (this.runtimeStatus === status)
2219
+ return;
2220
+ this.runtimeStatus = status;
2043
2221
  if (this.onStatus !== undefined) {
2044
2222
  try {
2045
2223
  this.onStatus(status);