@voxevolv/client 0.3.11 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -49,8 +49,10 @@ import {
49
49
  decodeContextUpdatedPayload,
50
50
  decodeRealtimeEvent
51
51
  } from "./generated/protocol-codecs.js";
52
- var SDK_VERSION = "0.3.11";
52
+ var SDK_VERSION = "0.3.14";
53
53
  var CLIENT_HELLO_TIMEOUT_MS = 3e4;
54
+ var RECOVERY_BUFFER_STALL_MS = 5e3;
55
+ var RECOVERY_BUFFER_POLL_MS = 500;
54
56
  var DIAGNOSTIC_REPORT_TIMEOUT_MS = 5e3;
55
57
  var DEFAULT_STARTUP_ATTEMPTS = [
56
58
  { delayBeforeMs: 0, readyTimeoutMs: 15e3 },
@@ -58,6 +60,12 @@ var DEFAULT_STARTUP_ATTEMPTS = [
58
60
  { delayBeforeMs: 3e3, readyTimeoutMs: 5e4 }
59
61
  ];
60
62
  var DEFAULT_STARTUP_TIMEOUT_MS = 9e4;
63
+ var RecoveryDataChannelStalledError = class extends Error {
64
+ constructor() {
65
+ super("Recovery data channel stopped draining before the agent handshake completed");
66
+ this.name = "RecoveryDataChannelStalledError";
67
+ }
68
+ };
61
69
  var RealtimeSessionHttpError = class extends Error {
62
70
  status;
63
71
  code;
@@ -136,6 +144,9 @@ var RealtimeSession = class _RealtimeSession {
136
144
  endNotificationDelivered = false;
137
145
  disconnectMode = null;
138
146
  activeRecovery = null;
147
+ recoveryStartedAt = null;
148
+ livekitResumeController = null;
149
+ terminalReplayController = null;
139
150
  autoRejoinPromise = null;
140
151
  autoRejoinController = null;
141
152
  manualRejoinController = null;
@@ -156,6 +167,7 @@ var RealtimeSession = class _RealtimeSession {
156
167
  lifecycleCredentials;
157
168
  diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
158
169
  activeContextTransfers = 0;
170
+ contextTransferControllers = /* @__PURE__ */ new Set();
159
171
  activeContextBytes = 0;
160
172
  diagnosticLoopTimer;
161
173
  diagnosticLoopExpectedAt = 0;
@@ -614,7 +626,11 @@ var RealtimeSession = class _RealtimeSession {
614
626
  this.apiBase = apiBase;
615
627
  this.agentReady = false;
616
628
  try {
617
- await this.connectRoom(signal, recovering);
629
+ await this.measureRecoveryStage(
630
+ "room_connect",
631
+ () => this.connectRoom(signal, recovering),
632
+ recovering
633
+ );
618
634
  throwIfAborted(signal);
619
635
  if (audioTrack) {
620
636
  await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
@@ -626,10 +642,20 @@ var RealtimeSession = class _RealtimeSession {
626
642
  }
627
643
  throwIfAborted(signal);
628
644
  this.setState(recovering ? "resuming" : "connected_not_ready");
629
- await raceWithAbort(this.sendHello(), signal);
645
+ await this.measureRecoveryStage("hello", () => recovering ? this.sendRecoveryHello(signal) : raceWithAbort(this.sendHello(), signal), recovering);
630
646
  const readyDeadline = Date.now() + readyTimeoutMs;
631
- await this.waitForAgentReady(readyTimeoutMs, signal);
632
- if (waitForRemoteAudio) await this.waitForRemoteAudioTrack(readyDeadline, signal);
647
+ await this.measureRecoveryStage(
648
+ "agent_ready",
649
+ () => this.waitForAgentReady(readyTimeoutMs, signal),
650
+ recovering
651
+ );
652
+ if (waitForRemoteAudio) {
653
+ await this.measureRecoveryStage(
654
+ "remote_audio",
655
+ () => this.waitForRemoteAudioTrack(readyDeadline, signal),
656
+ recovering
657
+ );
658
+ }
633
659
  throwIfAborted(signal);
634
660
  if (this.sessionEnded || generation !== this.contextGeneration) {
635
661
  throw new Error("Connection restoration interrupted");
@@ -731,11 +757,11 @@ var RealtimeSession = class _RealtimeSession {
731
757
  this.contextGeneration += 1;
732
758
  this.replacementContext.pause();
733
759
  this.rejectPendingCommands(new Error("Restoring the session"));
734
- let response = await this.fetchRoomToken(apiBase, signal);
760
+ let response = await this.fetchRoomToken(apiBase, signal, recovering);
735
761
  if (response.status === 401 && this.refreshCredentialsCallback) {
736
762
  await this.refreshCredentials(signal);
737
763
  throwIfAborted(signal);
738
- response = await this.fetchRoomToken(apiBase, signal);
764
+ response = await this.fetchRoomToken(apiBase, signal, recovering);
739
765
  }
740
766
  if (this.sessionEnded) {
741
767
  if (this.terminalSessionError) throw this.terminalSessionError;
@@ -751,7 +777,11 @@ var RealtimeSession = class _RealtimeSession {
751
777
  if (this.room.state !== LiveKitConnectionState.Disconnected) {
752
778
  this.disconnectMode = "rejoin";
753
779
  try {
754
- await raceWithAbort(this.room.disconnect(), signal);
780
+ await this.measureRecoveryStage(
781
+ "room_disconnect",
782
+ () => raceWithAbort(this.room.disconnect(), signal),
783
+ recovering
784
+ );
755
785
  } finally {
756
786
  this.disconnectMode = null;
757
787
  }
@@ -800,6 +830,8 @@ var RealtimeSession = class _RealtimeSession {
800
830
  }
801
831
  async sendContextUpdate(update, timeoutMs) {
802
832
  const generation = this.contextGeneration;
833
+ const controller = new AbortController();
834
+ this.contextTransferControllers.add(controller);
803
835
  const payloadBytes = contextUpdateBytes(update);
804
836
  const transferStartedAt = Date.now();
805
837
  let transferTimedOut = false;
@@ -811,7 +843,8 @@ var RealtimeSession = class _RealtimeSession {
811
843
  });
812
844
  this.activeContextTransfers += 1;
813
845
  this.activeContextBytes += payloadBytes;
814
- const uploading = ("image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update)).finally(() => {
846
+ const uploading = ("image" in update ? this.uploadImageContext(update, controller.signal) : this.uploadTextContext(update, controller.signal)).finally(() => {
847
+ this.contextTransferControllers.delete(controller);
815
848
  this.activeContextTransfers -= 1;
816
849
  this.activeContextBytes -= payloadBytes;
817
850
  });
@@ -835,6 +868,7 @@ var RealtimeSession = class _RealtimeSession {
835
868
  timeout_ms: timeoutMs
836
869
  });
837
870
  this.flushDiagnostics("context_transfer_timeout");
871
+ controller.abort(error);
838
872
  reject(error);
839
873
  }, timeoutMs);
840
874
  });
@@ -891,7 +925,7 @@ var RealtimeSession = class _RealtimeSession {
891
925
  void this.replacementContext.resume(restore).catch(() => {
892
926
  });
893
927
  }
894
- async uploadTextContext(update) {
928
+ async uploadTextContext(update, signal) {
895
929
  const data = new TextEncoder().encode(update.text);
896
930
  if (data.byteLength === 0) throw new TypeError("Context text cannot be empty");
897
931
  if (data.byteLength > 262144) {
@@ -906,17 +940,20 @@ var RealtimeSession = class _RealtimeSession {
906
940
  if (data.byteLength <= 32768) return { payload: { ...base, text: update.text } };
907
941
  const sha256 = await sha256Hex(data);
908
942
  const streamId = contextStreamId();
909
- const send = async () => {
910
- const writer = await this.room.localParticipant.streamText({
943
+ const send = async (signal2) => {
944
+ signal2.throwIfAborted();
945
+ const options = {
911
946
  streamId,
912
947
  topic: "voxevolv.context.text",
913
948
  totalSize: data.byteLength,
914
- attributes: { sha256 }
915
- });
949
+ attributes: { sha256 },
950
+ signal: signal2
951
+ };
952
+ const writer = await this.room.localParticipant.streamText(options);
916
953
  await writer.write(update.text);
917
954
  await writer.close();
918
955
  };
919
- await send();
956
+ await send(signal);
920
957
  return {
921
958
  payload: {
922
959
  ...base,
@@ -925,7 +962,7 @@ var RealtimeSession = class _RealtimeSession {
925
962
  resend: send
926
963
  };
927
964
  }
928
- async uploadImageContext(update) {
965
+ async uploadImageContext(update, signal) {
929
966
  const allowed = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
930
967
  if (!allowed.has(update.image.type)) {
931
968
  throw new TypeError("Image context must be JPEG, PNG, or WebP");
@@ -936,19 +973,22 @@ var RealtimeSession = class _RealtimeSession {
936
973
  }
937
974
  const sha256 = await sha256Hex(data);
938
975
  const streamId = contextStreamId();
939
- const send = async () => {
940
- const writer = await this.room.localParticipant.streamBytes({
976
+ const send = async (signal2) => {
977
+ signal2.throwIfAborted();
978
+ const options = {
941
979
  streamId,
942
980
  topic: "voxevolv.context.image",
943
981
  name: "context-image",
944
982
  mimeType: update.image.type,
945
983
  totalSize: data.byteLength,
946
- attributes: { sha256 }
947
- });
984
+ attributes: { sha256 },
985
+ signal: signal2
986
+ };
987
+ const writer = await this.room.localParticipant.streamBytes(options);
948
988
  await writer.write(data);
949
989
  await writer.close();
950
990
  };
951
- await send();
991
+ await send(signal);
952
992
  return {
953
993
  payload: {
954
994
  key: update.key,
@@ -1051,16 +1091,26 @@ var RealtimeSession = class _RealtimeSession {
1051
1091
  bindRoomEvents() {
1052
1092
  this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
1053
1093
  if (participant.isAgent && !this.disconnectMode) {
1054
- this.agentDisconnected = true;
1055
- if (this.terminalSessionError) return;
1056
- if (this.sessionEnded) {
1057
- this.finishPendingEnd();
1058
- return;
1059
- }
1060
- this.startAutoRejoin(
1061
- new Error("Agent disconnected from the room"),
1062
- { trigger: "agent_disconnected" }
1063
- );
1094
+ queueMicrotask(() => {
1095
+ if (this.disconnectMode || this.terminalSessionError || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
1096
+ if (this.sessionEnded) {
1097
+ this.agentDisconnected = true;
1098
+ this.finishPendingEnd();
1099
+ return;
1100
+ }
1101
+ if (!this.hasConnectedValue || this.room.state === LiveKitConnectionState.Reconnecting || this.activeRecovery !== null || this.autoRejoinPromise !== null || this.manualRejoinController !== null) return;
1102
+ if ([...this.room.remoteParticipants.values()].some((remote) => remote.isAgent)) return;
1103
+ this.agentDisconnected = true;
1104
+ if (!this.shouldAutoRejoin()) {
1105
+ void this.recoverDisconnectedSession().catch(() => {
1106
+ });
1107
+ return;
1108
+ }
1109
+ this.startAutoRejoin(
1110
+ new Error("Agent disconnected from the room"),
1111
+ { trigger: "agent_disconnected" }
1112
+ );
1113
+ });
1064
1114
  }
1065
1115
  });
1066
1116
  this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
@@ -1072,7 +1122,7 @@ var RealtimeSession = class _RealtimeSession {
1072
1122
  this.handleLiveKitDisconnected(reason);
1073
1123
  });
1074
1124
  this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
1075
- if (this.sessionEnded || this.terminalSessionError) return;
1125
+ if (this.sessionEnded || this.terminalSessionError || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
1076
1126
  if (state === LiveKitConnectionState.Connected) {
1077
1127
  const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
1078
1128
  this.setState(
@@ -1149,13 +1199,15 @@ var RealtimeSession = class _RealtimeSession {
1149
1199
  });
1150
1200
  }
1151
1201
  handleLiveKitReconnecting() {
1152
- if (this.sessionEnded || this.disconnectMode === "intentional") return;
1202
+ if (this.sessionEnded || this.disconnectMode === "intentional" || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
1153
1203
  this.agentReady = false;
1154
1204
  if (this.autoRejoinPromise !== null || this.activeRecovery?.strategy === "rejoin" || this.disconnectMode === "rejoin") {
1155
1205
  this.setState("resuming");
1156
1206
  return;
1157
1207
  }
1158
1208
  this.contextGeneration += 1;
1209
+ this.livekitResumeController?.abort(new Error("LiveKit connection interrupted again"));
1210
+ this.livekitResumeController = null;
1159
1211
  this.replacementContext.pause();
1160
1212
  this.reconnectStartedAt ??= Date.now();
1161
1213
  this.rejectPendingCommands(
@@ -1164,12 +1216,15 @@ var RealtimeSession = class _RealtimeSession {
1164
1216
  );
1165
1217
  if (this.activeRecovery === null) {
1166
1218
  this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
1219
+ this.recordDiagnostic({ operation: "connection_recovery", stage: "room_connect", status: "started" });
1167
1220
  }
1168
1221
  this.setState("reconnecting");
1169
1222
  }
1170
1223
  async handleLiveKitReconnected() {
1171
- if (this.sessionEnded || this.disconnectMode || this.autoRejoinPromise) return;
1224
+ if (this.sessionEnded || this.disconnectMode || this.autoRejoinPromise || this.livekitResumeController || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
1172
1225
  const generation = this.contextGeneration;
1226
+ const controller = new AbortController();
1227
+ this.livekitResumeController = controller;
1173
1228
  const recoveredAt = Date.now();
1174
1229
  if (this.reconnectStartedAt !== null) {
1175
1230
  this.pendingReconnect = {
@@ -1182,21 +1237,35 @@ var RealtimeSession = class _RealtimeSession {
1182
1237
  if (this.hasConnectedValue && this.activeRecovery === null) {
1183
1238
  this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
1184
1239
  }
1240
+ this.recordDiagnostic({
1241
+ operation: "connection_recovery",
1242
+ stage: "room_connect",
1243
+ status: "succeeded",
1244
+ duration_ms: Math.max(Date.now() - (this.recoveryStartedAt ?? Date.now()), 0)
1245
+ });
1185
1246
  this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
1186
1247
  this.clientSeq = 0;
1187
1248
  this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
1188
1249
  try {
1189
- await this.sendHello();
1250
+ await this.measureRecoveryStage("hello", () => this.sendRecoveryHello(controller.signal));
1190
1251
  const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
1191
- await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
1192
- if (this.recoveryReadiness.waitForRemoteAudio) await this.waitForRemoteAudioTrack(deadline);
1252
+ await this.measureRecoveryStage(
1253
+ "agent_ready",
1254
+ () => this.waitForAgentReady(Math.max(deadline - Date.now(), 0), controller.signal)
1255
+ );
1256
+ if (this.recoveryReadiness.waitForRemoteAudio) {
1257
+ await this.measureRecoveryStage(
1258
+ "remote_audio",
1259
+ () => this.waitForRemoteAudioTrack(deadline, controller.signal)
1260
+ );
1261
+ }
1193
1262
  if (this.sessionEnded || generation !== this.contextGeneration) return;
1194
1263
  this.resumeContextDelivery(true);
1195
1264
  if (this.sessionEnded || generation !== this.contextGeneration) return;
1196
1265
  this.setState("connected");
1197
1266
  this.completeRecovery();
1198
1267
  } catch (error) {
1199
- if (this.sessionEnded || generation !== this.contextGeneration) return;
1268
+ if (controller.signal.aborted || this.sessionEnded || generation !== this.contextGeneration) return;
1200
1269
  const normalizedError = toError(error);
1201
1270
  if (this.shouldAutoRejoin()) {
1202
1271
  this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
@@ -1204,6 +1273,8 @@ var RealtimeSession = class _RealtimeSession {
1204
1273
  this.failRecovery(normalizedError);
1205
1274
  this.setState("failed");
1206
1275
  }
1276
+ } finally {
1277
+ if (this.livekitResumeController === controller) this.livekitResumeController = null;
1207
1278
  }
1208
1279
  }
1209
1280
  handleLiveKitDisconnected(reason) {
@@ -1223,7 +1294,7 @@ var RealtimeSession = class _RealtimeSession {
1223
1294
  this.setState("disconnected");
1224
1295
  return;
1225
1296
  }
1226
- if (this.terminalSessionError) return;
1297
+ if (this.terminalSessionError || this.terminalReplayController) return;
1227
1298
  if (this.shouldAutoRejoin(reason)) {
1228
1299
  this.startAutoRejoin(
1229
1300
  new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`),
@@ -1231,6 +1302,11 @@ var RealtimeSession = class _RealtimeSession {
1231
1302
  );
1232
1303
  return;
1233
1304
  }
1305
+ if (this.hasConnectedValue && !this.sessionEnded && !["disconnected", "failed"].includes(this.stateValue) && (!isTerminalDisconnectReason(reason) || reason === DisconnectReason.ROOM_CLOSED || reason === DisconnectReason.ROOM_DELETED)) {
1306
+ void this.recoverDisconnectedSession().catch(() => {
1307
+ });
1308
+ return;
1309
+ }
1234
1310
  if (this.activeRecovery !== null) {
1235
1311
  this.failRecovery(
1236
1312
  new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`)
@@ -1252,14 +1328,42 @@ var RealtimeSession = class _RealtimeSession {
1252
1328
  void this.disconnect().catch(() => {
1253
1329
  });
1254
1330
  }
1255
- async recoverSessionEnd(apiBase, signal) {
1331
+ async recoverDisconnectedSession() {
1332
+ if (this.terminalReplayController) return;
1333
+ this.failRecovery(new Error("Transport closed during recovery"));
1334
+ this.cancelRecovery();
1335
+ this.contextGeneration += 1;
1336
+ this.replacementContext.pause();
1337
+ const controller = new AbortController();
1338
+ this.terminalReplayController = controller;
1339
+ this.setState("resuming");
1340
+ try {
1341
+ await this.recoverSessionEnd(this.apiBase, controller.signal, true);
1342
+ } finally {
1343
+ if (this.terminalReplayController === controller) {
1344
+ this.terminalReplayController = null;
1345
+ if (!controller.signal.aborted && !this.sessionEnded && !this.terminalSessionError) {
1346
+ await this.disconnect();
1347
+ }
1348
+ }
1349
+ }
1350
+ }
1351
+ async recoverSessionEnd(apiBase, signal, waitForCommit = false) {
1256
1352
  const controller = new AbortController();
1257
1353
  const cancel = () => controller.abort(signal?.reason);
1258
1354
  signal?.addEventListener("abort", cancel, { once: true });
1259
1355
  if (signal?.aborted) cancel();
1260
1356
  const timeout = globalThis.setTimeout(() => controller.abort(), 5e3);
1261
1357
  try {
1262
- await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
1358
+ do {
1359
+ try {
1360
+ await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
1361
+ } catch (error) {
1362
+ if (!waitForCommit || controller.signal.aborted || isTerminalRejoinError(error)) throw error;
1363
+ }
1364
+ if (!waitForCommit || this.sessionEnded || this.terminalSessionError) break;
1365
+ await abortableDelay(500, controller.signal);
1366
+ } while (!controller.signal.aborted);
1263
1367
  } catch {
1264
1368
  } finally {
1265
1369
  globalThis.clearTimeout(timeout);
@@ -1275,6 +1379,7 @@ var RealtimeSession = class _RealtimeSession {
1275
1379
  this.notifyListeners(this.endedListeners, event);
1276
1380
  }
1277
1381
  beginRecovery(strategy, attempt, details) {
1382
+ this.recoveryStartedAt ??= Date.now();
1278
1383
  this.activeRecovery = { strategy, attempt, details };
1279
1384
  this.recordDiagnostic({
1280
1385
  operation: "connection_recovery",
@@ -1282,7 +1387,8 @@ var RealtimeSession = class _RealtimeSession {
1282
1387
  reason: details.trigger,
1283
1388
  message_id: details.messageId,
1284
1389
  context_key: details.contextKey,
1285
- timeout_ms: details.timeoutMs
1390
+ timeout_ms: details.timeoutMs,
1391
+ retry_count: attempt - 1
1286
1392
  });
1287
1393
  this.flushDiagnostics("recovery_started");
1288
1394
  this.notifyListeners(this.recoveryListeners, {
@@ -1295,6 +1401,16 @@ var RealtimeSession = class _RealtimeSession {
1295
1401
  completeRecovery() {
1296
1402
  const recovery = this.activeRecovery;
1297
1403
  if (!recovery) return;
1404
+ this.recordDiagnostic({
1405
+ operation: "connection_recovery",
1406
+ stage: "complete",
1407
+ status: "succeeded",
1408
+ duration_ms: Math.max(Date.now() - (this.recoveryStartedAt ?? Date.now()), 0),
1409
+ retry_count: recovery.attempt - 1,
1410
+ reason: recovery.details.trigger
1411
+ });
1412
+ this.flushDiagnostics("recovery_completed");
1413
+ this.recoveryStartedAt = null;
1298
1414
  this.activeRecovery = null;
1299
1415
  this.notifyListeners(this.recoveryListeners, {
1300
1416
  status: "succeeded",
@@ -1306,6 +1422,15 @@ var RealtimeSession = class _RealtimeSession {
1306
1422
  failRecovery(error) {
1307
1423
  const recovery = this.activeRecovery;
1308
1424
  if (!recovery) return;
1425
+ this.recordDiagnostic({
1426
+ operation: "connection_recovery",
1427
+ stage: "complete",
1428
+ status: "failed",
1429
+ duration_ms: Math.max(Date.now() - (this.recoveryStartedAt ?? Date.now()), 0),
1430
+ retry_count: recovery.attempt - 1,
1431
+ reason: recovery.details.trigger
1432
+ });
1433
+ this.flushDiagnostics("recovery_failed");
1309
1434
  this.activeRecovery = null;
1310
1435
  this.notifyListeners(this.recoveryListeners, {
1311
1436
  status: "failed",
@@ -1320,13 +1445,19 @@ var RealtimeSession = class _RealtimeSession {
1320
1445
  }
1321
1446
  startAutoRejoin(initialError, details) {
1322
1447
  if (this.autoRejoinPromise || !this.shouldAutoRejoin()) return;
1448
+ this.recoveryStartedAt ??= Date.now();
1323
1449
  this.agentReady = false;
1324
1450
  this.contextGeneration += 1;
1451
+ this.livekitResumeController?.abort(initialError);
1325
1452
  this.replacementContext.pause();
1326
1453
  this.rejectPendingCommands(initialError);
1327
1454
  this.reconnectStartedAt ??= Date.now();
1328
1455
  const controller = new AbortController();
1329
1456
  this.autoRejoinController = controller;
1457
+ const remainingMs = Math.max(
1458
+ this.autoRejoinOptions.timeoutMs - (Date.now() - this.recoveryStartedAt),
1459
+ 0
1460
+ );
1330
1461
  const timeout = globalThis.setTimeout(() => {
1331
1462
  if (controller.signal.aborted) return;
1332
1463
  const error = new Error("Session recovery timed out");
@@ -1336,7 +1467,7 @@ var RealtimeSession = class _RealtimeSession {
1336
1467
  this.replacementContext.clear(error);
1337
1468
  this.failRecovery(error);
1338
1469
  this.setState("failed");
1339
- }, this.autoRejoinOptions.timeoutMs);
1470
+ }, remainingMs);
1340
1471
  const promise = this.runAutoRejoin(initialError, controller.signal, details).finally(() => {
1341
1472
  globalThis.clearTimeout(timeout);
1342
1473
  if (this.autoRejoinController === controller) {
@@ -1382,6 +1513,11 @@ var RealtimeSession = class _RealtimeSession {
1382
1513
  this.setState("failed");
1383
1514
  }
1384
1515
  cancelRecovery() {
1516
+ this.terminalReplayController?.abort();
1517
+ this.terminalReplayController = null;
1518
+ this.livekitResumeController?.abort();
1519
+ this.livekitResumeController = null;
1520
+ this.recoveryStartedAt = null;
1385
1521
  this.autoRejoinController?.abort();
1386
1522
  this.autoRejoinController = null;
1387
1523
  this.manualRejoinController?.abort();
@@ -1419,19 +1555,71 @@ var RealtimeSession = class _RealtimeSession {
1419
1555
  throw new SessionConfigurationError("audioTrack has already ended");
1420
1556
  }
1421
1557
  }
1422
- async fetchRoomToken(apiBase, signal) {
1558
+ async measureRecoveryStage(stage, operation, recovering = true) {
1559
+ if (!recovering) return operation();
1560
+ const startedAt = Date.now();
1561
+ this.recordDiagnostic({ operation: "connection_recovery", stage, status: "started" });
1562
+ try {
1563
+ const result = await operation();
1564
+ this.recordDiagnostic({
1565
+ operation: "connection_recovery",
1566
+ stage,
1567
+ status: "succeeded",
1568
+ duration_ms: Date.now() - startedAt
1569
+ });
1570
+ return result;
1571
+ } catch (error) {
1572
+ this.recordDiagnostic({
1573
+ operation: "connection_recovery",
1574
+ stage,
1575
+ status: "failed",
1576
+ duration_ms: Date.now() - startedAt,
1577
+ reason: error instanceof RecoveryDataChannelStalledError ? "data_channel_stalled" : "stage_failed"
1578
+ });
1579
+ this.flushDiagnostics("recovery_stage_failed");
1580
+ throw error;
1581
+ }
1582
+ }
1583
+ async fetchRoomToken(apiBase, signal, recovering = true) {
1423
1584
  const controller = new AbortController();
1424
1585
  const cancel = () => controller.abort(signal?.reason);
1425
1586
  signal?.addEventListener("abort", cancel, { once: true });
1426
1587
  const timeout = globalThis.setTimeout(() => controller.abort(), 1e4);
1588
+ const startedAt = Date.now();
1589
+ if (recovering) {
1590
+ this.recordDiagnostic({ operation: "connection_recovery", stage: "token_request", status: "started" });
1591
+ }
1427
1592
  try {
1428
1593
  throwIfAborted(signal);
1429
- return await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
1594
+ const response = await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
1430
1595
  method: "POST",
1431
1596
  headers: { Authorization: `Bearer ${this.accessToken}` },
1432
1597
  credentials: this.lifecycleCredentials,
1433
1598
  signal: controller.signal
1434
1599
  }), controller.signal);
1600
+ if (recovering) {
1601
+ this.recordDiagnostic({
1602
+ operation: "connection_recovery",
1603
+ stage: "token_request",
1604
+ status: response.ok ? "succeeded" : "failed",
1605
+ http_status: response.status,
1606
+ duration_ms: Date.now() - startedAt
1607
+ });
1608
+ if (!response.ok) this.flushDiagnostics("recovery_stage_failed");
1609
+ }
1610
+ return response;
1611
+ } catch (error) {
1612
+ if (recovering) {
1613
+ this.recordDiagnostic({
1614
+ operation: "connection_recovery",
1615
+ stage: "token_request",
1616
+ status: "failed",
1617
+ duration_ms: Date.now() - startedAt,
1618
+ reason: signal?.aborted ? "cancelled" : controller.signal.aborted ? "request_timeout" : "request_failed"
1619
+ });
1620
+ this.flushDiagnostics("recovery_stage_failed");
1621
+ }
1622
+ throw error;
1435
1623
  } finally {
1436
1624
  globalThis.clearTimeout(timeout);
1437
1625
  signal?.removeEventListener("abort", cancel);
@@ -1563,6 +1751,36 @@ var RealtimeSession = class _RealtimeSession {
1563
1751
  await this.sendTrackedCommand(message, CLIENT_HELLO_TIMEOUT_MS);
1564
1752
  if (this.pendingReconnect === reconnect) this.pendingReconnect = null;
1565
1753
  }
1754
+ async sendRecoveryHello(signal) {
1755
+ throwIfAborted(signal);
1756
+ let previousBytes = this.reliableDataChannelBufferedBytes();
1757
+ let lastProgressAt = Date.now();
1758
+ let timer;
1759
+ const stalled = new Promise((_resolve, reject) => {
1760
+ const checkProgress = () => {
1761
+ const bufferedBytes = this.reliableDataChannelBufferedBytes();
1762
+ if (bufferedBytes === void 0 || bufferedBytes === 0 || previousBytes === void 0 || previousBytes === 0 || bufferedBytes < previousBytes) {
1763
+ lastProgressAt = Date.now();
1764
+ } else if (Date.now() - lastProgressAt >= RECOVERY_BUFFER_STALL_MS) {
1765
+ reject(new RecoveryDataChannelStalledError());
1766
+ return;
1767
+ }
1768
+ previousBytes = bufferedBytes;
1769
+ timer = globalThis.setTimeout(checkProgress, RECOVERY_BUFFER_POLL_MS);
1770
+ };
1771
+ timer = globalThis.setTimeout(checkProgress, RECOVERY_BUFFER_POLL_MS);
1772
+ });
1773
+ try {
1774
+ await raceWithAbort(Promise.race([this.sendHello(), stalled]), signal);
1775
+ } catch (error) {
1776
+ if (error instanceof RecoveryDataChannelStalledError) {
1777
+ this.rejectPendingCommands(error);
1778
+ }
1779
+ throw error;
1780
+ } finally {
1781
+ if (timer !== void 0) globalThis.clearTimeout(timer);
1782
+ }
1783
+ }
1566
1784
  async sendTrackedCommand(message, timeoutMs, retryTransfer) {
1567
1785
  const contextKey = contextKeyFromMessage(message);
1568
1786
  const payloadBytes = contextPayloadBytes(message.payload);
@@ -1579,10 +1797,14 @@ var RealtimeSession = class _RealtimeSession {
1579
1797
  timeout_ms: timeoutMs,
1580
1798
  event_type: diagnosticEventType(message.type)
1581
1799
  });
1800
+ const sendController = message.type === "context.update" ? new AbortController() : void 0;
1582
1801
  let markSettled = () => {
1583
1802
  };
1584
1803
  const settled = new Promise((resolve) => {
1585
- markSettled = resolve;
1804
+ markSettled = () => {
1805
+ sendController?.abort();
1806
+ resolve();
1807
+ };
1586
1808
  });
1587
1809
  const response = new Promise((resolve, reject) => {
1588
1810
  const timeout = globalThis.setTimeout(() => {
@@ -1625,6 +1847,7 @@ var RealtimeSession = class _RealtimeSession {
1625
1847
  settled,
1626
1848
  markSettled,
1627
1849
  retryTransfer,
1850
+ sendController,
1628
1851
  transferRetrying: false,
1629
1852
  transferRetryRequested: false
1630
1853
  }
@@ -1648,10 +1871,10 @@ var RealtimeSession = class _RealtimeSession {
1648
1871
  if (!pending) return;
1649
1872
  if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue))) return;
1650
1873
  try {
1651
- if (pending.transferRetryRequested && pending.retryTransfer) {
1874
+ if (pending.transferRetryRequested && pending.retryTransfer && pending.sendController) {
1652
1875
  pending.transferRetryRequested = false;
1653
1876
  pending.transferRetrying = true;
1654
- const transfer = pending.retryTransfer();
1877
+ const transfer = pending.retryTransfer(pending.sendController.signal);
1655
1878
  pending.transferSettled = transfer.then(() => {
1656
1879
  }, () => {
1657
1880
  });
@@ -1698,12 +1921,13 @@ var RealtimeSession = class _RealtimeSession {
1698
1921
  const attemptStartedAt = Date.now();
1699
1922
  try {
1700
1923
  let publishFinished = false;
1701
- const publish = this.publishCommand(message).then(() => {
1924
+ const publish = this.publishCommand(message, pending.sendController?.signal).then(() => {
1702
1925
  publishFinished = true;
1703
1926
  });
1704
1927
  await Promise.race([publish, pending.settled]);
1705
1928
  if (!publishFinished) return;
1706
1929
  } catch (error) {
1930
+ if (pending.sendController?.signal.aborted) return;
1707
1931
  this.recordDiagnostic({
1708
1932
  operation: "command_send",
1709
1933
  status: "failed",
@@ -1735,10 +1959,11 @@ var RealtimeSession = class _RealtimeSession {
1735
1959
  });
1736
1960
  if (durationMs > 2e3) this.flushDiagnostics("slow_operation");
1737
1961
  }
1738
- async publishCommand(message) {
1962
+ async publishCommand(message, signal) {
1963
+ const options = { reliable: true, topic: "voxevolv.commands", signal };
1739
1964
  await this.room.localParticipant.publishData(
1740
1965
  new TextEncoder().encode(JSON.stringify(message)),
1741
- { reliable: true, topic: "voxevolv.commands" }
1966
+ options
1742
1967
  );
1743
1968
  }
1744
1969
  startDiagnosticLoopMonitor() {
@@ -1757,7 +1982,7 @@ var RealtimeSession = class _RealtimeSession {
1757
1982
  connection_state: this.stateValue,
1758
1983
  buffered_bytes: this.reliableDataChannelBufferedBytes()
1759
1984
  };
1760
- if (entry.operation === "tool_rpc") {
1985
+ if (entry.operation === "tool_rpc" || entry.operation === "connection_recovery") {
1761
1986
  snapshot.page_visibility = globalThis.document?.visibilityState;
1762
1987
  snapshot.active_context_transfers = this.activeContextTransfers;
1763
1988
  snapshot.active_context_bytes = this.activeContextBytes;
@@ -1933,6 +2158,9 @@ var RealtimeSession = class _RealtimeSession {
1933
2158
  }
1934
2159
  }
1935
2160
  rejectPendingCommands(error, preserveContext = false) {
2161
+ if (!preserveContext) {
2162
+ for (const controller of this.contextTransferControllers) controller.abort(error);
2163
+ }
1936
2164
  for (const [messageId, pending] of this.pendingCommands) {
1937
2165
  if (preserveContext && pending.commandType === "context.update") continue;
1938
2166
  this.recordCommandResult(