openai-ws-opencode 0.1.28 → 0.1.30

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.
@@ -9,10 +9,14 @@ let WebSocketImpl = loadDefaultWebSocketConstructor();
9
9
  export const connectionPool = [];
10
10
  const turnStateByContext = new Map();
11
11
  const lastResponseIDByContext = new Map();
12
+ const contextEpochByKey = new Map();
13
+ const contextOwnerByKey = new Map();
12
14
  const scopeCooldowns = new Map();
13
15
  const authEndpoints = new Map();
16
+ const OAUTH_MISSING_ACCOUNT_SEED = "codex-oauth-missing-account";
14
17
  let nextConnectionID = 1;
15
18
  let nextStreamID = 1;
19
+ let contextEpochClock = 1;
16
20
  export const readyState = {
17
21
  CONNECTING: 0,
18
22
  OPEN: 1,
@@ -39,14 +43,25 @@ function authScopeHash(headers) {
39
43
  .join("\n");
40
44
  return crypto.createHash("sha256").update(auth).digest("hex");
41
45
  }
42
- function accountFingerprint(headers) {
46
+ function accountFingerprint(headers, wsUrl) {
43
47
  const account = headers["ChatGPT-Account-ID"] ?? headers["ChatGPT-Account-Id"] ?? headers["chatgpt-account-id"] ?? "";
44
48
  if (account) {
45
49
  return crypto.createHash("sha256").update(`acct:${account}`).digest("hex").slice(0, 32);
46
50
  }
51
+ // Codex OAuth without account id: stable shared sentinel so bearer rotation cannot bypass account caps.
52
+ if (providerForUrl(wsUrl) === "codex") {
53
+ return crypto.createHash("sha256").update(`acct:${OAUTH_MISSING_ACCOUNT_SEED}`).digest("hex").slice(0, 32);
54
+ }
55
+ // Public API keys: fingerprint by auth material.
47
56
  const auth = headers.Authorization ?? headers.authorization ?? "";
48
57
  return crypto.createHash("sha256").update(`auth:${auth}`).digest("hex").slice(0, 32);
49
58
  }
59
+ function authGenerationKey(endpoint, accountFp) {
60
+ return `${endpoint}::${accountFp}`;
61
+ }
62
+ function hashIdentity(kind, value) {
63
+ return crypto.createHash("sha256").update(`${kind}:${value}`).digest("hex").slice(0, 32);
64
+ }
50
65
  function breakerDomainKey(provider, origin, accountFp) {
51
66
  return crypto.createHash("sha256").update(`${provider}\n${origin}\n${accountFp}`).digest("hex");
52
67
  }
@@ -60,7 +75,92 @@ function scopeKey(wsUrl, headers) {
60
75
  return `${wsUrl}::${authScopeHash(headers)}`;
61
76
  }
62
77
  function contextScopeKey(wsUrl, headers, context) {
63
- return `${scopeKey(wsUrl, headers)}::session:${context.sessionID ?? ""}::agent:${context.agent ?? ""}`;
78
+ const sessionHash = context.sessionID ? hashIdentity("session", context.sessionID) : "";
79
+ const agentHash = context.agent ? hashIdentity("agent", context.agent) : "";
80
+ return `${scopeKey(wsUrl, headers)}::ctx:${sessionHash}::agt:${agentHash}`;
81
+ }
82
+ function contextKeyMatchesSession(contextKey, sessionID) {
83
+ const sessionHash = hashIdentity("session", sessionID);
84
+ return contextKey.includes(`::ctx:${sessionHash}::`);
85
+ }
86
+ function contextEntryCount() {
87
+ const keys = new Set();
88
+ for (const key of turnStateByContext.keys())
89
+ keys.add(key);
90
+ for (const key of lastResponseIDByContext.keys())
91
+ keys.add(key);
92
+ for (const key of contextEpochByKey.keys())
93
+ keys.add(key);
94
+ for (const key of contextOwnerByKey.keys())
95
+ keys.add(key);
96
+ return keys.size;
97
+ }
98
+ function scrubIdleConnectionContext(contextKey) {
99
+ for (const conn of connectionPool) {
100
+ if (conn.contextKey !== contextKey || conn.busy)
101
+ continue;
102
+ conn.lastResponseID = undefined;
103
+ conn.turnState = undefined;
104
+ }
105
+ }
106
+ function boundContextMaps() {
107
+ const max = transportConfig.maxAccountingEntries;
108
+ while (contextEntryCount() > max) {
109
+ const oldest = contextEpochByKey.keys().next().value ??
110
+ turnStateByContext.keys().next().value ??
111
+ lastResponseIDByContext.keys().next().value ??
112
+ contextOwnerByKey.keys().next().value;
113
+ if (oldest === undefined)
114
+ break;
115
+ turnStateByContext.delete(oldest);
116
+ lastResponseIDByContext.delete(oldest);
117
+ contextEpochByKey.delete(oldest);
118
+ contextOwnerByKey.delete(oldest);
119
+ // Evicted map keys must not be reintroduced from idle local connection state.
120
+ scrubIdleConnectionContext(oldest);
121
+ }
122
+ }
123
+ function currentContextEpoch(contextKey) {
124
+ return contextEpochByKey.get(contextKey) ?? 0;
125
+ }
126
+ function bumpContextEpoch(contextKey) {
127
+ const next = Math.max(contextEpochClock, currentContextEpoch(contextKey) + 1);
128
+ contextEpochClock = next + 1;
129
+ contextEpochByKey.delete(contextKey);
130
+ contextEpochByKey.set(contextKey, next);
131
+ boundContextMaps();
132
+ return next;
133
+ }
134
+ function setContextMapValue(map, contextKey, value) {
135
+ map.delete(contextKey);
136
+ map.set(contextKey, value);
137
+ if (!contextEpochByKey.has(contextKey)) {
138
+ contextEpochByKey.set(contextKey, currentContextEpoch(contextKey));
139
+ }
140
+ else {
141
+ const epoch = contextEpochByKey.get(contextKey);
142
+ contextEpochByKey.delete(contextKey);
143
+ contextEpochByKey.set(contextKey, epoch);
144
+ }
145
+ boundContextMaps();
146
+ }
147
+ function clearContextAccounting(contextKey, options = {}) {
148
+ turnStateByContext.delete(contextKey);
149
+ lastResponseIDByContext.delete(contextKey);
150
+ contextOwnerByKey.delete(contextKey);
151
+ if (options.bumpEpoch !== false)
152
+ bumpContextEpoch(contextKey);
153
+ else
154
+ boundContextMaps();
155
+ }
156
+ function canPublishContextState(conn) {
157
+ if (conn.staleAuth || !isCurrentAuth(conn))
158
+ return false;
159
+ return conn.contextEpoch === currentContextEpoch(conn.contextKey);
160
+ }
161
+ function admitContextTurn(conn) {
162
+ conn.contextEpoch = currentContextEpoch(conn.contextKey);
163
+ setContextMapValue(contextOwnerByKey, conn.contextKey, conn.id);
64
164
  }
65
165
  function headersForConnection(wsUrl, baseHeaders, context, key) {
66
166
  const headers = { ...baseHeaders };
@@ -143,11 +243,12 @@ function boundAuthEndpoints() {
143
243
  authEndpoints.delete(oldest);
144
244
  }
145
245
  }
146
- function bindAuthGeneration(endpoint, hash) {
147
- let state = authEndpoints.get(endpoint);
246
+ function bindAuthGeneration(endpoint, accountFp, hash) {
247
+ const key = authGenerationKey(endpoint, accountFp);
248
+ let state = authEndpoints.get(key);
148
249
  if (!state) {
149
250
  state = { currentGeneration: 1, currentHash: hash, bindings: new Map([[hash, 1]]), order: [hash] };
150
- authEndpoints.set(endpoint, state);
251
+ authEndpoints.set(key, state);
151
252
  boundAuthEndpoints();
152
253
  return 1;
153
254
  }
@@ -171,13 +272,17 @@ function bindAuthGeneration(endpoint, hash) {
171
272
  }
172
273
  state.bindings.delete(oldest);
173
274
  }
275
+ // Refresh map insertion order for bounded eviction fairness.
276
+ authEndpoints.delete(key);
277
+ authEndpoints.set(key, state);
278
+ boundAuthEndpoints();
174
279
  return state.currentGeneration;
175
280
  }
176
- function currentAuthGeneration(endpoint) {
177
- return authEndpoints.get(endpoint)?.currentGeneration ?? 0;
281
+ function currentAuthGeneration(endpoint, accountFp) {
282
+ return authEndpoints.get(authGenerationKey(endpoint, accountFp))?.currentGeneration ?? 0;
178
283
  }
179
284
  function isCurrentAuth(conn) {
180
- return conn.authGeneration === currentAuthGeneration(conn.endpointKey);
285
+ return conn.authGeneration === currentAuthGeneration(conn.endpointKey, conn.accountFp);
181
286
  }
182
287
  function finishConnectPermit(conn, result) {
183
288
  if (!conn.connectPermit || conn.connectFinished)
@@ -554,8 +659,10 @@ function clearLastResponseID(conn) {
554
659
  lastResponseIDByContext.delete(conn.contextKey);
555
660
  }
556
661
  function persistLastResponseID(conn, responseID) {
662
+ if (!canPublishContextState(conn))
663
+ return;
557
664
  conn.lastResponseID = responseID;
558
- lastResponseIDByContext.set(conn.contextKey, responseID);
665
+ setContextMapValue(lastResponseIDByContext, conn.contextKey, responseID);
559
666
  }
560
667
  function frameResponseID(frame) {
561
668
  let responseID;
@@ -805,6 +912,7 @@ function connect(conn) {
805
912
  return;
806
913
  conn.lastCloseCode = 1006;
807
914
  conn.lastCloseReason = "connection timed out";
915
+ conn.sawConnectTimeout = true;
808
916
  finishConnectPermit(conn, { success: false, breakerEligible: true, timeout: true });
809
917
  handleSocketLoss(conn);
810
918
  }, transportConfig.connectTimeoutMs);
@@ -822,14 +930,32 @@ function connect(conn) {
822
930
  if (generation !== conn.generation)
823
931
  return;
824
932
  const turnState = headerValue(response, X_CODEX_TURN_STATE_HEADER);
825
- if (turnState) {
933
+ if (turnState && canPublishContextState(conn)) {
826
934
  conn.turnState = turnState;
827
- turnStateByContext.set(conn.contextKey, turnState);
935
+ setContextMapValue(turnStateByContext, conn.contextKey, turnState);
828
936
  }
829
937
  conn.serverReasoningIncluded = headerValue(response, X_REASONING_INCLUDED_HEADER) !== undefined;
830
938
  conn.modelsEtag = headerValue(response, X_MODELS_ETAG_HEADER);
831
939
  conn.serverModel = headerValue(response, OPENAI_MODEL_HEADER);
832
940
  };
941
+ const rejectInboundOverflow = (message, probeBytes) => {
942
+ const streamKey = conn.pending?.streamKey ?? `overflow:${conn.id}`;
943
+ // Non-mutating reject path: oversized probe increments queueOverflows and retains zero bytes.
944
+ getTransportCoordinator().adjustQueue({
945
+ provider: conn.provider,
946
+ streamKey,
947
+ bytes: probeBytes,
948
+ messages: 1,
949
+ });
950
+ fail(conn, transportError({
951
+ code: "queue_overflow",
952
+ phase: "active",
953
+ message,
954
+ fallbackEligible: false,
955
+ breakerEligible: false,
956
+ safeToReplay: false,
957
+ }), true);
958
+ };
833
959
  const handleMessage = (data, isBinary) => {
834
960
  if (generation !== conn.generation)
835
961
  return;
@@ -838,7 +964,23 @@ function connect(conn) {
838
964
  fail(conn, new Error("unexpected binary websocket event"), true);
839
965
  return;
840
966
  }
967
+ const decoded = decodeFrameData(data);
968
+ const maxBytes = transportConfig.queueMaxBytesPerStream;
969
+ const incomingBytes = Buffer.byteLength(decoded, "utf8");
970
+ if (incomingBytes > maxBytes) {
971
+ rejectInboundOverflow("OpenAI WebSocket inbound frame queue overflow", incomingBytes);
972
+ return;
973
+ }
974
+ const carryBytes = Buffer.byteLength(conn.frameCarryover, "utf8");
975
+ if (carryBytes + incomingBytes > maxBytes) {
976
+ rejectInboundOverflow("OpenAI WebSocket inbound carryover queue overflow", carryBytes + incomingBytes);
977
+ return;
978
+ }
841
979
  const parsed = parseFrames(data, conn.frameCarryover);
980
+ if (Buffer.byteLength(parsed.carryover, "utf8") > maxBytes) {
981
+ rejectInboundOverflow("OpenAI WebSocket inbound carryover queue overflow", Buffer.byteLength(parsed.carryover, "utf8"));
982
+ return;
983
+ }
842
984
  conn.frameCarryover = parsed.carryover;
843
985
  for (const frame of parsed.frames)
844
986
  enqueueSSE(conn, frame);
@@ -847,6 +989,7 @@ function connect(conn) {
847
989
  if (generation !== conn.generation)
848
990
  return;
849
991
  conn.lastErrorMessage = messageFromError(error) ?? "unknown websocket error";
992
+ conn.sawConnectNonTimeout = true;
850
993
  finishConnectPermit(conn, { success: false, breakerEligible: true });
851
994
  handleSocketLoss(conn);
852
995
  };
@@ -860,6 +1003,8 @@ function connect(conn) {
860
1003
  else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined) {
861
1004
  conn.lastCloseReason = "socket closed without close code";
862
1005
  }
1006
+ if (conn.lastCloseReason !== "connection timed out")
1007
+ conn.sawConnectNonTimeout = true;
863
1008
  finishConnectPermit(conn, { success: false, breakerEligible: true });
864
1009
  handleSocketLoss(conn);
865
1010
  };
@@ -871,6 +1016,7 @@ function connect(conn) {
871
1016
  conn.lastCloseCode = status;
872
1017
  conn.retryAfterMs = parseRetryAfterMs(response);
873
1018
  conn.lastCloseReason = `websocket handshake failed${status !== undefined ? ` with status ${status}` : ""}`;
1019
+ conn.sawConnectNonTimeout = true;
874
1020
  finishConnectPermit(conn, { success: false, breakerEligible: true });
875
1021
  handleSocketLoss(conn);
876
1022
  };
@@ -879,7 +1025,7 @@ function connect(conn) {
879
1025
  try {
880
1026
  ws = new WebSocketImpl(conn.wsUrl, {
881
1027
  headers: conn.headers,
882
- perMessageDeflate: true,
1028
+ perMessageDeflate: false,
883
1029
  finishRequest(request) {
884
1030
  if (attachUpgrade)
885
1031
  request.on?.("upgrade", handleUpgrade);
@@ -889,6 +1035,7 @@ function connect(conn) {
889
1035
  }
890
1036
  catch (error) {
891
1037
  conn.lastErrorMessage = messageFromError(error) ?? "websocket constructor failed";
1038
+ conn.sawConnectNonTimeout = true;
892
1039
  finishConnectPermit(conn, { success: false, breakerEligible: true });
893
1040
  queueMicrotask(() => handleSocketLoss(conn));
894
1041
  return;
@@ -911,6 +1058,10 @@ function connect(conn) {
911
1058
  off(ws, "close", handleClose);
912
1059
  if (attachUpgrade)
913
1060
  off(ws, "unexpected-response", handleUnexpectedResponse);
1061
+ // Keep one inert terminal listener on the detached socket so a late EventEmitter
1062
+ // 'error' after terminate/close (common while CONNECTING) cannot throw unhandled.
1063
+ // Retry disposal calls detach() directly before closing the prior socket.
1064
+ on(ws, "error", () => { });
914
1065
  };
915
1066
  if (ws.readyState === readyState.OPEN)
916
1067
  queueMicrotask(handleOpen);
@@ -964,12 +1115,24 @@ function handleSocketLoss(conn) {
964
1115
  if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted) {
965
1116
  return;
966
1117
  }
1118
+ if (conn.staleAuth || !isCurrentAuth(conn)) {
1119
+ getTransportCoordinator().noteAuthStale();
1120
+ fail(conn, transportError({
1121
+ code: "stale_auth",
1122
+ phase: "pre_send",
1123
+ message: "OpenAI WebSocket auth generation is stale; refusing reconnect",
1124
+ fallbackEligible: true,
1125
+ safeToReplay: true,
1126
+ }), true);
1127
+ return;
1128
+ }
967
1129
  connect(conn);
968
1130
  }, retryDelay(conn.reconnectAttempts, retryAfterMs)));
969
1131
  return;
970
1132
  }
1133
+ const allTimeouts = conn.sawConnectTimeout && !conn.sawConnectNonTimeout;
971
1134
  fail(conn, transportError({
972
- code: "connect_failure",
1135
+ code: allTimeouts ? "handshake_timeout" : "connect_failure",
973
1136
  phase: "handshake",
974
1137
  message: formatFailureMessage(conn, "retry limit reached before response.create was sent"),
975
1138
  fallbackEligible: true,
@@ -987,7 +1150,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
987
1150
  const endpoint = endpointKey(wsUrl);
988
1151
  const provider = providerForUrl(wsUrl);
989
1152
  const origin = canonicalWebSocketOrigin(wsUrl);
990
- const accountFp = accountFingerprint(headers);
1153
+ const accountFp = accountFingerprint(headers, wsUrl);
991
1154
  const now = Date.now();
992
1155
  const conn = {
993
1156
  id: `ws-${nextConnectionID++}`,
@@ -1001,7 +1164,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
1001
1164
  accountFp,
1002
1165
  provider,
1003
1166
  origin,
1004
- authGeneration: bindAuthGeneration(endpoint, hash),
1167
+ authGeneration: bindAuthGeneration(endpoint, accountFp, hash),
1005
1168
  busy: false,
1006
1169
  warm,
1007
1170
  staleAuth: false,
@@ -1025,6 +1188,9 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
1025
1188
  turnCount: 0,
1026
1189
  intent: warm ? "prewarm" : "demand",
1027
1190
  breakerDomain: breakerDomainKey(provider, origin, accountFp),
1191
+ contextEpoch: currentContextEpoch(key),
1192
+ sawConnectTimeout: false,
1193
+ sawConnectNonTimeout: false,
1028
1194
  };
1029
1195
  connectionPool.push(conn);
1030
1196
  getTransportCoordinator().adjustSessions(1);
@@ -1055,20 +1221,25 @@ function activeBusy(key) {
1055
1221
  }
1056
1222
  export function invalidateStaleAuthConnections(wsUrl, headers) {
1057
1223
  const endpoint = endpointKey(wsUrl);
1224
+ const accountFp = accountFingerprint(headers, wsUrl);
1058
1225
  const hash = authScopeHash(headers);
1059
- const state = authEndpoints.get(endpoint);
1226
+ const authKey = authGenerationKey(endpoint, accountFp);
1227
+ const state = authEndpoints.get(authKey);
1060
1228
  // ABA: presenting an old remembered binding must not become current or stale newer sockets.
1061
1229
  if (state && hash !== state.currentHash && state.bindings.has(hash))
1062
1230
  return;
1063
- const generation = bindAuthGeneration(endpoint, hash);
1064
- const keepHash = authEndpoints.get(endpoint)?.currentHash ?? hash;
1065
- const keepGen = authEndpoints.get(endpoint)?.currentGeneration ?? generation;
1231
+ const generation = bindAuthGeneration(endpoint, accountFp, hash);
1232
+ const keepHash = authEndpoints.get(authKey)?.currentHash ?? hash;
1233
+ const keepGen = authEndpoints.get(authKey)?.currentGeneration ?? generation;
1066
1234
  for (const conn of [...connectionPool]) {
1067
1235
  if (conn.wsUrl !== wsUrl)
1068
1236
  continue;
1237
+ if (conn.accountFp !== accountFp)
1238
+ continue;
1069
1239
  if (conn.scopeHash === keepHash && conn.authGeneration === keepGen)
1070
1240
  continue;
1071
1241
  conn.staleAuth = true;
1242
+ clearContextAccounting(conn.contextKey);
1072
1243
  if (conn.busy)
1073
1244
  continue;
1074
1245
  remove(conn);
@@ -1124,8 +1295,9 @@ function reserveConnection(wsUrl, headers, context) {
1124
1295
  }
1125
1296
  const endpoint = endpointKey(wsUrl);
1126
1297
  const hash = authScopeHash(headers);
1127
- const generation = bindAuthGeneration(endpoint, hash);
1128
- if (generation !== currentAuthGeneration(endpoint)) {
1298
+ const accountFp = accountFingerprint(headers, wsUrl);
1299
+ const generation = bindAuthGeneration(endpoint, accountFp, hash);
1300
+ if (generation !== currentAuthGeneration(endpoint, accountFp)) {
1129
1301
  coordinator.noteAuthStale();
1130
1302
  admitMiss("stale_auth", "OpenAI WebSocket auth generation is stale");
1131
1303
  }
@@ -1137,10 +1309,13 @@ function reserveConnection(wsUrl, headers, context) {
1137
1309
  reusable.warm = false;
1138
1310
  reusable.intent = "demand";
1139
1311
  reusable.turnCount += 1;
1312
+ // Always take the bounded map as source of truth, including absences after clear/eviction.
1313
+ reusable.lastResponseID = lastResponseIDByContext.get(key);
1314
+ reusable.turnState = turnStateByContext.get(key);
1315
+ admitContextTurn(reusable);
1140
1316
  coordinator.noteLeaseAdmission();
1141
1317
  return reusable;
1142
1318
  }
1143
- const accountFp = accountFingerprint(headers);
1144
1319
  const provider = providerForUrl(wsUrl);
1145
1320
  const origin = canonicalWebSocketOrigin(wsUrl);
1146
1321
  const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "demand" });
@@ -1161,17 +1336,18 @@ function reserveConnection(wsUrl, headers, context) {
1161
1336
  const conn = create(wsUrl, headers, context, false, started.permit);
1162
1337
  conn.busy = true;
1163
1338
  conn.turnCount += 1;
1339
+ admitContextTurn(conn);
1164
1340
  coordinator.noteLeaseAdmission();
1165
1341
  return conn;
1166
1342
  }
1167
1343
  export function acquireConnection(wsUrl, headers, context, _signal) {
1168
1344
  return reserveConnection(wsUrl, headers, context);
1169
1345
  }
1170
- export function ensureWarmConnection(wsUrl, headers) {
1346
+ export function ensureWarmConnection(wsUrl, headers, context = {}) {
1171
1347
  const coordinator = getTransportCoordinator();
1172
1348
  coordinator.notePrewarmAttempt();
1173
1349
  invalidateStaleAuthConnections(wsUrl, headers);
1174
- const key = contextScopeKey(wsUrl, headers, {});
1350
+ const key = contextScopeKey(wsUrl, headers, context);
1175
1351
  const now = Date.now();
1176
1352
  cleanupExpiredIdle(now);
1177
1353
  const existing = connectionPool.find((conn) => conn.scopeKey === key &&
@@ -1185,7 +1361,7 @@ export function ensureWarmConnection(wsUrl, headers) {
1185
1361
  existing.idleTimer = null;
1186
1362
  return existing;
1187
1363
  }
1188
- const accountFp = accountFingerprint(headers);
1364
+ const accountFp = accountFingerprint(headers, wsUrl);
1189
1365
  const provider = providerForUrl(wsUrl);
1190
1366
  const origin = canonicalWebSocketOrigin(wsUrl);
1191
1367
  const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "prewarm" });
@@ -1205,7 +1381,66 @@ export function ensureWarmConnection(wsUrl, headers) {
1205
1381
  coordinator.notePrewarmDeferred();
1206
1382
  return undefined;
1207
1383
  }
1208
- return create(wsUrl, headers, {}, true, started.permit);
1384
+ return create(wsUrl, headers, context, true, started.permit);
1385
+ }
1386
+ export function clearTransportContextState(wsUrl, headers, context) {
1387
+ const key = contextScopeKey(wsUrl, headers, context);
1388
+ clearContextAccounting(key);
1389
+ for (const conn of connectionPool) {
1390
+ if (conn.contextKey !== key)
1391
+ continue;
1392
+ conn.lastResponseID = undefined;
1393
+ conn.turnState = undefined;
1394
+ }
1395
+ }
1396
+ export function clearSessionTransportState(sessionID) {
1397
+ const keys = new Set();
1398
+ for (const key of turnStateByContext.keys()) {
1399
+ if (contextKeyMatchesSession(key, sessionID))
1400
+ keys.add(key);
1401
+ }
1402
+ for (const key of lastResponseIDByContext.keys()) {
1403
+ if (contextKeyMatchesSession(key, sessionID))
1404
+ keys.add(key);
1405
+ }
1406
+ for (const key of contextEpochByKey.keys()) {
1407
+ if (contextKeyMatchesSession(key, sessionID))
1408
+ keys.add(key);
1409
+ }
1410
+ for (const key of contextOwnerByKey.keys()) {
1411
+ if (contextKeyMatchesSession(key, sessionID))
1412
+ keys.add(key);
1413
+ }
1414
+ for (const key of scopeCooldowns.keys()) {
1415
+ if (contextKeyMatchesSession(key, sessionID))
1416
+ scopeCooldowns.delete(key);
1417
+ }
1418
+ for (const key of keys)
1419
+ clearContextAccounting(key);
1420
+ for (const conn of connectionPool) {
1421
+ if (!contextKeyMatchesSession(conn.contextKey, sessionID))
1422
+ continue;
1423
+ conn.lastResponseID = undefined;
1424
+ conn.turnState = undefined;
1425
+ }
1426
+ }
1427
+ export function clearAllTransportState() {
1428
+ const keys = new Set([
1429
+ ...turnStateByContext.keys(),
1430
+ ...lastResponseIDByContext.keys(),
1431
+ ...contextEpochByKey.keys(),
1432
+ ...contextOwnerByKey.keys(),
1433
+ ]);
1434
+ for (const key of keys)
1435
+ clearContextAccounting(key);
1436
+ turnStateByContext.clear();
1437
+ lastResponseIDByContext.clear();
1438
+ contextOwnerByKey.clear();
1439
+ // Keep bumped epochs so in-flight completions cannot republish under a recycled 0 epoch.
1440
+ for (const conn of connectionPool) {
1441
+ conn.lastResponseID = undefined;
1442
+ conn.turnState = undefined;
1443
+ }
1209
1444
  }
1210
1445
  export function closeConnections(predicate = () => true, message = "Session disposed") {
1211
1446
  for (const conn of [...connectionPool]) {
@@ -1251,7 +1486,10 @@ export function syncCoordinatorFromTransportConfig() {
1251
1486
  });
1252
1487
  }
1253
1488
  export function getTransportSnapshotForTesting() {
1254
- return getTransportCoordinator().snapshot();
1489
+ return {
1490
+ ...getTransportCoordinator().snapshot(),
1491
+ contextEntries: contextEntryCount(),
1492
+ };
1255
1493
  }
1256
1494
  export function resetPoolForTesting() {
1257
1495
  for (const conn of [...connectionPool]) {
@@ -1267,8 +1505,11 @@ export function resetPoolForTesting() {
1267
1505
  }
1268
1506
  turnStateByContext.clear();
1269
1507
  lastResponseIDByContext.clear();
1508
+ contextEpochByKey.clear();
1509
+ contextOwnerByKey.clear();
1270
1510
  scopeCooldowns.clear();
1271
1511
  authEndpoints.clear();
1512
+ contextEpochClock = 1;
1272
1513
  nextStreamID = 1;
1273
1514
  resetTransportCoordinatorForTesting();
1274
1515
  syncCoordinatorFromTransportConfig();