@inkbox/sdk 0.4.7 → 0.4.8

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.
@@ -25,7 +25,7 @@ import { ControlHeaders, ControlPaths, HOP_BY_HOP_RESPONSE, INKBOX_FORWARDED_HEA
25
25
  import { validateEnvelopePath } from "./_validation.js";
26
26
  import { createUndiciAgentCache, forwardEnvelopeToUrl, } from "./_url_forward.js";
27
27
  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, } from "./_ws.js";
28
+ import { dispatchWsUpgradeInProcess, WsServerDraining, } from "./_ws.js";
29
29
  const HTTP2_HEADER_METHOD = http2.constants.HTTP2_HEADER_METHOD;
30
30
  const HTTP2_HEADER_PATH = http2.constants.HTTP2_HEADER_PATH;
31
31
  const HTTP2_HEADER_SCHEME = http2.constants.HTTP2_HEADER_SCHEME;
@@ -40,6 +40,21 @@ export const PING_INTERVAL_MS = 20_000;
40
40
  export const PING_ACK_TIMEOUT_MS = 10_000;
41
41
  export const BACKOFF_CAP_SEC = 30.0;
42
42
  export const BACKOFF_JITTER = 0.25;
43
+ // On drain, keep a post-GOAWAY connection alive for its in-flight bridges
44
+ // up to this long, then force it closed. Bounds the handoff tail.
45
+ export const DRAINING_CONNECTION_CLOSE_TIMEOUT_MS = 90_000;
46
+ // Budget for re-dialing the replacement connection during a handoff (the
47
+ // server may bounce the first hello while it drains). Must stay below the
48
+ // close timeout so a stuck handoff still resolves.
49
+ export const HANDOFF_REDIAL_BUDGET_MS = 30_000;
50
+ // Minimum spacing between handoffs. A real drain rollout spaces GOAWAYs
51
+ // seconds apart per task; this rate-limit keeps a stray/rapid GOAWAY from
52
+ // chaining handoffs in a tight loop (e.g. a fresh conn signalled at once).
53
+ export const HANDOFF_SETTLE_MS = 2_000;
54
+ // How long an HTTP reply waits for an in-flight handoff to publish the new
55
+ // active connection before giving up (the server response deadline + the
56
+ // third-party retry recover a dropped reply).
57
+ export const POST_ACTIVE_WAIT_MS = 5_000;
43
58
  export const DEFAULT_INBOUND_BODY_BYTES = 32 * 1024 * 1024;
44
59
  export const DEFAULT_OUTBOUND_BODY_BYTES = 32 * 1024 * 1024;
45
60
  export class TunnelAuthError extends Error {
@@ -80,6 +95,34 @@ function isSessionTerminalError(err) {
80
95
  code === "ERR_HTTP2_STREAM_CANCEL" ||
81
96
  code === "ERR_HTTP2_SESSION_ERROR");
82
97
  }
98
+ /**
99
+ * One persistent h2 connection's state. The runtime holds a single
100
+ * `active` Connection (the pool that parks new intakes) plus zero-or-more
101
+ * `draining` ones during a make-before-break handoff. State is
102
+ * per-connection because two live h2 sessions each allocate stream ids
103
+ * 1,3,5… — a shared streams map would collide across them.
104
+ */
105
+ class Connection {
106
+ id;
107
+ session = null;
108
+ ownerToken = null;
109
+ serverPoolSize = null;
110
+ intakeIdleSeconds = null;
111
+ responseDeadlineSeconds = null;
112
+ // Stop parking new intakes once this conn has received GOAWAY.
113
+ draining = false;
114
+ streams = new Map();
115
+ bridgeStreamIds = new Set();
116
+ pingHandle = null;
117
+ pingAbort = null;
118
+ constructor(id) {
119
+ this.id = id;
120
+ }
121
+ /** Live WS/TCP bridge count — drives the drain-quiescent check. */
122
+ get liveBridges() {
123
+ return this.bridgeStreamIds.size;
124
+ }
125
+ }
83
126
  /**
84
127
  * The data-plane runtime. Construct with the bootstrap-derived
85
128
  * tunnelId/secret/zone/publicHost; call `serveForever()` to drive it,
@@ -100,14 +143,23 @@ export class TunnelRuntime {
100
143
  onStatus;
101
144
  rng;
102
145
  http2Connect;
103
- session = null;
104
- ownerToken = null;
105
- serverPoolSize = null;
106
- intakeIdleSeconds = null;
107
- responseDeadlineSeconds = null;
146
+ // The pool that parks new intakes. Swapped atomically on handoff.
147
+ active = null;
148
+ // Post-GOAWAY connections finishing in-flight work before close.
149
+ draining = new Set();
150
+ nextConnId = 1;
151
+ // True while a make-before-break handoff is dialing the replacement.
152
+ handoffInFlight = false;
153
+ // When the last handoff began (rate-limit, see HANDOFF_SETTLE_MS).
154
+ lastHandoffAt = 0;
155
+ // The in-flight handoff, so the supervisor can await it instead of
156
+ // hot-spinning if the old session closes mid-dial.
157
+ handoffPromise = null;
158
+ // Resolves the supervisor's wait when the active conn is swapped.
159
+ wakeSupervisor = null;
108
160
  stop = false;
109
- streams = new Map();
110
- bridgeStreamIds = new Set();
161
+ // Dispatch tasks are runtime-scoped (not per-connection): a handoff must
162
+ // let an in-flight handler finish and post its reply on the NEW conn.
111
163
  tasks = new Set();
112
164
  // Lazy: built on first passthrough TCP stream; closed in aclose().
113
165
  passthroughDispatch = null;
@@ -116,8 +168,6 @@ export class TunnelRuntime {
116
168
  // fresh Agent per request, which would leak sockets/timers. Closed
117
169
  // in aclose().
118
170
  undiciAgentCache = createUndiciAgentCache();
119
- pingHandle = null;
120
- pingAbort = null;
121
171
  shutdownAbort = new AbortController();
122
172
  constructor(opts) {
123
173
  this.tunnelId = opts.tunnelId;
@@ -185,11 +235,10 @@ export class TunnelRuntime {
185
235
  }
186
236
  this.notifyStatus("closed");
187
237
  }
188
- /** Graceful shutdown. Signals all loops to exit; closes the h2 session. */
238
+ /** Graceful shutdown. Signals all loops to exit; closes every conn. */
189
239
  async aclose() {
190
240
  this.stop = true;
191
241
  this.shutdownAbort.abort();
192
- this.pingAbort?.abort();
193
242
  if (this.passthroughDispatch !== null) {
194
243
  try {
195
244
  await this.passthroughDispatch.aclose();
@@ -205,95 +254,230 @@ export class TunnelRuntime {
205
254
  catch {
206
255
  /* swallow */
207
256
  }
208
- if (this.pingHandle !== null) {
209
- clearInterval(this.pingHandle);
210
- this.pingHandle = null;
211
- }
212
- const session = this.session;
213
- if (session !== null && !session.closed) {
214
- // Tier 1 of the GOAWAY fallback ladder: high-level close().
215
- // Node's `Http2Session.close()` waits for in-flight streams to
216
- // drain before emitting `close`. The intake pool parks streams
217
- // indefinitely, so we explicitly emit GOAWAY and then destroy
218
- // after a short grace this is Tier 4 of the ladder
219
- // (bounded drain timeout) and is a known divergence from
220
- // Python's no-timeout aclose().
221
- try {
222
- session.goaway();
223
- }
224
- catch {
225
- /* swallow */
226
- }
227
- // Cancel parked intake streams: best-effort.
228
- for (const sid of this.streams.keys()) {
257
+ // Close active + every draining conn; stop each one's ping loop so no
258
+ // ping loop leaks across the handoff set.
259
+ const conns = [this.active, ...this.draining].filter((c) => c !== null);
260
+ for (const conn of conns) {
261
+ this.stopPingLoop(conn);
262
+ await this.closeConnection(conn);
263
+ }
264
+ }
265
+ /**
266
+ * Emit GOAWAY then close/destroy a connection's session, bounded by a
267
+ * short grace. The intake pool parks streams indefinitely, so a plain
268
+ * `close()` would never resolve; we GOAWAY then destroy after 250ms.
269
+ */
270
+ async closeConnection(conn) {
271
+ const session = conn.session;
272
+ conn.session = null;
273
+ if (session === null || session.closed)
274
+ return;
275
+ try {
276
+ session.goaway();
277
+ }
278
+ catch {
279
+ /* swallow */
280
+ }
281
+ await new Promise((resolve) => {
282
+ const t = setTimeout(() => {
229
283
  try {
230
- // node:http2 does not surface a per-stream cancel from the
231
- // session-level alone; ignore — destroy below tears down all
232
- // streams atomically.
233
- void sid;
284
+ session.destroy();
234
285
  }
235
286
  catch {
236
287
  /* swallow */
237
288
  }
289
+ resolve();
290
+ }, 250);
291
+ session.once("close", () => {
292
+ clearTimeout(t);
293
+ resolve();
294
+ });
295
+ try {
296
+ session.close();
238
297
  }
239
- // Bounded drain: 250ms. Then destroy.
240
- await new Promise((resolve) => {
241
- const t = setTimeout(() => {
242
- try {
243
- session.destroy();
244
- }
245
- catch {
246
- /* swallow */
247
- }
248
- resolve();
249
- }, 250);
250
- session.once("close", () => {
251
- clearTimeout(t);
252
- resolve();
253
- });
298
+ catch {
299
+ clearTimeout(t);
254
300
  try {
255
- session.close();
301
+ session.destroy();
256
302
  }
257
303
  catch {
258
- clearTimeout(t);
259
- try {
260
- session.destroy();
261
- }
262
- catch {
263
- /* swallow */
264
- }
265
- resolve();
304
+ /* swallow */
266
305
  }
267
- });
268
- }
306
+ resolve();
307
+ }
308
+ });
269
309
  }
270
310
  // --- per-connection lifecycle -----------------------------------------
271
311
  async runOnce() {
272
- await this.openConnection();
312
+ const first = new Connection(this.nextConnId++);
313
+ let conn = first;
314
+ this.active = first;
273
315
  try {
274
- await this.sendHello();
316
+ await this.openConnection(first);
317
+ await this.sendHello(first);
275
318
  this.notifyStatus("connected");
276
- const effectivePool = this.serverPoolSize ?? this.poolSize ?? 1;
277
- const intakes = [];
278
- for (let slot = 0; slot < effectivePool; slot++) {
279
- intakes.push(this.intakeLoop(slot));
280
- }
281
- this.startPingLoop();
282
- // Wait for the session to close (read pump implicitly drives via
283
- // `session.on('close', ...)`).
284
- await this.waitForSessionClose();
285
- // Cancel intake loops; they exit when stop is set or session
286
- // closes (read pump drains queue events).
287
- await Promise.allSettled(intakes);
319
+ this.startServing(first);
320
+ // Supervise the active connection. A GOAWAY handoff swaps in a new
321
+ // active conn out-of-band; follow it without going through the
322
+ // backoff loop. Only a cold death (active conn closed with no
323
+ // successor) returns, so serveForever reconnects with backoff.
324
+ while (!this.stop) {
325
+ await this.waitCloseOrHandoff(conn);
326
+ if (this.stop)
327
+ break;
328
+ // If a handoff is dialing the replacement, wait for it to resolve
329
+ // before deciding yields to the event loop so the dial's IO
330
+ // isn't starved by re-racing an already-closed old session.
331
+ if (this.handoffInFlight && this.handoffPromise !== null) {
332
+ await this.handoffPromise;
333
+ }
334
+ const next = this.active;
335
+ if (next !== null && next !== conn && !next.draining) {
336
+ conn = next;
337
+ continue;
338
+ }
339
+ const session = conn.session;
340
+ if (session === null || session.closed || session.destroyed)
341
+ break;
342
+ }
288
343
  }
289
344
  finally {
290
- this.stopPingLoop();
291
- this.streams.clear();
292
- this.bridgeStreamIds.clear();
293
- this.session = null;
345
+ this.stopPingLoop(conn);
346
+ conn.streams.clear();
347
+ conn.bridgeStreamIds.clear();
348
+ conn.session = null;
349
+ if (this.active === conn)
350
+ this.active = null;
294
351
  }
295
352
  }
296
- async openConnection() {
353
+ /** Spawn a connection's intake pool + ping loop (cold open or handoff). */
354
+ startServing(conn) {
355
+ const effectivePool = conn.serverPoolSize ?? this.poolSize ?? 1;
356
+ for (let slot = 0; slot < effectivePool; slot++) {
357
+ // Fire-and-forget: each slot self-terminates when the conn closes
358
+ // or starts draining.
359
+ void this.intakeLoop(conn, slot).catch(() => undefined);
360
+ }
361
+ this.startPingLoop(conn);
362
+ }
363
+ /** Resolve once the supervised conn closes OR a handoff swaps active. */
364
+ waitCloseOrHandoff(conn) {
365
+ const closed = new Promise((resolve) => {
366
+ const session = conn.session;
367
+ if (session === null || session.closed) {
368
+ resolve();
369
+ return;
370
+ }
371
+ session.once("close", () => resolve());
372
+ });
373
+ const woken = new Promise((resolve) => {
374
+ this.wakeSupervisor = resolve;
375
+ });
376
+ return Promise.race([closed, woken]).finally(() => {
377
+ this.wakeSupervisor = null;
378
+ });
379
+ }
380
+ signalSupervisor() {
381
+ const w = this.wakeSupervisor;
382
+ this.wakeSupervisor = null;
383
+ if (w !== null)
384
+ w();
385
+ }
386
+ // --- make-before-break handoff ----------------------------------------
387
+ /**
388
+ * On a NO_ERROR GOAWAY, mark the old conn draining and stand up a fresh
389
+ * connection before closing it. In-band: never trips the backoff loop.
390
+ */
391
+ beginHandoff(oldConn) {
392
+ if (this.stop ||
393
+ oldConn.draining ||
394
+ this.active !== oldConn ||
395
+ this.handoffInFlight ||
396
+ Date.now() - this.lastHandoffAt < HANDOFF_SETTLE_MS) {
397
+ return;
398
+ }
399
+ this.handoffInFlight = true;
400
+ this.lastHandoffAt = Date.now();
401
+ oldConn.draining = true;
402
+ this.draining.add(oldConn);
403
+ // Stop the draining conn's ping loop: it's expected to close, and we
404
+ // don't want two ping loops racing across the handoff set.
405
+ this.stopPingLoop(oldConn);
406
+ this.handoffPromise = this.runHandoff(oldConn);
407
+ }
408
+ async runHandoff(oldConn) {
409
+ try {
410
+ const newConn = await this.makeReplacementConnection();
411
+ this.active = newConn;
412
+ // Supervisor was watching oldConn; wake it to follow newConn.
413
+ this.signalSupervisor();
414
+ }
415
+ catch (err) {
416
+ // Redial budget exhausted (or auth failure): give up on
417
+ // make-before-break and fall to the cold reconnect path by forcing
418
+ // the old session closed so the supervisor returns.
419
+ // eslint-disable-next-line no-console
420
+ console.warn("tunnel runtime: handoff failed; reconnecting cold", err);
421
+ try {
422
+ oldConn.session?.destroy();
423
+ }
424
+ catch { /* swallow */ }
425
+ this.signalSupervisor();
426
+ }
427
+ finally {
428
+ this.handoffInFlight = false;
429
+ this.handoffPromise = null;
430
+ // Close the old conn once its bridges finish (or the deadline hits).
431
+ void this.drainOldConnection(oldConn);
432
+ }
433
+ }
434
+ /** Dial + hello + park a replacement, retrying transient hello failures. */
435
+ async makeReplacementConnection() {
436
+ let backoff = 0.1;
437
+ const start = Date.now();
438
+ while (!this.stop) {
439
+ const conn = new Connection(this.nextConnId++);
440
+ try {
441
+ await this.openConnection(conn);
442
+ await this.sendHello(conn);
443
+ this.startServing(conn);
444
+ return conn;
445
+ }
446
+ catch (err) {
447
+ try {
448
+ await this.closeConnection(conn);
449
+ }
450
+ catch { /* swallow */ }
451
+ if (err instanceof TunnelAuthError)
452
+ throw err;
453
+ if (Date.now() - start > HANDOFF_REDIAL_BUDGET_MS) {
454
+ throw new Error("handoff redial budget exhausted");
455
+ }
456
+ // A drain 503 on the new hello means the NLB landed us back on the
457
+ // draining task; back off (jittered) so it re-routes us elsewhere.
458
+ const jitter = backoff * BACKOFF_JITTER * (2 * this.rng() - 1);
459
+ await setTimeoutPromise(Math.max(50, (backoff + jitter) * 1000), undefined, {
460
+ signal: this.shutdownAbort.signal,
461
+ }).catch(() => undefined);
462
+ backoff = Math.min(backoff * 2, 5.0);
463
+ }
464
+ }
465
+ throw new Error("runtime stopped during handoff");
466
+ }
467
+ /** Wait for a draining conn's bridges to finish, then close it. */
468
+ async drainOldConnection(oldConn) {
469
+ const deadline = Date.now() + DRAINING_CONNECTION_CLOSE_TIMEOUT_MS;
470
+ // Node keeps existing streams running after a NO_ERROR GOAWAY, so let
471
+ // live WS/TCP bridges finish until they drain or the deadline hits.
472
+ while (oldConn.liveBridges > 0 && Date.now() < deadline && !this.stop) {
473
+ await setTimeoutPromise(250).catch(() => undefined);
474
+ }
475
+ await this.closeConnection(oldConn);
476
+ oldConn.streams.clear();
477
+ oldConn.bridgeStreamIds.clear();
478
+ this.draining.delete(oldConn);
479
+ }
480
+ async openConnection(conn) {
297
481
  const authority = `https://${this.zone}`;
298
482
  const session = this.http2Connect(authority, {
299
483
  ALPNProtocols: ["h2"],
@@ -303,13 +487,13 @@ export class TunnelRuntime {
303
487
  // workaround. Node http2 either accepts `:protocol` or doesn't
304
488
  // (Spike 1) — the setting line doesn't translate.
305
489
  });
306
- this.session = session;
490
+ conn.session = session;
307
491
  session.on("close", () => {
308
492
  // eslint-disable-next-line no-console
309
493
  console.info("tunnel runtime: h2 session closed");
310
494
  // Drain all open streams with a synthetic reset event so any
311
495
  // awaiters wake up.
312
- for (const [, bus] of this.streams) {
496
+ for (const [, bus] of conn.streams) {
313
497
  if (!bus.ended) {
314
498
  bus.events.push({ kind: "reset", code: 0 });
315
499
  bus.ended = true;
@@ -327,6 +511,12 @@ export class TunnelRuntime {
327
511
  session.on("goaway", (errorCode, lastStreamId) => {
328
512
  // eslint-disable-next-line no-console
329
513
  console.info(`tunnel runtime: GOAWAY received error_code=${errorCode} last_stream_id=${lastStreamId}`);
514
+ // NO_ERROR GOAWAY is the server's drain signal: stand up a fresh
515
+ // connection make-before-break. A non-zero code is a real fault —
516
+ // let the session close and reconnect cold.
517
+ if (errorCode === 0) {
518
+ this.beginHandoff(conn);
519
+ }
330
520
  });
331
521
  // Watch the underlying TCP/TLS socket directly. Node's h2 client
332
522
  // sometimes loses the connection without emitting ``error`` or
@@ -387,8 +577,8 @@ export class TunnelRuntime {
387
577
  /* swallow */
388
578
  }
389
579
  }
390
- waitForSessionClose() {
391
- const session = this.session;
580
+ waitForSessionClose(conn) {
581
+ const session = conn.session;
392
582
  if (session === null)
393
583
  return Promise.resolve();
394
584
  return new Promise((resolve) => {
@@ -400,11 +590,11 @@ export class TunnelRuntime {
400
590
  });
401
591
  }
402
592
  // --- handshake ---------------------------------------------------------
403
- async sendHello() {
404
- this.ownerToken = null;
405
- this.serverPoolSize = null;
406
- this.intakeIdleSeconds = null;
407
- this.responseDeadlineSeconds = null;
593
+ async sendHello(conn) {
594
+ conn.ownerToken = null;
595
+ conn.serverPoolSize = null;
596
+ conn.intakeIdleSeconds = null;
597
+ conn.responseDeadlineSeconds = null;
408
598
  const helloHeaders = {
409
599
  [HTTP2_HEADER_METHOD]: "POST",
410
600
  [HTTP2_HEADER_SCHEME]: "https",
@@ -417,8 +607,8 @@ export class TunnelRuntime {
417
607
  if (this.poolSize !== null) {
418
608
  helloHeaders[ControlHeaders.POOL_SIZE] = String(this.poolSize);
419
609
  }
420
- const stream = this.openStream(helloHeaders, { endStream: true });
421
- const { status, body } = await this.awaitResponse(stream.streamId);
610
+ const stream = this.openStream(conn, helloHeaders, { endStream: true });
611
+ const { status, body } = await this.awaitResponse(conn, stream.streamId);
422
612
  if (status === 401 || status === 403) {
423
613
  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)`);
424
614
  }
@@ -438,20 +628,20 @@ export class TunnelRuntime {
438
628
  if (typeof ownerToken !== "string" || ownerToken === "") {
439
629
  throw new Error(`${ControlPaths.HELLO} response missing owner_token; cannot park intake`);
440
630
  }
441
- this.ownerToken = ownerToken;
631
+ conn.ownerToken = ownerToken;
442
632
  if (typeof payload["default_pool_size"] === "number") {
443
- this.serverPoolSize = payload["default_pool_size"];
633
+ conn.serverPoolSize = payload["default_pool_size"];
444
634
  }
445
635
  if (typeof payload["intake_idle_seconds"] === "number") {
446
- this.intakeIdleSeconds = payload["intake_idle_seconds"];
636
+ conn.intakeIdleSeconds = payload["intake_idle_seconds"];
447
637
  }
448
638
  if (typeof payload["response_deadline_seconds"] === "number") {
449
- this.responseDeadlineSeconds = payload["response_deadline_seconds"];
639
+ conn.responseDeadlineSeconds = payload["response_deadline_seconds"];
450
640
  }
451
641
  }
452
642
  // --- stream helpers ----------------------------------------------------
453
- openStream(headers, opts) {
454
- const session = this.session;
643
+ openStream(conn, headers, opts) {
644
+ const session = conn.session;
455
645
  if (session === null)
456
646
  throw new Error("h2 connection not open");
457
647
  const stream = session.request(headers, { endStream: opts.endStream });
@@ -468,7 +658,7 @@ export class TunnelRuntime {
468
658
  // In practice Node assigns the id synchronously; this is a guard.
469
659
  throw new Error("h2 stream id not assigned synchronously");
470
660
  }
471
- this.streams.set(streamId, bus);
661
+ conn.streams.set(streamId, bus);
472
662
  stream.on("response", (responseHeaders) => {
473
663
  const flat = [];
474
664
  for (const k of Object.keys(responseHeaders)) {
@@ -513,8 +703,8 @@ export class TunnelRuntime {
513
703
  w();
514
704
  }
515
705
  }
516
- async nextEvent(streamId) {
517
- const bus = this.streams.get(streamId);
706
+ async nextEvent(conn, streamId) {
707
+ const bus = conn.streams.get(streamId);
518
708
  if (bus === undefined)
519
709
  return null;
520
710
  while (true) {
@@ -530,14 +720,14 @@ export class TunnelRuntime {
530
720
  });
531
721
  }
532
722
  }
533
- async awaitResponse(streamId) {
723
+ async awaitResponse(conn, streamId) {
534
724
  const chunks = [];
535
725
  let status = 0;
536
726
  let gotHeaders = false;
537
727
  while (true) {
538
- const ev = await this.nextEvent(streamId);
728
+ const ev = await this.nextEvent(conn, streamId);
539
729
  if (ev === null) {
540
- this.streams.delete(streamId);
730
+ conn.streams.delete(streamId);
541
731
  return { status, body: Buffer.concat(chunks) };
542
732
  }
543
733
  if (ev.kind === "headers" && !gotHeaders) {
@@ -549,27 +739,30 @@ export class TunnelRuntime {
549
739
  chunks.push(ev.data);
550
740
  }
551
741
  else if (ev.kind === "end" || ev.kind === "reset") {
552
- this.streams.delete(streamId);
742
+ conn.streams.delete(streamId);
553
743
  return { status, body: Buffer.concat(chunks) };
554
744
  }
555
745
  }
556
746
  }
557
747
  // --- intake pool -------------------------------------------------------
558
- async intakeLoop(slot) {
559
- while (!this.stop && this.session !== null && !this.session.closed) {
748
+ async intakeLoop(conn, slot) {
749
+ while (!this.stop &&
750
+ !conn.draining &&
751
+ conn.session !== null &&
752
+ !conn.session.closed) {
560
753
  let envelope;
561
754
  try {
562
- envelope = await this.parkOneIntake(slot);
755
+ envelope = await this.parkOneIntake(conn, slot);
563
756
  }
564
757
  catch (err) {
565
758
  if (err instanceof OwnerTokenInvalidError) {
566
759
  // eslint-disable-next-line no-console
567
760
  console.warn(`intake slot ${slot}: owner_token rejected; ` +
568
761
  `forcing session.destroy() and reconnecting`);
569
- this.session?.destroy();
762
+ conn.session?.destroy();
570
763
  return;
571
764
  }
572
- if (isSessionTerminalError(err) || this.session?.destroyed) {
765
+ if (isSessionTerminalError(err) || conn.session?.destroyed) {
573
766
  // The h2 session is gone — every subsequent openStream will
574
767
  // throw the same error. Don't retry-storm; exit the slot so
575
768
  // ``runOnce`` observes ``waitForSessionClose`` resolve and
@@ -582,7 +775,7 @@ export class TunnelRuntime {
582
775
  `${err?.code ?? "no code"}); ` +
583
776
  `exiting slot`, err);
584
777
  try {
585
- this.session?.destroy();
778
+ conn.session?.destroy();
586
779
  }
587
780
  catch { /* swallow */ }
588
781
  return;
@@ -594,8 +787,10 @@ export class TunnelRuntime {
594
787
  }
595
788
  if (envelope === null)
596
789
  continue;
597
- // Fire-and-forget dispatch; tracked so we can join on shutdown.
598
- const task = this.dispatchEnvelope(envelope).catch((err) => {
790
+ // Fire-and-forget dispatch; tracked on the runtime (not the conn) so
791
+ // an in-flight handler survives this conn draining and can post its
792
+ // reply on the new active conn during a handoff.
793
+ const task = this.dispatchEnvelope(conn, envelope).catch((err) => {
599
794
  // eslint-disable-next-line no-console
600
795
  console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
601
796
  });
@@ -603,8 +798,8 @@ export class TunnelRuntime {
603
798
  task.finally(() => this.tasks.delete(task));
604
799
  }
605
800
  }
606
- async parkOneIntake(slot) {
607
- if (this.ownerToken === null) {
801
+ async parkOneIntake(conn, slot) {
802
+ if (conn.ownerToken === null) {
608
803
  throw new Error("intake parked before /_system/hello returned an owner_token");
609
804
  }
610
805
  const headers = {
@@ -613,17 +808,17 @@ export class TunnelRuntime {
613
808
  [HTTP2_HEADER_AUTHORITY]: this.zone,
614
809
  [HTTP2_HEADER_PATH]: ControlPaths.INTAKE,
615
810
  [ControlHeaders.TUNNEL_ID]: this.tunnelId,
616
- [ControlHeaders.OWNER_TOKEN]: this.ownerToken,
811
+ [ControlHeaders.OWNER_TOKEN]: conn.ownerToken,
617
812
  [ControlHeaders.POOL_SLOT]: String(slot),
618
813
  "content-length": "0",
619
814
  };
620
- const { streamId } = this.openStream(headers, { endStream: true });
815
+ const { streamId } = this.openStream(conn, headers, { endStream: true });
621
816
  let recvHeaders = null;
622
817
  const chunks = [];
623
818
  while (true) {
624
- const ev = await this.nextEvent(streamId);
819
+ const ev = await this.nextEvent(conn, streamId);
625
820
  if (ev === null) {
626
- this.streams.delete(streamId);
821
+ conn.streams.delete(streamId);
627
822
  return null;
628
823
  }
629
824
  if (ev.kind === "headers" && recvHeaders === null) {
@@ -636,11 +831,11 @@ export class TunnelRuntime {
636
831
  break;
637
832
  }
638
833
  else if (ev.kind === "reset") {
639
- this.streams.delete(streamId);
834
+ conn.streams.delete(streamId);
640
835
  return null;
641
836
  }
642
837
  }
643
- this.streams.delete(streamId);
838
+ conn.streams.delete(streamId);
644
839
  if (recvHeaders === null)
645
840
  return null;
646
841
  const status = recvHeaders.find(([k]) => k === HTTP2_HEADER_STATUS)?.[1] ?? "0";
@@ -656,10 +851,10 @@ export class TunnelRuntime {
656
851
  return parseEnvelope(recvHeaders, Buffer.concat(chunks));
657
852
  }
658
853
  // --- ping loop ---------------------------------------------------------
659
- startPingLoop() {
660
- this.pingAbort = new AbortController();
661
- this.pingHandle = setInterval(() => {
662
- const session = this.session;
854
+ startPingLoop(conn) {
855
+ conn.pingAbort = new AbortController();
856
+ conn.pingHandle = setInterval(() => {
857
+ const session = conn.session;
663
858
  if (session === null || session.closed)
664
859
  return;
665
860
  let ackTimer = null;
@@ -711,19 +906,19 @@ export class TunnelRuntime {
711
906
  }, PING_INTERVAL_MS);
712
907
  // Do NOT unref(): explicit cancellation in stopPingLoop().
713
908
  }
714
- stopPingLoop() {
715
- if (this.pingHandle !== null) {
716
- clearInterval(this.pingHandle);
717
- this.pingHandle = null;
909
+ stopPingLoop(conn) {
910
+ if (conn.pingHandle !== null) {
911
+ clearInterval(conn.pingHandle);
912
+ conn.pingHandle = null;
718
913
  }
719
- this.pingAbort?.abort();
720
- this.pingAbort = null;
914
+ conn.pingAbort?.abort();
915
+ conn.pingAbort = null;
721
916
  }
722
917
  // --- envelope dispatch -------------------------------------------------
723
- async dispatchEnvelope(envelope) {
918
+ async dispatchEnvelope(conn, envelope) {
724
919
  if (envelope.routeKind === TunnelRouteKind.WS_UPGRADE) {
725
920
  try {
726
- await this.dispatchWsUpgrade(envelope);
921
+ await this.dispatchWsUpgrade(conn, envelope);
727
922
  }
728
923
  catch (err) {
729
924
  // eslint-disable-next-line no-console
@@ -734,7 +929,7 @@ export class TunnelRuntime {
734
929
  if (envelope.routeKind === TunnelRouteKind.TCP_STREAM) {
735
930
  // Passthrough TCP bridge — defer until M4 lands here.
736
931
  try {
737
- await this.dispatchTcpStream(envelope);
932
+ await this.dispatchTcpStream(conn, envelope);
738
933
  }
739
934
  catch (err) {
740
935
  // eslint-disable-next-line no-console
@@ -743,13 +938,13 @@ export class TunnelRuntime {
743
938
  return;
744
939
  }
745
940
  try {
746
- await this.dispatchHttp(envelope);
941
+ await this.dispatchHttp(conn, envelope);
747
942
  }
748
943
  catch (err) {
749
944
  // eslint-disable-next-line no-console
750
945
  console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
751
946
  try {
752
- await this.postResponse(envelope.requestId, 500, [["content-type", "text/plain"]], Buffer.from("internal error"));
947
+ await this.postHttpResponse(conn, envelope.requestId, 500, [["content-type", "text/plain"]], Buffer.from("internal error"));
753
948
  }
754
949
  catch {
755
950
  /* swallow */
@@ -757,10 +952,10 @@ export class TunnelRuntime {
757
952
  }
758
953
  }
759
954
  // --- HTTP dispatch -----------------------------------------------------
760
- async dispatchHttp(envelope) {
955
+ async dispatchHttp(conn, envelope) {
761
956
  const reject = validateEnvelopePath(envelope.path);
762
957
  if (reject !== null) {
763
- await this.postResponse(envelope.requestId, 400, [
958
+ await this.postHttpResponse(conn, envelope.requestId, 400, [
764
959
  ["content-type", "text/plain"],
765
960
  [TunnelMetaHeader.REASON, reject],
766
961
  ], Buffer.from("invalid path"));
@@ -774,13 +969,13 @@ export class TunnelRuntime {
774
969
  catch (err) {
775
970
  const reason = err instanceof BodyTooLargeError ? "request-body-too-large" : "body-fetch-failed";
776
971
  const status = err instanceof BodyTooLargeError ? 413 : 502;
777
- await this.postResponse(envelope.requestId, status, [
972
+ await this.postHttpResponse(conn, envelope.requestId, status, [
778
973
  ["content-type", "text/plain"],
779
974
  [TunnelMetaHeader.REASON, reason],
780
975
  ], Buffer.from(reason));
781
976
  return;
782
977
  }
783
- const deadlineMs = (this.responseDeadlineSeconds ?? 0) * 1000;
978
+ const deadlineMs = (conn.responseDeadlineSeconds ?? 0) * 1000;
784
979
  const ctrl = new AbortController();
785
980
  let deadlineHandle = null;
786
981
  // Sentinel resolved by the deadline timer — used as the loser side
@@ -838,7 +1033,7 @@ export class TunnelRuntime {
838
1033
  // the server-side deadline, and a late ``postResponse`` would
839
1034
  // target a request the tunnel server has already 504'd.
840
1035
  dispatchPromise.catch(() => undefined);
841
- await this.postResponse(envelope.requestId, 504, [
1036
+ await this.postHttpResponse(conn, envelope.requestId, 504, [
842
1037
  ["content-type", "text/plain"],
843
1038
  [TunnelMetaHeader.REASON, "response-deadline-exceeded"],
844
1039
  ], Buffer.from("local handler too slow"));
@@ -847,10 +1042,10 @@ export class TunnelRuntime {
847
1042
  if (outcome.kind === "in-process") {
848
1043
  const inProcess = outcome.result;
849
1044
  if (inProcess.kind === "ok") {
850
- await this.postResponse(envelope.requestId, inProcess.status, filterResponseHeaders(inProcess.headers), inProcess.body);
1045
+ await this.postHttpResponse(conn, envelope.requestId, inProcess.status, filterResponseHeaders(inProcess.headers), inProcess.body);
851
1046
  }
852
1047
  else {
853
- await this.postResponse(envelope.requestId, inProcess.status, [
1048
+ await this.postHttpResponse(conn, envelope.requestId, inProcess.status, [
854
1049
  ["content-type", "text/plain"],
855
1050
  [TunnelMetaHeader.REASON, inProcess.inkboxReason],
856
1051
  ], Buffer.from(inProcess.inkboxReason));
@@ -860,10 +1055,10 @@ export class TunnelRuntime {
860
1055
  if (outcome.kind === "url-forward") {
861
1056
  const result = outcome.result;
862
1057
  if (result.kind === "ok") {
863
- await this.postResponse(envelope.requestId, result.status, filterResponseHeaders(result.headers), result.body);
1058
+ await this.postHttpResponse(conn, envelope.requestId, result.status, filterResponseHeaders(result.headers), result.body);
864
1059
  }
865
1060
  else {
866
- await this.postResponse(envelope.requestId, result.status, [
1061
+ await this.postHttpResponse(conn, envelope.requestId, result.status, [
867
1062
  ["content-type", "text/plain"],
868
1063
  [TunnelMetaHeader.REASON, result.inkboxReason],
869
1064
  ], Buffer.from(result.inkboxReason));
@@ -872,7 +1067,7 @@ export class TunnelRuntime {
872
1067
  }
873
1068
  // No HTTP path configured — should be impossible if connect()
874
1069
  // validation is correct, but defend.
875
- await this.postResponse(envelope.requestId, 501, [
1070
+ await this.postHttpResponse(conn, envelope.requestId, 501, [
876
1071
  ["content-type", "text/plain"],
877
1072
  [TunnelMetaHeader.REASON, "no-http-handler"],
878
1073
  ], Buffer.from("no http handler"));
@@ -911,9 +1106,9 @@ export class TunnelRuntime {
911
1106
  return { ...envelope, body: Buffer.concat(chunks, total) };
912
1107
  }
913
1108
  // --- WS dispatch -------------------------------------------------------
914
- async dispatchWsUpgrade(envelope) {
1109
+ async dispatchWsUpgrade(conn, envelope) {
915
1110
  if (envelope.wsId === null) {
916
- await this.postResponse(envelope.requestId, 400, [
1111
+ await this.postResponse(conn, envelope.requestId, 400, [
917
1112
  ["content-type", "text/plain"],
918
1113
  [TunnelMetaHeader.REASON, "missing-ws-id"],
919
1114
  ], Buffer.from("missing ws_id"));
@@ -923,7 +1118,7 @@ export class TunnelRuntime {
923
1118
  // validateEnvelopePath check, so apply it here too.
924
1119
  const reject = validateEnvelopePath(envelope.path);
925
1120
  if (reject !== null) {
926
- await this.postResponse(envelope.requestId, 400, [
1121
+ await this.postResponse(conn, envelope.requestId, 400, [
927
1122
  ["content-type", "text/plain"],
928
1123
  [TunnelMetaHeader.REASON, reject],
929
1124
  ], Buffer.from("invalid path"));
@@ -932,19 +1127,19 @@ export class TunnelRuntime {
932
1127
  // URL forward — bridge to the upstream WS via h1 Upgrade.
933
1128
  if (this.dispatch.wsHandler === undefined &&
934
1129
  this.dispatch.forwardTo !== undefined) {
935
- await this.dispatchWsUpgradeToUrl(envelope, this.dispatch.forwardTo);
1130
+ await this.dispatchWsUpgradeToUrl(conn, envelope, this.dispatch.forwardTo);
936
1131
  return;
937
1132
  }
938
1133
  if (this.dispatch.wsHandler === undefined) {
939
1134
  // No URL upstream and no in-process WS handler — reject 501.
940
- await this.postResponse(envelope.requestId, 501, [
1135
+ await this.postResponse(conn, envelope.requestId, 501, [
941
1136
  ["content-type", "text/plain"],
942
1137
  [TunnelMetaHeader.REASON, "ws-not-supported"],
943
1138
  ], Buffer.from("ws upgrade not supported"));
944
1139
  return;
945
1140
  }
946
- const acceptDeadlineMs = (this.responseDeadlineSeconds ?? 30) * 1000;
947
- const bridge = await this.openWsBridge(envelope);
1141
+ const acceptDeadlineMs = (conn.responseDeadlineSeconds ?? 30) * 1000;
1142
+ const bridge = await this.openWsBridge(conn, envelope);
948
1143
  try {
949
1144
  await dispatchWsUpgradeInProcess({
950
1145
  envelope,
@@ -958,7 +1153,7 @@ export class TunnelRuntime {
958
1153
  bridge.cleanup();
959
1154
  }
960
1155
  }
961
- async dispatchWsUpgradeToUrl(envelope, forwardTo) {
1156
+ async dispatchWsUpgradeToUrl(conn, envelope, forwardTo) {
962
1157
  // Open the upstream WS hop. On failure, surface the upstream-style
963
1158
  // status back to the third party so the client sees a clean
964
1159
  // non-101 instead of hanging.
@@ -979,8 +1174,8 @@ export class TunnelRuntime {
979
1174
  // the server already 504'd would just be wasted work.
980
1175
  // Floor at 1ms (not 1s) — sub-second response deadlines are valid
981
1176
  // and must be honored. Earlier shape clamped 0.1s up to 1s.
982
- const handshakeTimeoutMs = this.responseDeadlineSeconds !== null
983
- ? Math.max(1, this.responseDeadlineSeconds * 1000)
1177
+ const handshakeTimeoutMs = conn.responseDeadlineSeconds !== null
1178
+ ? Math.max(1, conn.responseDeadlineSeconds * 1000)
984
1179
  : undefined;
985
1180
  try {
986
1181
  upstream = await openWsUpstream({
@@ -997,7 +1192,7 @@ export class TunnelRuntime {
997
1192
  }
998
1193
  catch (e) {
999
1194
  const status = e instanceof WsUpstreamError ? e.status : 502;
1000
- await this.postResponse(envelope.requestId, status, [
1195
+ await this.postResponse(conn, envelope.requestId, status, [
1001
1196
  ["content-type", "text/plain"],
1002
1197
  [TunnelMetaHeader.REASON, "ws-upstream-failed"],
1003
1198
  ], Buffer.from("upstream ws upgrade failed"));
@@ -1030,7 +1225,7 @@ export class TunnelRuntime {
1030
1225
  continue;
1031
1226
  upgradeReplyHeaders.push([hk, hv]);
1032
1227
  }
1033
- const bridge = await this.openWsBridge(envelope);
1228
+ const bridge = await this.openWsBridge(conn, envelope);
1034
1229
  try {
1035
1230
  // postUpgradeReply both posts the 200 AND opens the inkbox bridge
1036
1231
  // CONNECT stream — skipping it (an earlier draft did) leaves
@@ -1078,7 +1273,7 @@ export class TunnelRuntime {
1078
1273
  bridge.cleanup();
1079
1274
  }
1080
1275
  }
1081
- async openWsBridge(envelope) {
1276
+ async openWsBridge(conn, envelope) {
1082
1277
  const wsId = envelope.wsId;
1083
1278
  const requestId = envelope.requestId;
1084
1279
  let connectStreamId = null;
@@ -1099,7 +1294,7 @@ export class TunnelRuntime {
1099
1294
  }
1100
1295
  };
1101
1296
  const postUpgradeReply = async (headers) => {
1102
- await this.postResponse(requestId, 200, headers, Buffer.alloc(0));
1297
+ await this.postResponse(conn, requestId, 200, headers, Buffer.alloc(0));
1103
1298
  // Open the extended-CONNECT bridge stream after the upgrade reply.
1104
1299
  const connectHeaders = {
1105
1300
  [HTTP2_HEADER_METHOD]: "CONNECT",
@@ -1113,10 +1308,10 @@ export class TunnelRuntime {
1113
1308
  [ControlHeaders.API_KEY]: this.apiKey,
1114
1309
  [TunnelMetaHeader.WS_ID]: wsId,
1115
1310
  };
1116
- const opened = this.openStream(connectHeaders, { endStream: false });
1311
+ const opened = this.openStream(conn, connectHeaders, { endStream: false });
1117
1312
  connectStreamId = opened.streamId;
1118
1313
  bridgeStream = opened.stream;
1119
- this.bridgeStreamIds.add(connectStreamId);
1314
+ conn.bridgeStreamIds.add(connectStreamId);
1120
1315
  try {
1121
1316
  // Wait for the 200 on the bridge stream — bounded so a server
1122
1317
  // that never replies can't wedge the dispatch task after the
@@ -1124,7 +1319,7 @@ export class TunnelRuntime {
1124
1319
  // _with_deadline + the TCP passthrough's
1125
1320
  // BRIDGE_STATUS_TIMEOUT_MS race.
1126
1321
  const ev = await Promise.race([
1127
- this.nextEvent(connectStreamId),
1322
+ this.nextEvent(conn, connectStreamId),
1128
1323
  setTimeoutPromise(BRIDGE_STATUS_TIMEOUT_MS).then(() => "timeout"),
1129
1324
  ]);
1130
1325
  if (ev === "timeout") {
@@ -1147,7 +1342,7 @@ export class TunnelRuntime {
1147
1342
  }
1148
1343
  };
1149
1344
  const rejectUpgrade = async (status, reason) => {
1150
- await this.postResponse(requestId, status, [
1345
+ await this.postResponse(conn, requestId, status, [
1151
1346
  ["content-type", "text/plain"],
1152
1347
  [TunnelMetaHeader.REASON, reason],
1153
1348
  ], Buffer.from(reason));
@@ -1165,18 +1360,30 @@ export class TunnelRuntime {
1165
1360
  return (async function* () {
1166
1361
  while (true) {
1167
1362
  const id = sid();
1168
- if (id === null)
1363
+ if (id === null) {
1364
+ if (conn.draining)
1365
+ throw new WsServerDraining();
1169
1366
  return;
1170
- const ev = await self.nextEvent(id);
1171
- if (ev === null)
1367
+ }
1368
+ const ev = await self.nextEvent(conn, id);
1369
+ if (ev === null) {
1370
+ if (conn.draining)
1371
+ throw new WsServerDraining();
1172
1372
  return;
1373
+ }
1173
1374
  if (ev.kind === "data") {
1174
1375
  yield ev.data;
1175
1376
  }
1176
1377
  else if (ev.kind === "end") {
1378
+ if (conn.draining)
1379
+ throw new WsServerDraining();
1177
1380
  return;
1178
1381
  }
1179
1382
  else if (ev.kind === "reset") {
1383
+ // A reset while the conn is draining is the redeploy drain, not
1384
+ // a peer error — surface it typed so the handler can reconnect.
1385
+ if (conn.draining)
1386
+ throw new WsServerDraining();
1180
1387
  throw new Error(`bridge stream reset code=${ev.code}`);
1181
1388
  }
1182
1389
  }
@@ -1218,8 +1425,8 @@ export class TunnelRuntime {
1218
1425
  }
1219
1426
  }
1220
1427
  if (connectStreamId !== null) {
1221
- this.bridgeStreamIds.delete(connectStreamId);
1222
- this.streams.delete(connectStreamId);
1428
+ conn.bridgeStreamIds.delete(connectStreamId);
1429
+ conn.streams.delete(connectStreamId);
1223
1430
  }
1224
1431
  };
1225
1432
  return {
@@ -1228,7 +1435,7 @@ export class TunnelRuntime {
1228
1435
  };
1229
1436
  }
1230
1437
  // --- TCP-stream bridge (passthrough) ----------------------------------
1231
- async dispatchTcpStream(envelope) {
1438
+ async dispatchTcpStream(conn, envelope) {
1232
1439
  if (this.tlsTerminator === null ||
1233
1440
  (this.dispatch.forwardTo === undefined &&
1234
1441
  this.dispatch.httpHandler === undefined) ||
@@ -1253,15 +1460,15 @@ export class TunnelRuntime {
1253
1460
  [ControlHeaders.API_KEY]: this.apiKey,
1254
1461
  [TunnelMetaHeader.TCP_ID]: tcpId,
1255
1462
  };
1256
- const { stream, streamId } = this.openStream(connectHeaders, {
1463
+ const { stream, streamId } = this.openStream(conn, connectHeaders, {
1257
1464
  endStream: false,
1258
1465
  });
1259
- this.bridgeStreamIds.add(streamId);
1466
+ conn.bridgeStreamIds.add(streamId);
1260
1467
  // 2) Wait for status 200 with timeout.
1261
1468
  let openOk = false;
1262
1469
  try {
1263
1470
  const ev = await Promise.race([
1264
- this.nextEvent(streamId),
1471
+ this.nextEvent(conn, streamId),
1265
1472
  setTimeoutPromise(BRIDGE_STATUS_TIMEOUT_MS).then(() => "timeout"),
1266
1473
  ]);
1267
1474
  if (ev !== null && ev !== "timeout" && typeof ev !== "string" && ev.kind === "headers") {
@@ -1281,8 +1488,8 @@ export class TunnelRuntime {
1281
1488
  catch {
1282
1489
  /* swallow */
1283
1490
  }
1284
- this.bridgeStreamIds.delete(streamId);
1285
- this.streams.delete(streamId);
1491
+ conn.bridgeStreamIds.delete(streamId);
1492
+ conn.streams.delete(streamId);
1286
1493
  return;
1287
1494
  }
1288
1495
  // 3) Build (or reuse) the passthrough dispatcher for this runtime.
@@ -1380,7 +1587,7 @@ export class TunnelRuntime {
1380
1587
  let pendingFrags = null;
1381
1588
  try {
1382
1589
  while (true) {
1383
- const ev = await this.nextEvent(streamId);
1590
+ const ev = await this.nextEvent(conn, streamId);
1384
1591
  if (ev === null)
1385
1592
  return;
1386
1593
  if (ev.kind === "end")
@@ -1560,8 +1767,8 @@ export class TunnelRuntime {
1560
1767
  /* swallow */
1561
1768
  }
1562
1769
  }
1563
- this.bridgeStreamIds.delete(streamId);
1564
- this.streams.delete(streamId);
1770
+ conn.bridgeStreamIds.delete(streamId);
1771
+ conn.streams.delete(streamId);
1565
1772
  }
1566
1773
  }
1567
1774
  writeBridgeFrame(stream, frame, endStream = false) {
@@ -1584,7 +1791,40 @@ export class TunnelRuntime {
1584
1791
  });
1585
1792
  }
1586
1793
  // --- response posting --------------------------------------------------
1587
- async postResponse(requestId, status, userHeaders, body) {
1794
+ /**
1795
+ * Post an HTTP webhook reply on the CURRENT active connection (not the
1796
+ * connection the envelope arrived on). After a GOAWAY the old connection
1797
+ * refuses new streams, so an in-flight reply must ride the new one, which
1798
+ * lands on the new task. `origin` is the fallback if no handoff is active.
1799
+ * If a handoff is mid-dial, wait (bounded) for it to publish the new
1800
+ * active; if none is healthy in time, drop the reply (the server deadline
1801
+ * + third-party retry recover it).
1802
+ */
1803
+ async postHttpResponse(origin, requestId, status, userHeaders, body) {
1804
+ if (this.handoffInFlight && this.handoffPromise !== null) {
1805
+ await Promise.race([
1806
+ this.handoffPromise,
1807
+ setTimeoutPromise(POST_ACTIVE_WAIT_MS),
1808
+ ]);
1809
+ }
1810
+ const target = this.pickReplyConnection(origin);
1811
+ if (target === null) {
1812
+ // eslint-disable-next-line no-console
1813
+ console.warn(`no live connection to post reply request_id=${requestId}; dropping`);
1814
+ return;
1815
+ }
1816
+ await this.postResponse(target, requestId, status, userHeaders, body);
1817
+ }
1818
+ /** The active conn if it can take new streams, else the origin if it can. */
1819
+ pickReplyConnection(origin) {
1820
+ const usable = (c) => c !== null && !c.draining && c.session !== null && !c.session.closed;
1821
+ if (usable(this.active))
1822
+ return this.active;
1823
+ if (usable(origin))
1824
+ return origin;
1825
+ return null;
1826
+ }
1827
+ async postResponse(conn, requestId, status, userHeaders, body) {
1588
1828
  const reqHeaders = {
1589
1829
  [HTTP2_HEADER_METHOD]: "POST",
1590
1830
  [HTTP2_HEADER_SCHEME]: "https",
@@ -1624,7 +1864,7 @@ export class TunnelRuntime {
1624
1864
  reqHeaders[TunnelMetaHeader.REASON] = v;
1625
1865
  }
1626
1866
  }
1627
- const { streamId, stream } = this.openStream(reqHeaders, {
1867
+ const { streamId, stream } = this.openStream(conn, reqHeaders, {
1628
1868
  endStream: body.length === 0,
1629
1869
  });
1630
1870
  if (body.length > 0) {
@@ -1639,12 +1879,12 @@ export class TunnelRuntime {
1639
1879
  // block forever; cleanup either way.
1640
1880
  try {
1641
1881
  await Promise.race([
1642
- this.awaitResponse(streamId),
1882
+ this.awaitResponse(conn, streamId),
1643
1883
  setTimeoutPromise(30_000),
1644
1884
  ]);
1645
1885
  }
1646
1886
  finally {
1647
- this.streams.delete(streamId);
1887
+ conn.streams.delete(streamId);
1648
1888
  }
1649
1889
  }
1650
1890
  // --- utilities ---------------------------------------------------------