@voicethere/client 0.7.13 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +14 -11
  3. package/dist/browser/browser-session.d.ts +7 -1
  4. package/dist/browser/browser-session.d.ts.map +1 -1
  5. package/dist/browser/browser-session.js +6 -1
  6. package/dist/browser/browser-session.js.map +1 -1
  7. package/dist/browser/browser-voice-session.d.ts +19 -0
  8. package/dist/browser/browser-voice-session.d.ts.map +1 -1
  9. package/dist/browser/browser-voice-session.js +580 -93
  10. package/dist/browser/browser-voice-session.js.map +1 -1
  11. package/dist/browser/client-capabilities.d.ts +15 -0
  12. package/dist/browser/client-capabilities.d.ts.map +1 -0
  13. package/dist/browser/client-capabilities.js +14 -0
  14. package/dist/browser/client-capabilities.js.map +1 -0
  15. package/dist/browser/peer-connection-close.d.ts +34 -0
  16. package/dist/browser/peer-connection-close.d.ts.map +1 -0
  17. package/dist/browser/peer-connection-close.js +98 -0
  18. package/dist/browser/peer-connection-close.js.map +1 -0
  19. package/dist/browser/session-api-retry.d.ts +2 -0
  20. package/dist/browser/session-api-retry.d.ts.map +1 -1
  21. package/dist/browser/session-api-retry.js +37 -3
  22. package/dist/browser/session-api-retry.js.map +1 -1
  23. package/dist/browser/session-provision-poll.d.ts +1 -0
  24. package/dist/browser/session-provision-poll.d.ts.map +1 -1
  25. package/dist/browser/session-provision-poll.js +31 -2
  26. package/dist/browser/session-provision-poll.js.map +1 -1
  27. package/dist/browser/session-provision.d.ts +5 -0
  28. package/dist/browser/session-provision.d.ts.map +1 -1
  29. package/dist/browser/session-provision.js +8 -1
  30. package/dist/browser/session-provision.js.map +1 -1
  31. package/dist/browser/voice-session-diagnostics.d.ts +26 -0
  32. package/dist/browser/voice-session-diagnostics.d.ts.map +1 -0
  33. package/dist/browser/voice-session-diagnostics.js +48 -0
  34. package/dist/browser/voice-session-diagnostics.js.map +1 -0
  35. package/dist/browser/webrtc-runtime.d.ts +4 -0
  36. package/dist/browser/webrtc-runtime.d.ts.map +1 -1
  37. package/dist/browser/webrtc-runtime.js +8 -0
  38. package/dist/browser/webrtc-runtime.js.map +1 -1
  39. package/dist/node/index.d.ts +1 -0
  40. package/dist/node/index.d.ts.map +1 -1
  41. package/dist/node/index.js +1 -0
  42. package/dist/node/index.js.map +1 -1
  43. package/package.json +1 -1
@@ -4,6 +4,8 @@ import { createLocalSessionError, emitSessionError, isSessionErrorEvent, parseLe
4
4
  import { buildWebRtcConnectionStatus, isWebRtcConnectionReady, resolveReadinessProfile, } from "./webrtc-connection-status.js";
5
5
  import { collectWebRtcDiagnostics, } from "./webrtc-diagnostics.js";
6
6
  import { getDefaultBrowserRuntime, } from "./webrtc-runtime.js";
7
+ import { closePeerConnectionAwaitable, } from "./peer-connection-close.js";
8
+ import { emitDiagnosticSafely, redactDiagnosticDetail, } from "./voice-session-diagnostics.js";
7
9
  import { waitForIceGatheringComplete } from "./wait-for-ice-gathering.js";
8
10
  import { isWebRtcConnectRetryError, WebRtcConnectRetryError, } from "./webrtc-connect-retry.js";
9
11
  /** Game-sync DC traffic logged at debug level — E2E stderr needs `LOAD_TEST_CLIENT_DEBUG=1`. */
@@ -105,7 +107,8 @@ export async function connectBrowserVoiceSession(options) {
105
107
  let controlChannel = null;
106
108
  let syncChannel = null;
107
109
  let micStream = null;
108
- const pendingIce = [];
110
+ /** ICE candidates queued by negotiation generation until that PC is ready. */
111
+ const pendingIceByGeneration = new Map();
109
112
  let connectionState = "new";
110
113
  let resolveConnected = null;
111
114
  let rejectConnected = null;
@@ -113,6 +116,63 @@ export async function connectBrowserVoiceSession(options) {
113
116
  let pendingConnectFailure = null;
114
117
  let stopMicPump = null;
115
118
  let gracefulDisconnect = false;
119
+ /** Bumped on each offer / disconnect so stale answer/ICE paths cannot resurrect. */
120
+ let negotiationGeneration = 0;
121
+ /** Serializes overlapping createAnswer/setLocalDescription/gather/send paths. */
122
+ let offerChain = Promise.resolve();
123
+ let disconnectAsyncInFlight = null;
124
+ /** Cached terminal disconnect outcome — repeats must not invent `closed` for a null pc. */
125
+ let terminalDisconnectResult = null;
126
+ /** Generation of the live PC (mirrors offer gen at create); ICE/handlers ignore mismatches. */
127
+ let activePcGeneration = 0;
128
+ /**
129
+ * Signaling/reconnect epoch. Incremented only when starting a reconnect flight
130
+ * or on disconnect (invalidates in-flight WS handlers).
131
+ */
132
+ let signalingEpoch = 0;
133
+ /** True single-flight: concurrent reconnect callers share this promise. */
134
+ let reconnectFlight = null;
135
+ /**
136
+ * After a timed_out/failed replace-close, further PC creation is blocked.
137
+ * The unsafe native PC may still be live — never invent `closed`.
138
+ */
139
+ let replacementBlockedResult = null;
140
+ /** Retained reference after failed replace-close (do not retry native close). */
141
+ let quarantinedPc = null;
142
+ /** Per-PC intentional retire tracking (replaces a global ignore boolean). */
143
+ const intentionallyRetiringPcs = new WeakSet();
144
+ const clearPendingIceGenerations = (keepGeneration) => {
145
+ if (keepGeneration === undefined) {
146
+ pendingIceByGeneration.clear();
147
+ return;
148
+ }
149
+ for (const generation of [...pendingIceByGeneration.keys()]) {
150
+ if (generation !== keepGeneration) {
151
+ pendingIceByGeneration.delete(generation);
152
+ }
153
+ }
154
+ };
155
+ const queuePendingIce = (generation, candidate) => {
156
+ const bucket = pendingIceByGeneration.get(generation);
157
+ if (bucket) {
158
+ bucket.push(candidate);
159
+ }
160
+ else {
161
+ pendingIceByGeneration.set(generation, [candidate]);
162
+ }
163
+ };
164
+ const drainPendingIce = async (targetPc, generation) => {
165
+ const bucket = pendingIceByGeneration.get(generation) ?? [];
166
+ pendingIceByGeneration.delete(generation);
167
+ for (const candidate of bucket) {
168
+ await targetPc.addIceCandidate(candidate).catch(() => undefined);
169
+ }
170
+ };
171
+ const assertReplacementAllowed = () => {
172
+ if (replacementBlockedResult) {
173
+ throw new Error(`peer replacement blocked: previous close ${replacementBlockedResult.status}`);
174
+ }
175
+ };
116
176
  const reconnectPolicy = options.reconnectPolicy ?? "same-session";
117
177
  const maxAutoReconnectAttempts = options.maxAutoReconnectAttempts ?? 4;
118
178
  let autoReconnectAttempts = 0;
@@ -148,6 +208,8 @@ export async function connectBrowserVoiceSession(options) {
148
208
  return;
149
209
  pendingConnectFailure = null;
150
210
  resolveConnected?.();
211
+ // Success path must drop waiter handles immediately (not only timeout/reject).
212
+ clearConnectedWait();
151
213
  };
152
214
  const updateConnectionSnapshot = (patch) => {
153
215
  Object.assign(connectionSnapshot, patch);
@@ -270,9 +332,11 @@ export async function connectBrowserVoiceSession(options) {
270
332
  debug?.warn("dc", "malformed", raw);
271
333
  }
272
334
  };
273
- const wireBinaryChannel = (channel, kind) => {
335
+ const wireBinaryChannel = (channel, kind, isChannelCurrent) => {
274
336
  channel.binaryType = "arraybuffer";
275
337
  channel.onmessage = (event) => {
338
+ if (!isChannelCurrent())
339
+ return;
276
340
  if (typeof event.data === "string") {
277
341
  if (kind === "control") {
278
342
  handleControlJson(String(event.data));
@@ -302,21 +366,24 @@ export async function connectBrowserVoiceSession(options) {
302
366
  syncOutboundAudioTrack();
303
367
  debug?.info("voice", "mic_granted");
304
368
  }
305
- let ignorePeerConnectionClose = false;
306
- const resetPeerConnection = (options) => {
369
+ /**
370
+ * Retire the current PC with the awaitable close barrier.
371
+ * Callers that will create a replacement must abort when status is not `closed`.
372
+ * On timed_out/failed, quarantine and block all future replacements.
373
+ */
374
+ const retirePeerConnection = async (retireOptions) => {
375
+ if (replacementBlockedResult) {
376
+ return replacementBlockedResult;
377
+ }
307
378
  stopMicPump?.();
308
379
  stopMicPump = null;
309
380
  controlChannel = null;
310
381
  syncChannel = null;
311
- if (pc) {
312
- ignorePeerConnectionClose = true;
313
- pc.close();
314
- ignorePeerConnectionClose = false;
315
- }
316
- pc = null;
317
- pendingIce.length = 0;
382
+ const localPc = pc;
383
+ activePcGeneration = 0;
384
+ clearPendingIceGenerations();
318
385
  connectionState = "new";
319
- if (!options?.preserveConnectedWait) {
386
+ if (!retireOptions?.preserveConnectedWait) {
320
387
  connectedPromise = null;
321
388
  resolveConnected = null;
322
389
  rejectConnected = null;
@@ -329,13 +396,38 @@ export async function connectBrowserVoiceSession(options) {
329
396
  controlChannelOpen: false,
330
397
  syncChannelOpen: false,
331
398
  });
399
+ if (!localPc) {
400
+ return {
401
+ status: "closed",
402
+ mode: "sync",
403
+ durationMs: 0,
404
+ timedOut: false,
405
+ };
406
+ }
407
+ intentionallyRetiringPcs.add(localPc);
408
+ // Detach from live slot before awaiting close so handlers see identity change.
409
+ pc = null;
410
+ const closeResult = await closePeerConnectionAwaitable(localPc);
411
+ if (closeResult.status !== "closed") {
412
+ // Never invent closed later — retain unsafe PC, block replacement forever.
413
+ replacementBlockedResult = closeResult;
414
+ quarantinedPc = localPc;
415
+ terminalDisconnectResult = closeResult;
416
+ return closeResult;
417
+ }
418
+ return closeResult;
332
419
  };
333
- const bindDataChannel = (channel, binding) => {
420
+ const bindDataChannel = (channel, binding, isPcCurrent) => {
334
421
  binding.assign(channel);
422
+ const isChannelCurrent = () => isPcCurrent() && binding.getAssigned() === channel;
335
423
  const markOpen = () => {
424
+ if (!isChannelCurrent())
425
+ return;
336
426
  updateConnectionSnapshot({ [binding.openField]: true });
337
427
  };
338
428
  channel.onopen = () => {
429
+ if (!isChannelCurrent())
430
+ return;
339
431
  debug?.info("dc", "open", binding.label);
340
432
  markOpen();
341
433
  binding.onOpen?.(channel);
@@ -343,17 +435,25 @@ export async function connectBrowserVoiceSession(options) {
343
435
  if (channel.readyState === "open")
344
436
  markOpen();
345
437
  channel.onclose = () => {
438
+ if (!isChannelCurrent())
439
+ return;
346
440
  debug?.info("dc", "close", binding.label);
347
441
  binding.assign(null);
348
442
  updateConnectionSnapshot({ [binding.openField]: false });
349
443
  };
350
- wireBinaryChannel(channel, binding.kind);
444
+ channel.onerror = () => {
445
+ if (!isChannelCurrent())
446
+ return;
447
+ debug?.warn("dc", "error", binding.label);
448
+ };
449
+ wireBinaryChannel(channel, binding.kind, isChannelCurrent);
351
450
  };
352
- const wireControl = (channel) => {
451
+ const wireControl = (channel, isPcCurrent) => {
353
452
  bindDataChannel(channel, {
354
453
  kind: "control",
355
454
  label: VOICE_CONTROL_CHANNEL_LABEL,
356
455
  openField: "controlChannelOpen",
456
+ getAssigned: () => controlChannel,
357
457
  assign: (next) => {
358
458
  controlChannel = next;
359
459
  },
@@ -365,48 +465,74 @@ export async function connectBrowserVoiceSession(options) {
365
465
  customer_context: options.customerContext,
366
466
  }));
367
467
  },
368
- });
468
+ }, isPcCurrent);
369
469
  };
370
- const wireSync = (channel) => {
470
+ const wireSync = (channel, isPcCurrent) => {
371
471
  bindDataChannel(channel, {
372
472
  kind: "sync",
373
473
  label: VOICE_SYNC_CHANNEL_LABEL,
374
474
  openField: "syncChannelOpen",
475
+ getAssigned: () => syncChannel,
375
476
  assign: (next) => {
376
477
  syncChannel = next;
377
478
  },
378
- });
479
+ }, isPcCurrent);
379
480
  };
380
- const onServerOffer = async (sdp) => {
481
+ const onServerOffer = async (sdp, offerGeneration) => {
381
482
  let step = "reset_peer_connection";
382
483
  const startedAtMs = Date.now();
383
484
  const logOfferStep = (name) => {
384
485
  debug?.info("signaling", "offer_step", `${name} elapsed_ms=${Date.now() - startedAtMs}`);
385
486
  };
487
+ const isOfferCurrent = () => offerGeneration === negotiationGeneration && !gracefulDisconnect;
386
488
  try {
489
+ if (!isOfferCurrent())
490
+ return;
491
+ assertReplacementAllowed();
387
492
  if (pc) {
388
- ignorePeerConnectionClose = true;
389
- pc.close();
390
- ignorePeerConnectionClose = false;
391
- pc = null;
392
- pendingIce.length = 0;
393
- controlChannel = null;
394
- syncChannel = null;
395
- connectionState = "new";
396
- updateConnectionSnapshot({
397
- peerConnectionState: "new",
398
- inboundAudioTrack: false,
399
- outboundAudioTrack: false,
400
- controlChannelOpen: false,
401
- syncChannelOpen: false,
493
+ step = "await_previous_peer_close";
494
+ const closeResult = await retirePeerConnection({
495
+ preserveConnectedWait: true,
402
496
  });
497
+ emitDiagnosticSafely(options.onDiagnosticEvent, {
498
+ type: "peer_close",
499
+ status: closeResult.status,
500
+ mode: closeResult.mode,
501
+ durationMs: closeResult.durationMs,
502
+ timedOut: closeResult.timedOut,
503
+ context: "offer_replace",
504
+ ...(closeResult.error !== undefined
505
+ ? { error: redactDiagnosticDetail(closeResult.error) }
506
+ : {}),
507
+ });
508
+ if (!isOfferCurrent())
509
+ return;
510
+ if (closeResult.status !== "closed") {
511
+ throw new Error(`cannot replace peer connection: previous close ${closeResult.status}`);
512
+ }
403
513
  }
404
514
  logOfferStep(step);
515
+ assertReplacementAllowed();
405
516
  step = "create_peer_connection";
406
517
  ensureConnectedPromise();
407
- pc = new runtime.RTCPeerConnection({ iceServers });
518
+ const localPc = new runtime.RTCPeerConnection({
519
+ iceServers,
520
+ ...(options.iceTransportPolicy
521
+ ? { iceTransportPolicy: options.iceTransportPolicy }
522
+ : {}),
523
+ });
524
+ pc = localPc;
525
+ activePcGeneration = offerGeneration;
526
+ const boundGeneration = offerGeneration;
527
+ const isPcCurrent = () => pc === localPc &&
528
+ activePcGeneration === boundGeneration &&
529
+ offerGeneration === negotiationGeneration &&
530
+ !gracefulDisconnect &&
531
+ !replacementBlockedResult;
408
532
  logOfferStep(step);
409
- pc.ontrack = (event) => {
533
+ localPc.ontrack = (event) => {
534
+ if (!isPcCurrent())
535
+ return;
410
536
  if (event.track.kind !== "audio")
411
537
  return;
412
538
  const stream = event.streams[0] ?? new MediaStream([event.track]);
@@ -418,15 +544,19 @@ export async function connectBrowserVoiceSession(options) {
418
544
  updateConnectionSnapshot({ inboundAudioTrack: true });
419
545
  debug?.info("webrtc", "agent_audio_track");
420
546
  };
421
- pc.ondatachannel = (event) => {
547
+ localPc.ondatachannel = (event) => {
548
+ if (!isPcCurrent())
549
+ return;
422
550
  if (event.channel.label === VOICE_CONTROL_CHANNEL_LABEL) {
423
- wireControl(event.channel);
551
+ wireControl(event.channel, isPcCurrent);
424
552
  }
425
553
  else if (event.channel.label === VOICE_SYNC_CHANNEL_LABEL) {
426
- wireSync(event.channel);
554
+ wireSync(event.channel, isPcCurrent);
427
555
  }
428
556
  };
429
- pc.onicecandidate = (event) => {
557
+ localPc.onicecandidate = (event) => {
558
+ if (!isPcCurrent())
559
+ return;
430
560
  if (event.candidate) {
431
561
  sendToServer({
432
562
  type: "ice-candidate",
@@ -435,41 +565,60 @@ export async function connectBrowserVoiceSession(options) {
435
565
  });
436
566
  }
437
567
  };
438
- pc.onconnectionstatechange = () => {
439
- connectionState = pc?.connectionState ?? "new";
568
+ localPc.onconnectionstatechange = () => {
569
+ const intentionalRetire = intentionallyRetiringPcs.has(localPc);
570
+ // Per-PC identity: never let a retired/stale PC mutate live globals.
571
+ if (pc !== localPc) {
572
+ return;
573
+ }
574
+ if (!isPcCurrent() && !intentionalRetire)
575
+ return;
576
+ connectionState = localPc.connectionState ?? "new";
440
577
  debug?.info("webrtc", "connection_state", connectionState);
441
578
  updateConnectionSnapshot({ peerConnectionState: connectionState });
442
579
  if (connectionState === "connected") {
580
+ if (!isPcCurrent())
581
+ return;
443
582
  autoReconnectAttempts = 0;
444
583
  stopMicPump?.();
445
584
  stopMicPump = null;
446
585
  if (micStream && (options.micPump ?? "silent") === "silent") {
447
- stopMicPump = createMicPump(micStream, () => pc?.connectionState === "connected", debug);
586
+ stopMicPump = createMicPump(micStream, () => pc === localPc && localPc.connectionState === "connected", debug);
448
587
  }
449
588
  syncOutboundAudioTrack();
450
589
  }
451
590
  else if (connectionState === "failed") {
591
+ if (!isPcCurrent())
592
+ return;
452
593
  handleTransportFailure("failed", "webrtc_failed");
453
594
  }
454
595
  else if (connectionState === "closed") {
455
- if (ignorePeerConnectionClose || gracefulDisconnect) {
596
+ if (intentionalRetire || gracefulDisconnect) {
456
597
  if (gracefulDisconnect) {
457
598
  rejectConnectedWait(new Error(`peer connection ${connectionState}`), false);
458
599
  }
459
600
  return;
460
601
  }
602
+ if (!isPcCurrent())
603
+ return;
461
604
  handleTransportFailure("closed", "webrtc_closed");
462
605
  }
463
606
  };
464
- pc.oniceconnectionstatechange = () => {
465
- debug?.info("webrtc", "ice_connection_state", pc?.iceConnectionState ?? "unknown");
607
+ localPc.oniceconnectionstatechange = () => {
608
+ if (!isPcCurrent())
609
+ return;
610
+ debug?.info("webrtc", "ice_connection_state", localPc.iceConnectionState ?? "unknown");
466
611
  };
467
- pc.onicegatheringstatechange = () => {
468
- debug?.info("webrtc", "ice_gathering_state", pc?.iceGatheringState ?? "unknown");
612
+ localPc.onicegatheringstatechange = () => {
613
+ if (!isPcCurrent())
614
+ return;
615
+ debug?.info("webrtc", "ice_gathering_state", localPc.iceGatheringState ?? "unknown");
469
616
  };
470
617
  if (micStream) {
471
618
  step = "attach_mic";
472
- await attachMicTracks(pc, micStream);
619
+ await attachMicTracks(localPc, micStream);
620
+ if (!isPcCurrent())
621
+ return;
473
622
  syncOutboundAudioTrack();
474
623
  if ((options.micPump ?? "silent") === "external") {
475
624
  for (const track of micStream.getAudioTracks()) {
@@ -484,35 +633,49 @@ export async function connectBrowserVoiceSession(options) {
484
633
  logOfferStep(step);
485
634
  }
486
635
  step = "set_remote_description";
636
+ if (!isOfferCurrent() || !isPcCurrent())
637
+ return;
487
638
  if (!remoteOfferHasIceUfrag(sdp)) {
488
639
  throw new Error("set_remote_description called with no ice-ufrag (remote offer missing a=ice-ufrag)");
489
640
  }
490
- await pc.setRemoteDescription(sdp);
641
+ await localPc.setRemoteDescription(sdp);
642
+ if (!isOfferCurrent() || !isPcCurrent())
643
+ return;
491
644
  logOfferStep(step);
492
645
  step = "drain_pending_ice";
493
- for (const candidate of pendingIce.splice(0)) {
494
- await pc.addIceCandidate(candidate);
495
- }
646
+ if (!isOfferCurrent() || !isPcCurrent())
647
+ return;
648
+ await drainPendingIce(localPc, offerGeneration);
496
649
  logOfferStep(step);
497
650
  step = "create_answer";
498
- const answer = await pc.createAnswer();
651
+ if (!isOfferCurrent() || !isPcCurrent())
652
+ return;
653
+ const answer = await localPc.createAnswer();
654
+ if (!isOfferCurrent() || !isPcCurrent())
655
+ return;
499
656
  logOfferStep(step);
500
657
  step = "set_local_description";
501
- await pc.setLocalDescription(answer);
658
+ await localPc.setLocalDescription(answer);
659
+ if (!isOfferCurrent() || !isPcCurrent())
660
+ return;
502
661
  logOfferStep(step);
503
662
  step = "wait_ice_gathering";
504
- await waitForIceGatheringComplete(pc);
663
+ await waitForIceGatheringComplete(localPc);
664
+ if (!isOfferCurrent() || !isPcCurrent())
665
+ return;
505
666
  logOfferStep(step);
506
667
  step = "send_answer";
507
668
  sendToServer({
508
669
  type: "answer",
509
670
  targetPeerId: VOICE_AGENT_SERVER_PEER_ID,
510
- sdp: pc.localDescription,
671
+ sdp: localPc.localDescription,
511
672
  });
512
673
  debug?.info("signaling", "answer_sent");
513
674
  logOfferStep(step);
514
675
  }
515
676
  catch (error) {
677
+ if (!isOfferCurrent())
678
+ return;
516
679
  const detail = error instanceof Error ? error.message : String(error);
517
680
  const message = `WebRTC offer handler failed at ${step}: ${detail}`;
518
681
  debug?.error("signaling", "offer_handler_failed", `${message} elapsed_ms=${Date.now() - startedAtMs}`);
@@ -532,6 +695,20 @@ export async function connectBrowserVoiceSession(options) {
532
695
  }
533
696
  }
534
697
  };
698
+ /** One active negotiation at a time; newer offers supersede older generations. */
699
+ const enqueueServerOffer = (sdp) => {
700
+ if (replacementBlockedResult) {
701
+ debug?.warn("signaling", "offer_ignored_replacement_blocked", replacementBlockedResult.status);
702
+ return;
703
+ }
704
+ const offerGeneration = ++negotiationGeneration;
705
+ // Drop ICE buckets from prior generations; keep current gen for early candidates.
706
+ clearPendingIceGenerations(offerGeneration);
707
+ offerChain = offerChain
708
+ .catch(() => undefined)
709
+ .then(() => onServerOffer(sdp, offerGeneration));
710
+ void offerChain.catch(() => undefined);
711
+ };
535
712
  const scheduleAutoReconnect = (reason) => {
536
713
  if (gracefulDisconnect || reconnectPolicy === "new-session")
537
714
  return;
@@ -551,27 +728,44 @@ export async function connectBrowserVoiceSession(options) {
551
728
  });
552
729
  }, delayMs);
553
730
  };
554
- const attachWsMessageHandler = () => {
555
- if (!ws)
556
- return;
557
- ws.onmessage = (event) => {
731
+ const attachWsHandlers = (localWs, boundEpoch) => {
732
+ const isCurrentWs = () => ws === localWs && boundEpoch === signalingEpoch && !gracefulDisconnect;
733
+ localWs.onmessage = (event) => {
734
+ if (!isCurrentWs())
735
+ return;
558
736
  const message = JSON.parse(String(event.data));
559
737
  debug?.debug("signaling", message.type, message.peerId);
560
738
  switch (message.type) {
561
739
  case "offer":
562
740
  if (message.peerId === VOICE_AGENT_SERVER_PEER_ID && message.sdp) {
563
- void onServerOffer(message.sdp);
741
+ enqueueServerOffer(message.sdp);
564
742
  }
565
743
  break;
566
744
  case "ice-candidate":
567
745
  if (message.peerId === VOICE_AGENT_SERVER_PEER_ID &&
568
746
  message.candidate &&
569
- pc) {
570
- if (!pc.remoteDescription) {
571
- pendingIce.push(message.candidate);
747
+ !gracefulDisconnect) {
748
+ // Attribute to the latest negotiation generation (offer may already
749
+ // be enqueued while PC is still null on the offerChain).
750
+ const iceGeneration = negotiationGeneration;
751
+ if (iceGeneration === 0)
752
+ break;
753
+ const targetPc = pc;
754
+ if (targetPc &&
755
+ activePcGeneration === iceGeneration &&
756
+ pc === targetPc) {
757
+ if (!targetPc.remoteDescription) {
758
+ queuePendingIce(iceGeneration, message.candidate);
759
+ }
760
+ else {
761
+ void targetPc.addIceCandidate(message.candidate).catch(() => {
762
+ /* ignore stale/failed ICE on captured PC */
763
+ });
764
+ }
572
765
  }
573
766
  else {
574
- void pc.addIceCandidate(message.candidate);
767
+ // PC not yet materialized for this generation — queue for drain.
768
+ queuePendingIce(iceGeneration, message.candidate);
575
769
  }
576
770
  }
577
771
  break;
@@ -579,28 +773,86 @@ export async function connectBrowserVoiceSession(options) {
579
773
  break;
580
774
  }
581
775
  };
776
+ localWs.onclose = () => {
777
+ if (!isCurrentWs())
778
+ return;
779
+ if (gracefulDisconnect || reconnectPolicy === "new-session")
780
+ return;
781
+ scheduleAutoReconnect("signaling_closed");
782
+ };
582
783
  };
583
- const joinSignalingRoom = async (isReconnect) => {
784
+ const joinSignalingRoom = async (isReconnect, boundEpoch) => {
584
785
  if (isReconnect) {
585
- resetPeerConnection({ preserveConnectedWait: true });
586
- if (ws) {
587
- ws.onclose = null;
588
- ws.close();
589
- ws = null;
786
+ assertReplacementAllowed();
787
+ const closeResult = await retirePeerConnection({
788
+ preserveConnectedWait: true,
789
+ });
790
+ emitDiagnosticSafely(options.onDiagnosticEvent, {
791
+ type: "peer_close",
792
+ status: closeResult.status,
793
+ mode: closeResult.mode,
794
+ durationMs: closeResult.durationMs,
795
+ timedOut: closeResult.timedOut,
796
+ context: "reconnect",
797
+ ...(closeResult.error !== undefined
798
+ ? { error: redactDiagnosticDetail(closeResult.error) }
799
+ : {}),
800
+ });
801
+ if (closeResult.status !== "closed") {
802
+ throw new Error(`reconnect blocked: peer close ${closeResult.status}`);
803
+ }
804
+ if (boundEpoch !== signalingEpoch) {
805
+ return;
806
+ }
807
+ const previousWs = ws;
808
+ if (previousWs) {
809
+ previousWs.onclose = null;
810
+ previousWs.onmessage = null;
811
+ previousWs.onerror = null;
812
+ try {
813
+ previousWs.close();
814
+ }
815
+ catch {
816
+ /* ignore */
817
+ }
818
+ if (ws === previousWs) {
819
+ ws = null;
820
+ }
590
821
  }
591
822
  }
592
- ws = new runtime.WebSocket(signalingUrl);
823
+ if (boundEpoch !== signalingEpoch) {
824
+ return;
825
+ }
826
+ assertReplacementAllowed();
827
+ const nextWs = new runtime.WebSocket(signalingUrl);
828
+ if (boundEpoch !== signalingEpoch) {
829
+ try {
830
+ nextWs.close();
831
+ }
832
+ catch {
833
+ /* ignore */
834
+ }
835
+ return;
836
+ }
837
+ ws = nextWs;
593
838
  await new Promise((resolve, reject) => {
594
- if (!ws)
595
- return reject(new Error("WebSocket missing"));
596
- ws.onopen = () => {
839
+ nextWs.onopen = () => {
840
+ if (ws !== nextWs || boundEpoch !== signalingEpoch) {
841
+ reject(new Error("WebSocket superseded during reconnect"));
842
+ return;
843
+ }
844
+ // sendSignal uses global ws — only send when this socket is current.
597
845
  sendSignal({ type: "join", room: roomId, peerId });
598
846
  debug?.info("signaling", "join_sent", `room=${roomId} peer=${peerId}`);
599
847
  debug?.info("signaling", isReconnect ? "rejoined" : "joined", roomId);
600
848
  updateConnectionSnapshot({ signalingJoined: true });
601
849
  resolve();
602
850
  };
603
- ws.onerror = () => {
851
+ nextWs.onerror = () => {
852
+ if (ws !== nextWs || boundEpoch !== signalingEpoch) {
853
+ reject(new Error("WebSocket superseded during reconnect"));
854
+ return;
855
+ }
604
856
  dispatchConnectionError(createConnectionError("WebSocket error", {
605
857
  subsystem: "webrtc",
606
858
  sessionId: orchestratorSessionId,
@@ -611,20 +863,74 @@ export async function connectBrowserVoiceSession(options) {
611
863
  reject(new Error("WebSocket error"));
612
864
  };
613
865
  });
614
- attachWsMessageHandler();
615
- ws.onclose = () => {
616
- if (gracefulDisconnect || reconnectPolicy === "new-session")
617
- return;
618
- scheduleAutoReconnect("signaling_closed");
619
- };
866
+ if (boundEpoch !== signalingEpoch) {
867
+ if (ws === nextWs) {
868
+ nextWs.onclose = null;
869
+ nextWs.onmessage = null;
870
+ try {
871
+ nextWs.close();
872
+ }
873
+ catch {
874
+ /* ignore */
875
+ }
876
+ ws = null;
877
+ }
878
+ return;
879
+ }
880
+ attachWsHandlers(nextWs, boundEpoch);
620
881
  if (isReconnect) {
621
882
  debug?.info("session", "same_session_reconnect", orchestratorSessionId);
622
883
  }
623
884
  };
624
- const reconnectSignaling = async () => {
625
- await joinSignalingRoom(true);
885
+ const reconnectSignaling = () => {
886
+ if (gracefulDisconnect) {
887
+ return Promise.resolve();
888
+ }
889
+ if (replacementBlockedResult) {
890
+ return Promise.reject(new Error(`reconnect blocked: previous close ${replacementBlockedResult.status}`));
891
+ }
892
+ // True single-flight: all concurrent callers share one flight.
893
+ if (reconnectFlight) {
894
+ return reconnectFlight;
895
+ }
896
+ const epoch = ++signalingEpoch;
897
+ const flight = joinSignalingRoom(true, epoch).finally(() => {
898
+ if (reconnectFlight === flight) {
899
+ reconnectFlight = null;
900
+ }
901
+ });
902
+ reconnectFlight = flight;
903
+ return flight;
904
+ };
905
+ const cleanupFailedInitialJoin = () => {
906
+ const localWs = ws;
907
+ ws = null;
908
+ if (localWs) {
909
+ localWs.onclose = null;
910
+ localWs.onmessage = null;
911
+ localWs.onerror = null;
912
+ try {
913
+ localWs.close();
914
+ }
915
+ catch {
916
+ /* ignore */
917
+ }
918
+ }
919
+ const localMic = micStream;
920
+ micStream = null;
921
+ localMic?.getTracks().forEach((track) => track.stop());
922
+ updateConnectionSnapshot({
923
+ signalingJoined: false,
924
+ outboundAudioTrack: false,
925
+ });
626
926
  };
627
- await joinSignalingRoom(false);
927
+ try {
928
+ await joinSignalingRoom(false, signalingEpoch);
929
+ }
930
+ catch (error) {
931
+ cleanupFailedInitialJoin();
932
+ throw error;
933
+ }
628
934
  publishConnectionStatus();
629
935
  const requireOpenControl = () => {
630
936
  if (!controlChannel || controlChannel.readyState !== "open") {
@@ -657,21 +963,27 @@ export async function connectBrowserVoiceSession(options) {
657
963
  throw error;
658
964
  };
659
965
  while (true) {
660
- if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
966
+ if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
967
+ clearConnectedWait();
661
968
  return;
969
+ }
662
970
  if (pendingConnectFailure) {
663
971
  throw pendingConnectFailure;
664
972
  }
973
+ if (gracefulDisconnect) {
974
+ throw new Error("disconnected");
975
+ }
665
976
  const remainingMs = deadlineMs - Date.now();
666
977
  if (remainingMs <= 0) {
667
978
  throwConnectTimeout();
668
979
  }
669
980
  const waitPromise = ensureConnectedPromise();
981
+ let timer;
670
982
  try {
671
983
  await Promise.race([
672
984
  waitPromise,
673
985
  new Promise((_, reject) => {
674
- setTimeout(() => {
986
+ timer = setTimeout(() => {
675
987
  reject(new Error("__wait_for_connected_timeout__"));
676
988
  }, remainingMs);
677
989
  }),
@@ -688,8 +1000,15 @@ export async function connectBrowserVoiceSession(options) {
688
1000
  }
689
1001
  throw error;
690
1002
  }
691
- if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
1003
+ finally {
1004
+ // Success, retry, and disconnect paths must not leave a live timer.
1005
+ if (timer)
1006
+ clearTimeout(timer);
1007
+ }
1008
+ if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
1009
+ clearConnectedWait();
692
1010
  return;
1011
+ }
693
1012
  }
694
1013
  };
695
1014
  return {
@@ -741,16 +1060,81 @@ export async function connectBrowserVoiceSession(options) {
741
1060
  debug?.info("session", "close_signal", reason ?? "");
742
1061
  },
743
1062
  disconnect: () => {
1063
+ // Sync terminal invalidation — must not await native close (reconnect opens WS promptly).
744
1064
  gracefulDisconnect = true;
1065
+ negotiationGeneration += 1;
1066
+ activePcGeneration = 0;
1067
+ clearPendingIceGenerations();
1068
+ // Invalidate in-flight reconnect WS handlers / epoch.
1069
+ signalingEpoch += 1;
745
1070
  if (reconnectTimer)
746
1071
  clearTimeout(reconnectTimer);
1072
+ reconnectTimer = undefined;
747
1073
  stopMicPump?.();
748
1074
  stopMicPump = null;
749
- controlChannel?.close();
750
- syncChannel?.close();
751
- pc?.close();
752
- micStream?.getTracks().forEach((track) => track.stop());
753
- ws?.close();
1075
+ rejectConnected?.(new Error("disconnected"));
1076
+ clearConnectedWait();
1077
+ pendingConnectFailure = null;
1078
+ const asyncOwnsPeerClose = disconnectAsyncInFlight !== null;
1079
+ const localControl = controlChannel;
1080
+ const localSync = syncChannel;
1081
+ const localPc = asyncOwnsPeerClose ? null : pc;
1082
+ const localWs = ws;
1083
+ const localMic = micStream;
1084
+ controlChannel = null;
1085
+ syncChannel = null;
1086
+ if (!asyncOwnsPeerClose) {
1087
+ pc = null;
1088
+ }
1089
+ ws = null;
1090
+ micStream = null;
1091
+ try {
1092
+ localControl?.close();
1093
+ }
1094
+ catch {
1095
+ /* ignore */
1096
+ }
1097
+ try {
1098
+ localSync?.close();
1099
+ }
1100
+ catch {
1101
+ /* ignore */
1102
+ }
1103
+ if (localWs) {
1104
+ localWs.onclose = null;
1105
+ localWs.onmessage = null;
1106
+ localWs.onerror = null;
1107
+ try {
1108
+ localWs.close();
1109
+ }
1110
+ catch {
1111
+ /* ignore */
1112
+ }
1113
+ }
1114
+ // Do not sync-close while disconnectAsync owns native close — that masks
1115
+ // timed_out/failed outcomes from soak callers awaiting the async barrier.
1116
+ // Also do not sync-close a quarantined PC (native close already timed out).
1117
+ if (localPc && localPc !== quarantinedPc) {
1118
+ intentionallyRetiringPcs.add(localPc);
1119
+ try {
1120
+ localPc.close();
1121
+ }
1122
+ catch {
1123
+ /* ignore */
1124
+ }
1125
+ }
1126
+ // If sync disconnect wins on an async-capable runtime, record an explicit
1127
+ // sync terminal outcome — later disconnectAsync must not claim async close.
1128
+ // Prefer retained replacement-blocked failure over inventing closed.
1129
+ if (!asyncOwnsPeerClose && !terminalDisconnectResult) {
1130
+ terminalDisconnectResult = replacementBlockedResult ?? {
1131
+ status: "closed",
1132
+ mode: "sync",
1133
+ durationMs: 0,
1134
+ timedOut: false,
1135
+ };
1136
+ }
1137
+ localMic?.getTracks().forEach((track) => track.stop());
754
1138
  updateConnectionSnapshot({
755
1139
  signalingJoined: false,
756
1140
  peerConnectionState: "closed",
@@ -761,6 +1145,109 @@ export async function connectBrowserVoiceSession(options) {
761
1145
  });
762
1146
  debug?.info("session", "disconnected");
763
1147
  },
1148
+ disconnectAsync: async () => {
1149
+ if (terminalDisconnectResult) {
1150
+ return terminalDisconnectResult;
1151
+ }
1152
+ if (replacementBlockedResult) {
1153
+ // Unsafe old PC already retired/quarantined — surface that strict failure.
1154
+ terminalDisconnectResult = replacementBlockedResult;
1155
+ return replacementBlockedResult;
1156
+ }
1157
+ if (disconnectAsyncInFlight) {
1158
+ return disconnectAsyncInFlight;
1159
+ }
1160
+ disconnectAsyncInFlight = (async () => {
1161
+ gracefulDisconnect = true;
1162
+ negotiationGeneration += 1;
1163
+ activePcGeneration = 0;
1164
+ clearPendingIceGenerations();
1165
+ signalingEpoch += 1;
1166
+ if (reconnectTimer)
1167
+ clearTimeout(reconnectTimer);
1168
+ reconnectTimer = undefined;
1169
+ stopMicPump?.();
1170
+ stopMicPump = null;
1171
+ rejectConnected?.(new Error("disconnected"));
1172
+ clearConnectedWait();
1173
+ pendingConnectFailure = null;
1174
+ const localControl = controlChannel;
1175
+ const localSync = syncChannel;
1176
+ const localPc = pc;
1177
+ const localWs = ws;
1178
+ const localMic = micStream;
1179
+ controlChannel = null;
1180
+ syncChannel = null;
1181
+ pc = null;
1182
+ ws = null;
1183
+ micStream = null;
1184
+ try {
1185
+ localControl?.close();
1186
+ }
1187
+ catch {
1188
+ /* ignore */
1189
+ }
1190
+ try {
1191
+ localSync?.close();
1192
+ }
1193
+ catch {
1194
+ /* ignore */
1195
+ }
1196
+ if (localWs) {
1197
+ localWs.onclose = null;
1198
+ localWs.onmessage = null;
1199
+ localWs.onerror = null;
1200
+ try {
1201
+ localWs.close();
1202
+ }
1203
+ catch {
1204
+ /* ignore */
1205
+ }
1206
+ }
1207
+ localMic?.getTracks().forEach((track) => track.stop());
1208
+ // Never retry native close on a quarantined PC after timed_out/failed.
1209
+ const closeTarget = localPc && localPc !== quarantinedPc ? localPc : null;
1210
+ if (closeTarget) {
1211
+ intentionallyRetiringPcs.add(closeTarget);
1212
+ }
1213
+ const closeResult = closeTarget
1214
+ ? await closePeerConnectionAwaitable(closeTarget)
1215
+ : (replacementBlockedResult ?? {
1216
+ status: "closed",
1217
+ mode: "sync",
1218
+ durationMs: 0,
1219
+ timedOut: false,
1220
+ });
1221
+ terminalDisconnectResult = closeResult;
1222
+ emitDiagnosticSafely(options.onDiagnosticEvent, {
1223
+ type: "peer_close",
1224
+ status: closeResult.status,
1225
+ mode: closeResult.mode,
1226
+ durationMs: closeResult.durationMs,
1227
+ timedOut: closeResult.timedOut,
1228
+ context: "disconnect",
1229
+ ...(closeResult.error !== undefined
1230
+ ? { error: redactDiagnosticDetail(closeResult.error) }
1231
+ : {}),
1232
+ });
1233
+ updateConnectionSnapshot({
1234
+ signalingJoined: false,
1235
+ peerConnectionState: "closed",
1236
+ inboundAudioTrack: false,
1237
+ outboundAudioTrack: false,
1238
+ controlChannelOpen: false,
1239
+ syncChannelOpen: false,
1240
+ });
1241
+ debug?.info("session", "disconnected", `peer_close=${closeResult.status}`);
1242
+ return closeResult;
1243
+ })();
1244
+ try {
1245
+ return await disconnectAsyncInFlight;
1246
+ }
1247
+ finally {
1248
+ disconnectAsyncInFlight = null;
1249
+ }
1250
+ },
764
1251
  };
765
1252
  }
766
1253
  //# sourceMappingURL=browser-voice-session.js.map