openai-ws-opencode 0.1.25 → 0.1.27

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 (43) hide show
  1. package/README.md +6 -1
  2. package/bin/setup.js +82 -30
  3. package/dist/constants.d.ts +1 -1
  4. package/dist/constants.js +1 -1
  5. package/dist/models/catalog.d.ts +2 -0
  6. package/dist/models/catalog.d.ts.map +1 -1
  7. package/dist/models/catalog.js +6 -2
  8. package/dist/models/catalog.js.map +1 -1
  9. package/dist/models/resolve.d.ts.map +1 -1
  10. package/dist/models/resolve.js +64 -40
  11. package/dist/models/resolve.js.map +1 -1
  12. package/dist/plugin.d.ts.map +1 -1
  13. package/dist/plugin.js +22 -12
  14. package/dist/plugin.js.map +1 -1
  15. package/dist/testing.d.ts +96 -2
  16. package/dist/testing.d.ts.map +1 -1
  17. package/dist/testing.js +18 -2
  18. package/dist/testing.js.map +1 -1
  19. package/dist/transport/breaker.d.ts +41 -0
  20. package/dist/transport/breaker.d.ts.map +1 -0
  21. package/dist/transport/breaker.js +207 -0
  22. package/dist/transport/breaker.js.map +1 -0
  23. package/dist/transport/bridge.d.ts +6 -1
  24. package/dist/transport/bridge.d.ts.map +1 -1
  25. package/dist/transport/bridge.js +274 -72
  26. package/dist/transport/bridge.js.map +1 -1
  27. package/dist/transport/config.d.ts +37 -1
  28. package/dist/transport/config.d.ts.map +1 -1
  29. package/dist/transport/config.js +318 -6
  30. package/dist/transport/config.js.map +1 -1
  31. package/dist/transport/coordinator.d.ts +223 -0
  32. package/dist/transport/coordinator.d.ts.map +1 -0
  33. package/dist/transport/coordinator.js +506 -0
  34. package/dist/transport/coordinator.js.map +1 -0
  35. package/dist/transport/errors.d.ts +24 -0
  36. package/dist/transport/errors.d.ts.map +1 -0
  37. package/dist/transport/errors.js +49 -0
  38. package/dist/transport/errors.js.map +1 -0
  39. package/dist/transport/pool.d.ts +114 -7
  40. package/dist/transport/pool.d.ts.map +1 -1
  41. package/dist/transport/pool.js +473 -188
  42. package/dist/transport/pool.js.map +1 -1
  43. package/package.json +2 -2
@@ -1,22 +1,18 @@
1
1
  import crypto from "node:crypto";
2
2
  import { CODEX_WS_URL, OPENAI_MODEL_HEADER, OPENAI_WS_INSTALLATION_ID_ENV, RESPONSE_PROCESSED_DISABLE_ENV, RESPONSE_PROCESSED_ENV, X_CODEX_INSTALLATION_ID_HEADER, X_CODEX_TURN_STATE_HEADER, X_CODEX_WINDOW_ID_HEADER, X_MODELS_ETAG_HEADER, X_OPENAI_SUBAGENT_HEADER, X_REASONING_INCLUDED_HEADER, } from "../constants.js";
3
3
  import { loadDefaultWebSocketConstructor } from "./bun-websocket.js";
4
+ import { createTransportBreaker } from "./breaker.js";
4
5
  import { transportConfig } from "./config.js";
6
+ import { canonicalWebSocketOrigin, configureTransportCoordinator, getTransportCoordinator, resetTransportCoordinatorForTesting, } from "./coordinator.js";
7
+ import { isWebSocketTransportError, WebSocketTransportError } from "./errors.js";
5
8
  let WebSocketImpl = loadDefaultWebSocketConstructor();
6
- export class WebSocketPreStreamTransportError extends Error {
7
- constructor(message) {
8
- super(message);
9
- this.name = "WebSocketPreStreamTransportError";
10
- }
11
- }
12
- export function isWebSocketPreStreamTransportError(error) {
13
- return error instanceof WebSocketPreStreamTransportError || (error instanceof Error && error.name === "WebSocketPreStreamTransportError");
14
- }
15
9
  export const connectionPool = [];
16
- const acquisitionQueues = new Map();
17
10
  const turnStateByContext = new Map();
18
11
  const lastResponseIDByContext = new Map();
12
+ const scopeCooldowns = new Map();
13
+ const authEndpoints = new Map();
19
14
  let nextConnectionID = 1;
15
+ let nextStreamID = 1;
20
16
  export const readyState = {
21
17
  CONNECTING: 0,
22
18
  OPEN: 1,
@@ -43,6 +39,23 @@ function authScopeHash(headers) {
43
39
  .join("\n");
44
40
  return crypto.createHash("sha256").update(auth).digest("hex");
45
41
  }
42
+ function accountFingerprint(headers) {
43
+ const account = headers["ChatGPT-Account-Id"] ?? headers["chatgpt-account-id"] ?? "";
44
+ if (account) {
45
+ return crypto.createHash("sha256").update(`acct:${account}`).digest("hex").slice(0, 32);
46
+ }
47
+ const auth = headers.Authorization ?? headers.authorization ?? "";
48
+ return crypto.createHash("sha256").update(`auth:${auth}`).digest("hex").slice(0, 32);
49
+ }
50
+ function breakerDomainKey(provider, origin, accountFp) {
51
+ return crypto.createHash("sha256").update(`${provider}\n${origin}\n${accountFp}`).digest("hex");
52
+ }
53
+ function providerForUrl(wsUrl) {
54
+ return wsUrl === CODEX_WS_URL || wsUrl.includes("chatgpt.com/backend-api/codex") ? "codex" : "openai";
55
+ }
56
+ function endpointKey(wsUrl) {
57
+ return canonicalWebSocketOrigin(wsUrl);
58
+ }
46
59
  function scopeKey(wsUrl, headers) {
47
60
  return `${wsUrl}::${authScopeHash(headers)}`;
48
61
  }
@@ -110,6 +123,82 @@ function finalizePending(pending) {
110
123
  pending.onFinalize?.();
111
124
  pending.onFinalize = undefined;
112
125
  }
126
+ function transportError(options) {
127
+ return new WebSocketTransportError({
128
+ code: options.code,
129
+ phase: options.phase,
130
+ message: options.message,
131
+ fallbackEligible: options.fallbackEligible,
132
+ breakerEligible: options.breakerEligible ?? false,
133
+ safeToReplay: options.safeToReplay ?? options.fallbackEligible,
134
+ cause: options.cause,
135
+ });
136
+ }
137
+ function boundAuthEndpoints() {
138
+ const max = transportConfig.maxAccountingEntries;
139
+ while (authEndpoints.size > max) {
140
+ const oldest = authEndpoints.keys().next().value;
141
+ if (oldest === undefined)
142
+ break;
143
+ authEndpoints.delete(oldest);
144
+ }
145
+ }
146
+ function bindAuthGeneration(endpoint, hash) {
147
+ let state = authEndpoints.get(endpoint);
148
+ if (!state) {
149
+ state = { currentGeneration: 1, currentHash: hash, bindings: new Map([[hash, 1]]), order: [hash] };
150
+ authEndpoints.set(endpoint, state);
151
+ boundAuthEndpoints();
152
+ return 1;
153
+ }
154
+ if (hash === state.currentHash)
155
+ return state.currentGeneration;
156
+ // ABA-safe: a remembered non-current binding must never become current.
157
+ const remembered = state.bindings.get(hash);
158
+ if (remembered !== undefined)
159
+ return remembered;
160
+ state.currentGeneration += 1;
161
+ state.currentHash = hash;
162
+ state.bindings.set(hash, state.currentGeneration);
163
+ state.order.push(hash);
164
+ const max = transportConfig.authGenerationMaxBindings;
165
+ while (state.order.length > max) {
166
+ const oldest = state.order.shift();
167
+ if (!oldest || oldest === state.currentHash) {
168
+ if (oldest)
169
+ state.order.push(oldest);
170
+ break;
171
+ }
172
+ state.bindings.delete(oldest);
173
+ }
174
+ return state.currentGeneration;
175
+ }
176
+ function currentAuthGeneration(endpoint) {
177
+ return authEndpoints.get(endpoint)?.currentGeneration ?? 0;
178
+ }
179
+ function isCurrentAuth(conn) {
180
+ return conn.authGeneration === currentAuthGeneration(conn.endpointKey);
181
+ }
182
+ function finishConnectPermit(conn, result) {
183
+ if (!conn.connectPermit || conn.connectFinished)
184
+ return;
185
+ conn.connectFinished = true;
186
+ getTransportCoordinator().finishConnect(conn.connectPermit, result);
187
+ conn.connectPermit = null;
188
+ }
189
+ function releaseSocketAccounting(conn) {
190
+ if (!conn.socketReserved || conn.socketReleased)
191
+ return;
192
+ conn.socketReleased = true;
193
+ getTransportCoordinator().releaseSocket({ accountFp: conn.accountFp, provider: conn.provider });
194
+ getTransportCoordinator().noteSocketClosed(Math.max(0, Date.now() - conn.createdAt), conn.turnCount);
195
+ }
196
+ function releaseSessionAccounting(conn) {
197
+ if (!conn.sessionCounted)
198
+ return;
199
+ conn.sessionCounted = false;
200
+ getTransportCoordinator().adjustSessions(-1);
201
+ }
113
202
  function detach(conn) {
114
203
  if (conn.detach) {
115
204
  conn.detach();
@@ -127,6 +216,9 @@ function remove(conn) {
127
216
  if (index >= 0)
128
217
  connectionPool.splice(index, 1);
129
218
  detach(conn);
219
+ finishConnectPermit(conn, { success: false, breakerEligible: false });
220
+ releaseSocketAccounting(conn);
221
+ releaseSessionAccounting(conn);
130
222
  }
131
223
  function retryDelay(attempt, retryAfterMs) {
132
224
  if (retryAfterMs !== undefined)
@@ -147,16 +239,8 @@ function closeSocket(conn, code, reason) {
147
239
  }
148
240
  catch { }
149
241
  }
150
- function terminalRelease(conn) {
151
- release(conn);
152
- drainQueue(conn.scopeKey);
153
- }
154
- function removeAndDrain(conn) {
155
- remove(conn);
156
- drainQueue(conn.scopeKey);
157
- }
158
242
  export function closeConnection(conn, reason = "connection closed", graceful = false) {
159
- removeAndDrain(conn);
243
+ remove(conn);
160
244
  if (graceful) {
161
245
  try {
162
246
  conn.ws?.close(1000, reason);
@@ -178,16 +262,22 @@ function release(conn) {
178
262
  conn.frameCarryover = "";
179
263
  clearTimer(conn.idleTimer);
180
264
  conn.idleTimer = null;
181
- if (conn.staleAuth) {
182
- removeAndDrain(conn);
265
+ if (conn.staleAuth || !isCurrentAuth(conn)) {
266
+ remove(conn);
183
267
  closeSocket(conn, 1000, "auth changed");
184
268
  return;
185
269
  }
270
+ if (Date.now() >= conn.expiresAt) {
271
+ getTransportCoordinator().noteMaxAgeRetire();
272
+ remove(conn);
273
+ closeSocket(conn, 1000, "max age");
274
+ return;
275
+ }
186
276
  if (!conn.warm) {
187
277
  conn.idleTimer = unrefTimer(setTimeout(() => {
188
278
  conn.idleTimer = null;
189
279
  if (!conn.busy) {
190
- removeAndDrain(conn);
280
+ remove(conn);
191
281
  try {
192
282
  conn.ws?.close(1000, "idle eviction");
193
283
  }
@@ -231,7 +321,7 @@ function fail(conn, error, shouldCloseSocket) {
231
321
  else {
232
322
  finalizePending(pending);
233
323
  try {
234
- pending.controller.error(error);
324
+ pending.sink.error(error);
235
325
  }
236
326
  catch { }
237
327
  }
@@ -241,12 +331,9 @@ function fail(conn, error, shouldCloseSocket) {
241
331
  conn.activeSessionID = undefined;
242
332
  conn.activeAgent = undefined;
243
333
  if (shouldCloseSocket) {
244
- removeAndDrain(conn);
334
+ remove(conn);
245
335
  closeSocket(conn, 1000, "aborted");
246
336
  }
247
- else {
248
- drainQueue(conn.scopeKey);
249
- }
250
337
  }
251
338
  function isTerminalEvent(eventType) {
252
339
  return ["response.completed", "response.failed", "response.incomplete"].includes(eventType);
@@ -277,7 +364,12 @@ function schedulePendingIdleTimeout(conn, reason) {
277
364
  current.idleTimer = null;
278
365
  conn.lastCloseCode = 1006;
279
366
  conn.lastCloseReason = "idle timeout waiting for websocket";
280
- fail(conn, new Error(formatFailureMessage(conn, `idle timeout waiting for websocket after ${reason}`)), true);
367
+ fail(conn, transportError({
368
+ code: "active_socket_loss",
369
+ phase: "active",
370
+ message: formatFailureMessage(conn, `idle timeout waiting for websocket after ${reason}`),
371
+ fallbackEligible: false,
372
+ }), true);
281
373
  }, transportConfig.streamIdleTimeoutMs));
282
374
  }
283
375
  function decodeFrameData(data) {
@@ -366,15 +458,63 @@ function messageFromError(error) {
366
458
  }
367
459
  return error !== undefined && error !== null ? String(error) : undefined;
368
460
  }
369
- function wrappedWebSocketError(frame) {
461
+ function websocketErrorCode(frame) {
462
+ const error = frame.error && typeof frame.error === "object" ? frame.error : {};
463
+ return typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
464
+ }
465
+ function hasReplaySafeInput(body) {
466
+ const input = body.input;
467
+ if (typeof input === "string")
468
+ return input.length > 0;
469
+ if (!Array.isArray(input))
470
+ return false;
471
+ return (input.length > 0 &&
472
+ input.every((item) => {
473
+ if (!item || typeof item !== "object" || Array.isArray(item))
474
+ return false;
475
+ const value = item;
476
+ return value.type === "message";
477
+ }));
478
+ }
479
+ function wrappedProviderRejection(frame, pending) {
370
480
  if (frame.type !== "error")
371
481
  return undefined;
482
+ const code = websocketErrorCode(frame);
372
483
  const error = frame.error && typeof frame.error === "object" ? frame.error : {};
373
- const code = typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
374
484
  const message = typeof error.message === "string" ? error.message : typeof frame.message === "string" ? frame.message : code;
485
+ if (code === "previous_response_not_found") {
486
+ pending.metadata.responseId = undefined;
487
+ const replaySafe = hasReplaySafeInput(pending.body) && !pending.framesReceived;
488
+ return transportError({
489
+ code: "previous_response_not_found",
490
+ phase: pending.framesReceived ? "active" : "pre_send",
491
+ message: replaySafe
492
+ ? message ?? "Previous response not found"
493
+ : "OpenAI WebSocket previous_response_not_found; cannot safely retry without previous_response_id because request input does not contain replayable message context",
494
+ fallbackEligible: replaySafe,
495
+ safeToReplay: replaySafe,
496
+ breakerEligible: false,
497
+ });
498
+ }
375
499
  if (code === "websocket_connection_limit_reached") {
376
- return new Error(message ?? "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.");
500
+ return transportError({
501
+ code: "provider_connection_limit",
502
+ phase: pending.framesReceived ? "active" : "pre_send",
503
+ message: message ??
504
+ "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.",
505
+ fallbackEligible: !pending.framesReceived,
506
+ safeToReplay: !pending.framesReceived,
507
+ breakerEligible: false,
508
+ });
377
509
  }
510
+ return undefined;
511
+ }
512
+ function wrappedWebSocketError(frame) {
513
+ if (frame.type !== "error")
514
+ return undefined;
515
+ const error = frame.error && typeof frame.error === "object" ? frame.error : {};
516
+ const code = typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
517
+ const message = typeof error.message === "string" ? error.message : typeof frame.message === "string" ? frame.message : code;
378
518
  const status = numberFrom(frame.status) ??
379
519
  numberFrom(frame.status_code) ??
380
520
  numberFrom(error.status) ??
@@ -385,7 +525,7 @@ function wrappedWebSocketError(frame) {
385
525
  return new Error(`OpenAI WebSocket error${status !== undefined ? ` ${status}` : ""}${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`);
386
526
  }
387
527
  function isCodexConnection(conn) {
388
- return conn.wsUrl === CODEX_WS_URL || conn.wsUrl.includes("chatgpt.com/backend-api/codex");
528
+ return conn.provider === "codex";
389
529
  }
390
530
  function shouldSendResponseProcessed(conn) {
391
531
  if (process.env[RESPONSE_PROCESSED_DISABLE_ENV] === "1")
@@ -409,23 +549,6 @@ function sendResponseProcessed(conn, pending, eventType) {
409
549
  conn.lastErrorMessage = error instanceof Error ? error.message : String(error);
410
550
  }
411
551
  }
412
- function websocketErrorCode(frame) {
413
- const error = frame.error && typeof frame.error === "object" ? frame.error : {};
414
- return typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
415
- }
416
- function hasReplaySafeInput(body) {
417
- const input = body.input;
418
- if (typeof input === "string")
419
- return input.length > 0;
420
- if (!Array.isArray(input))
421
- return false;
422
- return input.length > 0 && input.every((item) => {
423
- if (!item || typeof item !== "object" || Array.isArray(item))
424
- return false;
425
- const value = item;
426
- return value.type === "message";
427
- });
428
- }
429
552
  function clearLastResponseID(conn) {
430
553
  conn.lastResponseID = undefined;
431
554
  lastResponseIDByContext.delete(conn.contextKey);
@@ -458,30 +581,25 @@ function cacheResponseMetadata(conn, pending, responseID) {
458
581
  function isStaleResponseFrame(pending, responseID) {
459
582
  return Boolean(responseID && pending.metadata.activeResponseId && responseID !== pending.metadata.activeResponseId);
460
583
  }
461
- function handlePreviousResponseNotFound(conn, pending) {
462
- clearLastResponseID(conn);
463
- pending.metadata.responseId = undefined;
464
- if (pending.previousResponseNotFoundRetried) {
465
- fail(conn, new Error("OpenAI WebSocket previous_response_not_found after retrying without previous_response_id"), true);
466
- return true;
584
+ function sweepScopeCooldowns(now) {
585
+ for (const [key, until] of scopeCooldowns) {
586
+ if (until <= now)
587
+ scopeCooldowns.delete(key);
467
588
  }
468
- if (!hasReplaySafeInput(pending.body)) {
469
- fail(conn, new Error("OpenAI WebSocket previous_response_not_found; cannot safely retry without previous_response_id because request input does not contain replayable message context"), true);
470
- return true;
471
- }
472
- pending.previousResponseNotFoundRetried = true;
473
- pending.body.previous_response_id = null;
474
- pending.sent = false;
475
- pending.writeCommitted = false;
476
- pending.framesReceived = false;
477
- pending.finalMessageOutputReceived = false;
478
- pending.processedAckSent = false;
479
- conn.frameCarryover = "";
480
- clearPendingTimer(pending);
481
- if (conn.ws?.readyState !== readyState.OPEN || !sendPending(conn)) {
482
- fail(conn, new Error("OpenAI WebSocket previous_response_not_found; retry without previous_response_id could not be sent"), true);
589
+ const max = transportConfig.maxAccountingEntries;
590
+ while (scopeCooldowns.size > max) {
591
+ const oldest = scopeCooldowns.keys().next().value;
592
+ if (oldest === undefined)
593
+ break;
594
+ scopeCooldowns.delete(oldest);
483
595
  }
484
- return true;
596
+ }
597
+ function setScopeCooldown(scopeKeyValue) {
598
+ const now = Date.now();
599
+ sweepScopeCooldowns(now);
600
+ scopeCooldowns.set(scopeKeyValue, now + transportConfig.sessionCooldownMs);
601
+ sweepScopeCooldowns(now);
602
+ getTransportCoordinator().noteCooldown();
485
603
  }
486
604
  function enqueueSSE(conn, frame) {
487
605
  const pending = conn.pending;
@@ -491,24 +609,36 @@ function enqueueSSE(conn, frame) {
491
609
  const eventType = typeof frame.type === "string" ? frame.type : "message";
492
610
  if (eventType !== "response.created" && isStaleResponseFrame(pending, responseID))
493
611
  return;
494
- pending.writeCommitted = true;
495
- pending.framesReceived = true;
496
- cacheResponseMetadata(conn, pending, responseID);
497
- if (isFinalMessageOutputItem(frame))
498
- pending.finalMessageOutputReceived = true;
612
+ const providerRejection = wrappedProviderRejection(frame, pending);
613
+ if (providerRejection) {
614
+ if (providerRejection.code === "previous_response_not_found")
615
+ clearLastResponseID(conn);
616
+ if (providerRejection.code === "provider_connection_limit")
617
+ setScopeCooldown(conn.scopeKey);
618
+ fail(conn, providerRejection, true);
619
+ return;
620
+ }
499
621
  const mappedError = wrappedWebSocketError(frame);
500
622
  if (mappedError) {
501
- if (websocketErrorCode(frame) === "previous_response_not_found" && handlePreviousResponseNotFound(conn, pending))
502
- return;
503
623
  clearLastResponseID(conn);
504
624
  fail(conn, mappedError, true);
505
625
  return;
506
626
  }
627
+ pending.writeCommitted = true;
628
+ pending.framesReceived = true;
629
+ cacheResponseMetadata(conn, pending, responseID);
630
+ if (isFinalMessageOutputItem(frame))
631
+ pending.finalMessageOutputReceived = true;
507
632
  const encoded = new TextEncoder().encode(`event: ${eventType}\ndata: ${JSON.stringify(frame)}\n\n`);
508
633
  try {
509
- pending.controller.enqueue(encoded);
634
+ pending.sink.enqueue(encoded);
510
635
  }
511
- catch {
636
+ catch (error) {
637
+ if (isWebSocketTransportError(error) && error.code === "queue_overflow") {
638
+ fail(conn, error, true);
639
+ return;
640
+ }
641
+ fail(conn, error instanceof Error ? error : new Error(String(error)), true);
512
642
  return;
513
643
  }
514
644
  if (!isTerminalEvent(eventType))
@@ -518,23 +648,22 @@ function enqueueSSE(conn, frame) {
518
648
  pending.done = true;
519
649
  sendResponseProcessed(conn, pending, eventType);
520
650
  try {
521
- pending.controller.close();
651
+ pending.sink.close();
522
652
  }
523
653
  catch { }
524
- if (pending.metadata.responseId) {
654
+ if (pending.metadata.responseId)
525
655
  persistLastResponseID(conn, pending.metadata.responseId);
526
- }
527
- else {
656
+ else
528
657
  clearLastResponseID(conn);
529
- }
530
- terminalRelease(conn);
658
+ conn.pending = null;
659
+ release(conn);
531
660
  }
532
661
  }
533
662
  function finishAfterFinalOutputSocketClose(conn, pending) {
534
663
  finalizePending(pending);
535
664
  pending.done = true;
536
665
  try {
537
- pending.controller.close();
666
+ pending.sink.close();
538
667
  }
539
668
  catch { }
540
669
  if (pending.metadata.responseId)
@@ -545,7 +674,7 @@ function finishAfterFinalOutputSocketClose(conn, pending) {
545
674
  conn.busy = false;
546
675
  conn.activeSessionID = undefined;
547
676
  conn.activeAgent = undefined;
548
- removeAndDrain(conn);
677
+ remove(conn);
549
678
  }
550
679
  export function sendPending(conn) {
551
680
  const pending = conn.pending;
@@ -554,6 +683,17 @@ export function sendPending(conn) {
554
683
  const ws = conn.ws;
555
684
  if (!ws || ws.readyState !== readyState.OPEN)
556
685
  return false;
686
+ if (!isCurrentAuth(conn) || conn.staleAuth) {
687
+ getTransportCoordinator().noteAuthStale();
688
+ fail(conn, transportError({
689
+ code: "stale_auth",
690
+ phase: "pre_send",
691
+ message: "OpenAI WebSocket auth generation is stale; refusing response.create",
692
+ fallbackEligible: true,
693
+ safeToReplay: true,
694
+ }), true);
695
+ return false;
696
+ }
557
697
  const generation = conn.generation;
558
698
  try {
559
699
  const body = { ...pending.body };
@@ -625,7 +765,36 @@ function parseRetryAfterMs(source) {
625
765
  return Math.max(0, date - Date.now());
626
766
  return undefined;
627
767
  }
768
+ function beginHandshake(conn) {
769
+ const coordinator = getTransportCoordinator();
770
+ const started = coordinator.startConnect({
771
+ intent: conn.intent,
772
+ provider: conn.provider,
773
+ origin: conn.origin,
774
+ accountFp: conn.accountFp,
775
+ scopeKey: conn.breakerDomain,
776
+ });
777
+ if (!started.ok) {
778
+ coordinator.noteAdmissionMiss(started.reason);
779
+ fail(conn, transportError({
780
+ code: started.reason,
781
+ phase: "handshake",
782
+ message: `OpenAI WebSocket handshake admission denied: ${started.reason}`,
783
+ fallbackEligible: true,
784
+ breakerEligible: started.reason === "provider_breaker",
785
+ }), true);
786
+ return false;
787
+ }
788
+ conn.connectPermit = started.permit;
789
+ conn.connectFinished = false;
790
+ return true;
791
+ }
628
792
  function connect(conn) {
793
+ // Initial create pre-reserves a permit; each bounded pre-send reconnect takes a fresh one.
794
+ if (!conn.connectPermit || conn.connectFinished) {
795
+ if (!beginHandshake(conn))
796
+ return;
797
+ }
629
798
  const generation = ++conn.generation;
630
799
  conn.frameCarryover = "";
631
800
  clearTimer(conn.retryTimer);
@@ -636,6 +805,7 @@ function connect(conn) {
636
805
  return;
637
806
  conn.lastCloseCode = 1006;
638
807
  conn.lastCloseReason = "connection timed out";
808
+ finishConnectPermit(conn, { success: false, breakerEligible: true, timeout: true });
639
809
  handleSocketLoss(conn);
640
810
  }, transportConfig.connectTimeoutMs);
641
811
  conn.connectTimer = unrefTimer(connectTimer);
@@ -645,6 +815,7 @@ function connect(conn) {
645
815
  clearTimer(conn.connectTimer);
646
816
  conn.connectTimer = null;
647
817
  conn.lastActivityAt = Date.now();
818
+ finishConnectPermit(conn, { success: true });
648
819
  sendPending(conn);
649
820
  };
650
821
  const handleUpgrade = (response) => {
@@ -676,6 +847,7 @@ function connect(conn) {
676
847
  if (generation !== conn.generation)
677
848
  return;
678
849
  conn.lastErrorMessage = messageFromError(error) ?? "unknown websocket error";
850
+ finishConnectPermit(conn, { success: false, breakerEligible: true });
679
851
  handleSocketLoss(conn);
680
852
  };
681
853
  const handleClose = (codeOrEvent, reason) => {
@@ -685,8 +857,10 @@ function connect(conn) {
685
857
  const normalizedReason = closeReasonFrom(codeOrEvent, reason);
686
858
  if (normalizedReason !== undefined)
687
859
  conn.lastCloseReason = normalizedReason;
688
- else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined)
860
+ else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined) {
689
861
  conn.lastCloseReason = "socket closed without close code";
862
+ }
863
+ finishConnectPermit(conn, { success: false, breakerEligible: true });
690
864
  handleSocketLoss(conn);
691
865
  };
692
866
  const handleUnexpectedResponse = (_request, response) => {
@@ -697,6 +871,7 @@ function connect(conn) {
697
871
  conn.lastCloseCode = status;
698
872
  conn.retryAfterMs = parseRetryAfterMs(response);
699
873
  conn.lastCloseReason = `websocket handshake failed${status !== undefined ? ` with status ${status}` : ""}`;
874
+ finishConnectPermit(conn, { success: false, breakerEligible: true });
700
875
  handleSocketLoss(conn);
701
876
  };
702
877
  const attachUpgrade = supportsUpgradeEvent(WebSocketImpl);
@@ -714,6 +889,7 @@ function connect(conn) {
714
889
  }
715
890
  catch (error) {
716
891
  conn.lastErrorMessage = messageFromError(error) ?? "websocket constructor failed";
892
+ finishConnectPermit(conn, { success: false, breakerEligible: true });
717
893
  queueMicrotask(() => handleSocketLoss(conn));
718
894
  return;
719
895
  }
@@ -745,7 +921,7 @@ function handleSocketLoss(conn) {
745
921
  const pending = conn.pending;
746
922
  if (!pending || pending.done) {
747
923
  finalizePending(pending);
748
- removeAndDrain(conn);
924
+ remove(conn);
749
925
  closeSocket(conn, 1000, "socket lost");
750
926
  return;
751
927
  }
@@ -753,8 +929,13 @@ function handleSocketLoss(conn) {
753
929
  finishAfterFinalOutputSocketClose(conn, pending);
754
930
  return;
755
931
  }
756
- if (pending.writeCommitted) {
757
- fail(conn, new Error(formatFailureMessage(conn, "response.create was already sent")), true);
932
+ if (pending.framesReceived || pending.writeCommitted) {
933
+ fail(conn, transportError({
934
+ code: "active_socket_loss",
935
+ phase: "active",
936
+ message: formatFailureMessage(conn, "response.create was already sent"),
937
+ fallbackEligible: false,
938
+ }), true);
758
939
  return;
759
940
  }
760
941
  const canRetry = conn.reconnectAttempts < transportConfig.maxReconnectAttempts;
@@ -780,17 +961,34 @@ function handleSocketLoss(conn) {
780
961
  conn.ws = null;
781
962
  conn.retryTimer = unrefTimer(setTimeout(() => {
782
963
  conn.retryTimer = null;
783
- if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted)
964
+ if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted) {
784
965
  return;
966
+ }
785
967
  connect(conn);
786
968
  }, retryDelay(conn.reconnectAttempts, retryAfterMs)));
787
969
  return;
788
970
  }
789
- fail(conn, new WebSocketPreStreamTransportError(formatFailureMessage(conn, "retry limit reached before response.create was sent")), true);
790
- }
791
- function create(wsUrl, headers, context = {}, warm = false) {
971
+ fail(conn, transportError({
972
+ code: "connect_failure",
973
+ phase: "handshake",
974
+ message: formatFailureMessage(conn, "retry limit reached before response.create was sent"),
975
+ fallbackEligible: true,
976
+ breakerEligible: true,
977
+ safeToReplay: true,
978
+ }), true);
979
+ }
980
+ function computeExpiresAt(now) {
981
+ const jitter = Math.floor(Math.random() * (transportConfig.connectionMaxAgeJitterMs + 1));
982
+ return now + transportConfig.connectionMaxAgeMs - jitter;
983
+ }
984
+ function create(wsUrl, headers, context = {}, warm = false, permit) {
792
985
  const hash = authScopeHash(headers);
793
986
  const key = contextScopeKey(wsUrl, headers, context);
987
+ const endpoint = endpointKey(wsUrl);
988
+ const provider = providerForUrl(wsUrl);
989
+ const origin = canonicalWebSocketOrigin(wsUrl);
990
+ const accountFp = accountFingerprint(headers);
991
+ const now = Date.now();
794
992
  const conn = {
795
993
  id: `ws-${nextConnectionID++}`,
796
994
  ws: null,
@@ -799,6 +997,11 @@ function create(wsUrl, headers, context = {}, warm = false) {
799
997
  scopeKey: key,
800
998
  scopeHash: hash,
801
999
  contextKey: key,
1000
+ endpointKey: endpoint,
1001
+ accountFp,
1002
+ provider,
1003
+ origin,
1004
+ authGeneration: bindAuthGeneration(endpoint, hash),
802
1005
  busy: false,
803
1006
  warm,
804
1007
  staleAuth: false,
@@ -806,20 +1009,30 @@ function create(wsUrl, headers, context = {}, warm = false) {
806
1009
  lastResponseID: lastResponseIDByContext.get(key),
807
1010
  generation: 0,
808
1011
  reconnectAttempts: 0,
809
- createdAt: Date.now(),
810
- lastActivityAt: Date.now(),
1012
+ createdAt: now,
1013
+ expiresAt: computeExpiresAt(now),
1014
+ lastActivityAt: now,
811
1015
  idleTimer: null,
812
1016
  connectTimer: null,
813
1017
  retryTimer: null,
814
1018
  frameCarryover: "",
815
1019
  detach: null,
1020
+ socketReserved: true,
1021
+ socketReleased: false,
1022
+ sessionCounted: true,
1023
+ connectPermit: permit,
1024
+ connectFinished: false,
1025
+ turnCount: 0,
1026
+ intent: warm ? "prewarm" : "demand",
1027
+ breakerDomain: breakerDomainKey(provider, origin, accountFp),
816
1028
  };
817
1029
  connectionPool.push(conn);
1030
+ getTransportCoordinator().adjustSessions(1);
818
1031
  connect(conn);
819
1032
  return conn;
820
1033
  }
821
1034
  function isReusable(conn, key, context, now) {
822
- if (conn.staleAuth)
1035
+ if (conn.staleAuth || !isCurrentAuth(conn))
823
1036
  return false;
824
1037
  if (conn.busy)
825
1038
  return false;
@@ -829,7 +1042,7 @@ function isReusable(conn, key, context, now) {
829
1042
  return false;
830
1043
  if (now - conn.lastActivityAt > transportConfig.staleReuseMs)
831
1044
  return false;
832
- if (now - conn.createdAt > transportConfig.connectionMaxAgeMs)
1045
+ if (now >= conn.expiresAt)
833
1046
  return false;
834
1047
  if (context.sessionID && conn.lastSessionID && conn.lastSessionID !== context.sessionID)
835
1048
  return false;
@@ -837,129 +1050,162 @@ function isReusable(conn, key, context, now) {
837
1050
  return false;
838
1051
  return true;
839
1052
  }
840
- function activeCount(key) {
841
- return connectionPool.filter((conn) => conn.scopeKey === key && (conn.busy || conn.ws?.readyState === readyState.CONNECTING)).length;
1053
+ function activeBusy(key) {
1054
+ return connectionPool.some((conn) => conn.scopeKey === key && conn.busy);
842
1055
  }
843
1056
  export function invalidateStaleAuthConnections(wsUrl, headers) {
844
- const currentHash = authScopeHash(headers);
1057
+ const endpoint = endpointKey(wsUrl);
1058
+ const hash = authScopeHash(headers);
1059
+ const state = authEndpoints.get(endpoint);
1060
+ // ABA: presenting an old remembered binding must not become current or stale newer sockets.
1061
+ if (state && hash !== state.currentHash && state.bindings.has(hash))
1062
+ return;
1063
+ const generation = bindAuthGeneration(endpoint, hash);
1064
+ const keepHash = authEndpoints.get(endpoint)?.currentHash ?? hash;
1065
+ const keepGen = authEndpoints.get(endpoint)?.currentGeneration ?? generation;
845
1066
  for (const conn of [...connectionPool]) {
846
- if (conn.wsUrl !== wsUrl || conn.scopeHash === currentHash)
1067
+ if (conn.wsUrl !== wsUrl)
1068
+ continue;
1069
+ if (conn.scopeHash === keepHash && conn.authGeneration === keepGen)
847
1070
  continue;
848
1071
  conn.staleAuth = true;
849
1072
  if (conn.busy)
850
1073
  continue;
851
- removeAndDrain(conn);
1074
+ remove(conn);
852
1075
  closeSocket(conn, 1000, "auth changed");
853
1076
  }
854
1077
  }
855
- function cleanupStaleConnections(key, now) {
1078
+ function cleanupExpiredIdle(now) {
1079
+ sweepScopeCooldowns(now);
856
1080
  for (const candidate of [...connectionPool]) {
857
- if (candidate.busy || candidate.scopeKey !== key)
1081
+ if (candidate.busy)
858
1082
  continue;
859
- if (candidate.ws?.readyState === readyState.OPEN &&
860
- (now - candidate.lastActivityAt > transportConfig.staleReuseMs ||
861
- now - candidate.createdAt > transportConfig.connectionMaxAgeMs)) {
862
- remove(candidate);
863
- try {
864
- candidate.ws?.close(1000, "stale idle");
865
- }
866
- catch { }
1083
+ const state = candidate.ws?.readyState;
1084
+ if (state !== readyState.OPEN && state !== readyState.CONNECTING)
1085
+ continue;
1086
+ const maxAgeExpired = now >= candidate.expiresAt;
1087
+ const staleIdle = now - candidate.lastActivityAt > transportConfig.staleReuseMs;
1088
+ if (!maxAgeExpired && !staleIdle)
1089
+ continue;
1090
+ // Max-age retirement is only for jittered expiry, never inactivity alone.
1091
+ if (maxAgeExpired)
1092
+ getTransportCoordinator().noteMaxAgeRetire();
1093
+ remove(candidate);
1094
+ try {
1095
+ candidate.ws?.close(1000, maxAgeExpired ? "max age" : "stale idle");
867
1096
  }
1097
+ catch { }
868
1098
  }
869
1099
  }
1100
+ function admitMiss(code, message) {
1101
+ getTransportCoordinator().noteAdmissionMiss(code);
1102
+ throw transportError({
1103
+ code,
1104
+ phase: "admission",
1105
+ message,
1106
+ fallbackEligible: true,
1107
+ breakerEligible: code === "provider_breaker",
1108
+ safeToReplay: true,
1109
+ });
1110
+ }
870
1111
  function reserveConnection(wsUrl, headers, context) {
1112
+ const coordinator = getTransportCoordinator();
1113
+ coordinator.noteLeaseAttempt();
871
1114
  invalidateStaleAuthConnections(wsUrl, headers);
872
1115
  const key = contextScopeKey(wsUrl, headers, context);
873
1116
  const now = Date.now();
874
- cleanupStaleConnections(key, now);
1117
+ cleanupExpiredIdle(now);
1118
+ const cooldownUntil = scopeCooldowns.get(key);
1119
+ if (cooldownUntil && now < cooldownUntil) {
1120
+ admitMiss("session_cooldown", `OpenAI WebSocket session cooldown active for ${Math.max(0, cooldownUntil - now)}ms`);
1121
+ }
1122
+ if (activeBusy(key)) {
1123
+ admitMiss("session_busy", "OpenAI WebSocket session already has an active turn");
1124
+ }
1125
+ const endpoint = endpointKey(wsUrl);
1126
+ const hash = authScopeHash(headers);
1127
+ const generation = bindAuthGeneration(endpoint, hash);
1128
+ if (generation !== currentAuthGeneration(endpoint)) {
1129
+ coordinator.noteAuthStale();
1130
+ admitMiss("stale_auth", "OpenAI WebSocket auth generation is stale");
1131
+ }
875
1132
  const reusable = connectionPool.find((conn) => isReusable(conn, key, context, now));
876
1133
  if (reusable) {
877
1134
  clearTimer(reusable.idleTimer);
878
1135
  reusable.idleTimer = null;
879
1136
  reusable.busy = true;
880
1137
  reusable.warm = false;
1138
+ reusable.intent = "demand";
1139
+ reusable.turnCount += 1;
1140
+ coordinator.noteLeaseAdmission();
881
1141
  return reusable;
882
1142
  }
883
- if (activeCount(key) >= transportConfig.maxConnectionsPerScope)
884
- return null;
885
- const conn = create(wsUrl, headers, context);
886
- conn.busy = true;
887
- return conn;
888
- }
889
- function queueAcquire(wsUrl, headers, context, signal) {
890
- const key = contextScopeKey(wsUrl, headers, context);
891
- return new Promise((resolve, reject) => {
892
- const entry = {
893
- wsUrl,
894
- headers,
895
- context,
896
- resolve,
897
- reject,
898
- signal,
899
- onAbort: () => {
900
- const queue = acquisitionQueues.get(key);
901
- if (queue) {
902
- const index = queue.indexOf(entry);
903
- if (index >= 0)
904
- queue.splice(index, 1);
905
- if (queue.length === 0)
906
- acquisitionQueues.delete(key);
907
- }
908
- reject(signal?.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError"));
909
- },
910
- };
911
- if (signal?.aborted) {
912
- entry.onAbort();
913
- return;
914
- }
915
- signal?.addEventListener("abort", entry.onAbort, { once: true });
916
- const queue = acquisitionQueues.get(key) ?? [];
917
- queue.push(entry);
918
- acquisitionQueues.set(key, queue);
1143
+ const accountFp = accountFingerprint(headers);
1144
+ const provider = providerForUrl(wsUrl);
1145
+ const origin = canonicalWebSocketOrigin(wsUrl);
1146
+ const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "demand" });
1147
+ if (!reserved.ok) {
1148
+ admitMiss(reserved.reason, `OpenAI WebSocket socket admission denied: ${reserved.reason}`);
1149
+ }
1150
+ const started = coordinator.startConnect({
1151
+ intent: "demand",
1152
+ provider,
1153
+ origin,
1154
+ accountFp,
1155
+ scopeKey: breakerDomainKey(provider, origin, accountFp),
919
1156
  });
920
- }
921
- function drainQueue(key) {
922
- const queue = acquisitionQueues.get(key);
923
- if (!queue?.length)
924
- return;
925
- while (queue.length) {
926
- const entry = queue[0];
927
- if (entry.signal?.aborted) {
928
- queue.shift();
929
- entry.onAbort();
930
- continue;
931
- }
932
- const conn = reserveConnection(entry.wsUrl, entry.headers, entry.context);
933
- if (!conn)
934
- break;
935
- queue.shift();
936
- entry.signal?.removeEventListener("abort", entry.onAbort);
937
- entry.resolve(conn);
1157
+ if (!started.ok) {
1158
+ coordinator.releaseSocket({ accountFp, provider });
1159
+ admitMiss(started.reason, `OpenAI WebSocket connect admission denied: ${started.reason}`);
938
1160
  }
939
- if (queue.length === 0)
940
- acquisitionQueues.delete(key);
1161
+ const conn = create(wsUrl, headers, context, false, started.permit);
1162
+ conn.busy = true;
1163
+ conn.turnCount += 1;
1164
+ coordinator.noteLeaseAdmission();
1165
+ return conn;
941
1166
  }
942
- export function acquireConnection(wsUrl, headers, context, signal) {
943
- const conn = reserveConnection(wsUrl, headers, context);
944
- if (conn)
945
- return conn;
946
- return queueAcquire(wsUrl, headers, context, signal);
1167
+ export function acquireConnection(wsUrl, headers, context, _signal) {
1168
+ return reserveConnection(wsUrl, headers, context);
947
1169
  }
948
1170
  export function ensureWarmConnection(wsUrl, headers) {
1171
+ const coordinator = getTransportCoordinator();
1172
+ coordinator.notePrewarmAttempt();
949
1173
  invalidateStaleAuthConnections(wsUrl, headers);
950
1174
  const key = contextScopeKey(wsUrl, headers, {});
951
1175
  const now = Date.now();
952
- cleanupStaleConnections(key, now);
953
- const existing = connectionPool.find((conn) => conn.scopeKey === key && !conn.busy && conn.ws?.readyState === readyState.OPEN);
1176
+ cleanupExpiredIdle(now);
1177
+ const existing = connectionPool.find((conn) => conn.scopeKey === key &&
1178
+ !conn.busy &&
1179
+ !conn.staleAuth &&
1180
+ isCurrentAuth(conn) &&
1181
+ (conn.ws?.readyState === readyState.OPEN || conn.ws?.readyState === readyState.CONNECTING));
954
1182
  if (existing) {
955
1183
  existing.warm = true;
956
1184
  clearTimer(existing.idleTimer);
957
1185
  existing.idleTimer = null;
958
1186
  return existing;
959
1187
  }
960
- if (activeCount(key) >= transportConfig.maxConnectionsPerScope)
1188
+ const accountFp = accountFingerprint(headers);
1189
+ const provider = providerForUrl(wsUrl);
1190
+ const origin = canonicalWebSocketOrigin(wsUrl);
1191
+ const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "prewarm" });
1192
+ if (!reserved.ok) {
1193
+ coordinator.notePrewarmDeferred();
961
1194
  return undefined;
962
- return create(wsUrl, headers, {}, true);
1195
+ }
1196
+ const started = coordinator.startConnect({
1197
+ intent: "prewarm",
1198
+ provider,
1199
+ origin,
1200
+ accountFp,
1201
+ scopeKey: breakerDomainKey(provider, origin, accountFp),
1202
+ });
1203
+ if (!started.ok) {
1204
+ coordinator.releaseSocket({ accountFp, provider });
1205
+ coordinator.notePrewarmDeferred();
1206
+ return undefined;
1207
+ }
1208
+ return create(wsUrl, headers, {}, true, started.permit);
963
1209
  }
964
1210
  export function closeConnections(predicate = () => true, message = "Session disposed") {
965
1211
  for (const conn of [...connectionPool]) {
@@ -968,6 +1214,45 @@ export function closeConnections(predicate = () => true, message = "Session disp
968
1214
  fail(conn, new Error(message), true);
969
1215
  }
970
1216
  }
1217
+ export function nextStreamKeyForTesting() {
1218
+ return `stream-${nextStreamID++}`;
1219
+ }
1220
+ export function allocateStreamKey() {
1221
+ return `stream-${nextStreamID++}`;
1222
+ }
1223
+ export function syncCoordinatorFromTransportConfig() {
1224
+ configureTransportCoordinator({
1225
+ processMaxSockets: transportConfig.processMaxSockets,
1226
+ maxSocketsPerProvider: transportConfig.maxSocketsPerProvider,
1227
+ maxSocketsPerAccount: transportConfig.maxSocketsPerAccount,
1228
+ maxConnecting: transportConfig.maxConnecting,
1229
+ prewarmMaxConnecting: transportConfig.prewarmMaxConnecting,
1230
+ prewarmDemandReserve: transportConfig.prewarmDemandReserve,
1231
+ connectRateProcessPerMin: transportConfig.connectRateProcessPerMin,
1232
+ connectRateProviderPerMin: transportConfig.connectRateProviderPerMin,
1233
+ connectRateOriginPerMin: transportConfig.connectRateOriginPerMin,
1234
+ connectRateAccountPerMin: transportConfig.connectRateAccountPerMin,
1235
+ maxAccountingEntries: transportConfig.maxAccountingEntries,
1236
+ queueMaxBytesPerStream: transportConfig.queueMaxBytesPerStream,
1237
+ queueMaxBytesTotal: transportConfig.queueMaxBytesTotal,
1238
+ maxSessions: transportConfig.shedMaxSessions,
1239
+ eventLoopLagMs: transportConfig.shedLagMs,
1240
+ rssMb: transportConfig.shedRssMb,
1241
+ breaker: createTransportBreaker({
1242
+ windowMs: transportConfig.breakerWindowMs,
1243
+ minSamples: transportConfig.breakerMinSamples,
1244
+ failurePercent: Math.round(transportConfig.breakerFailureRatio * 100),
1245
+ openMs: transportConfig.breakerOpenMs,
1246
+ jitterMs: transportConfig.breakerJitterMs,
1247
+ halfOpenSuccesses: transportConfig.breakerHalfOpenSuccesses,
1248
+ maxEntries: transportConfig.breakerMaxEntries,
1249
+ enabled: transportConfig.breakerEnabled,
1250
+ }),
1251
+ });
1252
+ }
1253
+ export function getTransportSnapshotForTesting() {
1254
+ return getTransportCoordinator().snapshot();
1255
+ }
971
1256
  export function resetPoolForTesting() {
972
1257
  for (const conn of [...connectionPool]) {
973
1258
  try {
@@ -980,15 +1265,13 @@ export function resetPoolForTesting() {
980
1265
  catch { }
981
1266
  remove(conn);
982
1267
  }
983
- for (const queue of acquisitionQueues.values()) {
984
- for (const entry of queue) {
985
- entry.signal?.removeEventListener("abort", entry.onAbort);
986
- entry.reject(new Error("Pool reset"));
987
- }
988
- }
989
- acquisitionQueues.clear();
990
1268
  turnStateByContext.clear();
991
1269
  lastResponseIDByContext.clear();
1270
+ scopeCooldowns.clear();
1271
+ authEndpoints.clear();
1272
+ nextStreamID = 1;
1273
+ resetTransportCoordinatorForTesting();
1274
+ syncCoordinatorFromTransportConfig();
992
1275
  }
993
1276
  export function setWebSocketConstructorForTesting(ctor) {
994
1277
  WebSocketImpl = ctor;
@@ -996,4 +1279,6 @@ export function setWebSocketConstructorForTesting(ctor) {
996
1279
  export function resetWebSocketConstructorForTesting() {
997
1280
  WebSocketImpl = loadDefaultWebSocketConstructor();
998
1281
  }
1282
+ // Initialize coordinator against boot-latched config.
1283
+ syncCoordinatorFromTransportConfig();
999
1284
  //# sourceMappingURL=pool.js.map