@voicethere/client 0.7.14 → 0.7.16
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/CHANGELOG.md +17 -0
- package/dist/browser/browser-session.d.ts +1 -1
- package/dist/browser/browser-session.d.ts.map +1 -1
- package/dist/browser/browser-session.js.map +1 -1
- package/dist/browser/browser-voice-session.d.ts +11 -3
- package/dist/browser/browser-voice-session.d.ts.map +1 -1
- package/dist/browser/browser-voice-session.js +530 -148
- package/dist/browser/browser-voice-session.js.map +1 -1
- package/dist/browser/client-capabilities.d.ts +5 -0
- package/dist/browser/client-capabilities.d.ts.map +1 -1
- package/dist/browser/client-capabilities.js +5 -0
- package/dist/browser/client-capabilities.js.map +1 -1
- package/dist/browser/peer-connection-close.d.ts +14 -2
- package/dist/browser/peer-connection-close.d.ts.map +1 -1
- package/dist/browser/peer-connection-close.js +40 -6
- package/dist/browser/peer-connection-close.js.map +1 -1
- package/dist/browser/session-api-retry.d.ts +2 -0
- package/dist/browser/session-api-retry.d.ts.map +1 -1
- package/dist/browser/session-api-retry.js +37 -3
- package/dist/browser/session-api-retry.js.map +1 -1
- package/dist/browser/session-provision-poll.d.ts +1 -0
- package/dist/browser/session-provision-poll.d.ts.map +1 -1
- package/dist/browser/session-provision-poll.js +31 -2
- package/dist/browser/session-provision-poll.js.map +1 -1
- package/dist/browser/session-provision.d.ts +5 -0
- package/dist/browser/session-provision.d.ts.map +1 -1
- package/dist/browser/session-provision.js +8 -1
- package/dist/browser/session-provision.js.map +1 -1
- package/dist/browser/voice-session-diagnostics.d.ts +3 -1
- package/dist/browser/voice-session-diagnostics.d.ts.map +1 -1
- package/dist/browser/voice-session-diagnostics.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ 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";
|
|
7
|
+
import { closePeerConnectionAwaitable, } from "./peer-connection-close.js";
|
|
8
8
|
import { emitDiagnosticSafely, redactDiagnosticDetail, } from "./voice-session-diagnostics.js";
|
|
9
9
|
import { waitForIceGatheringComplete } from "./wait-for-ice-gathering.js";
|
|
10
10
|
import { isWebRtcConnectRetryError, WebRtcConnectRetryError, } from "./webrtc-connect-retry.js";
|
|
@@ -107,7 +107,8 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
107
107
|
let controlChannel = null;
|
|
108
108
|
let syncChannel = null;
|
|
109
109
|
let micStream = null;
|
|
110
|
-
|
|
110
|
+
/** ICE candidates queued by negotiation generation until that PC is ready. */
|
|
111
|
+
const pendingIceByGeneration = new Map();
|
|
111
112
|
let connectionState = "new";
|
|
112
113
|
let resolveConnected = null;
|
|
113
114
|
let rejectConnected = null;
|
|
@@ -115,6 +116,63 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
115
116
|
let pendingConnectFailure = null;
|
|
116
117
|
let stopMicPump = null;
|
|
117
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
|
+
};
|
|
118
176
|
const reconnectPolicy = options.reconnectPolicy ?? "same-session";
|
|
119
177
|
const maxAutoReconnectAttempts = options.maxAutoReconnectAttempts ?? 4;
|
|
120
178
|
let autoReconnectAttempts = 0;
|
|
@@ -150,6 +208,8 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
150
208
|
return;
|
|
151
209
|
pendingConnectFailure = null;
|
|
152
210
|
resolveConnected?.();
|
|
211
|
+
// Success path must drop waiter handles immediately (not only timeout/reject).
|
|
212
|
+
clearConnectedWait();
|
|
153
213
|
};
|
|
154
214
|
const updateConnectionSnapshot = (patch) => {
|
|
155
215
|
Object.assign(connectionSnapshot, patch);
|
|
@@ -272,9 +332,11 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
272
332
|
debug?.warn("dc", "malformed", raw);
|
|
273
333
|
}
|
|
274
334
|
};
|
|
275
|
-
const wireBinaryChannel = (channel, kind) => {
|
|
335
|
+
const wireBinaryChannel = (channel, kind, isChannelCurrent) => {
|
|
276
336
|
channel.binaryType = "arraybuffer";
|
|
277
337
|
channel.onmessage = (event) => {
|
|
338
|
+
if (!isChannelCurrent())
|
|
339
|
+
return;
|
|
278
340
|
if (typeof event.data === "string") {
|
|
279
341
|
if (kind === "control") {
|
|
280
342
|
handleControlJson(String(event.data));
|
|
@@ -304,21 +366,24 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
304
366
|
syncOutboundAudioTrack();
|
|
305
367
|
debug?.info("voice", "mic_granted");
|
|
306
368
|
}
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}
|
|
309
378
|
stopMicPump?.();
|
|
310
379
|
stopMicPump = null;
|
|
311
380
|
controlChannel = null;
|
|
312
381
|
syncChannel = null;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
ignorePeerConnectionClose = false;
|
|
317
|
-
}
|
|
318
|
-
pc = null;
|
|
319
|
-
pendingIce.length = 0;
|
|
382
|
+
const localPc = pc;
|
|
383
|
+
activePcGeneration = 0;
|
|
384
|
+
clearPendingIceGenerations();
|
|
320
385
|
connectionState = "new";
|
|
321
|
-
if (!
|
|
386
|
+
if (!retireOptions?.preserveConnectedWait) {
|
|
322
387
|
connectedPromise = null;
|
|
323
388
|
resolveConnected = null;
|
|
324
389
|
rejectConnected = null;
|
|
@@ -331,13 +396,38 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
331
396
|
controlChannelOpen: false,
|
|
332
397
|
syncChannelOpen: false,
|
|
333
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;
|
|
334
419
|
};
|
|
335
|
-
const bindDataChannel = (channel, binding) => {
|
|
420
|
+
const bindDataChannel = (channel, binding, isPcCurrent) => {
|
|
336
421
|
binding.assign(channel);
|
|
422
|
+
const isChannelCurrent = () => isPcCurrent() && binding.getAssigned() === channel;
|
|
337
423
|
const markOpen = () => {
|
|
424
|
+
if (!isChannelCurrent())
|
|
425
|
+
return;
|
|
338
426
|
updateConnectionSnapshot({ [binding.openField]: true });
|
|
339
427
|
};
|
|
340
428
|
channel.onopen = () => {
|
|
429
|
+
if (!isChannelCurrent())
|
|
430
|
+
return;
|
|
341
431
|
debug?.info("dc", "open", binding.label);
|
|
342
432
|
markOpen();
|
|
343
433
|
binding.onOpen?.(channel);
|
|
@@ -345,17 +435,25 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
345
435
|
if (channel.readyState === "open")
|
|
346
436
|
markOpen();
|
|
347
437
|
channel.onclose = () => {
|
|
438
|
+
if (!isChannelCurrent())
|
|
439
|
+
return;
|
|
348
440
|
debug?.info("dc", "close", binding.label);
|
|
349
441
|
binding.assign(null);
|
|
350
442
|
updateConnectionSnapshot({ [binding.openField]: false });
|
|
351
443
|
};
|
|
352
|
-
|
|
444
|
+
channel.onerror = () => {
|
|
445
|
+
if (!isChannelCurrent())
|
|
446
|
+
return;
|
|
447
|
+
debug?.warn("dc", "error", binding.label);
|
|
448
|
+
};
|
|
449
|
+
wireBinaryChannel(channel, binding.kind, isChannelCurrent);
|
|
353
450
|
};
|
|
354
|
-
const wireControl = (channel) => {
|
|
451
|
+
const wireControl = (channel, isPcCurrent) => {
|
|
355
452
|
bindDataChannel(channel, {
|
|
356
453
|
kind: "control",
|
|
357
454
|
label: VOICE_CONTROL_CHANNEL_LABEL,
|
|
358
455
|
openField: "controlChannelOpen",
|
|
456
|
+
getAssigned: () => controlChannel,
|
|
359
457
|
assign: (next) => {
|
|
360
458
|
controlChannel = next;
|
|
361
459
|
},
|
|
@@ -367,53 +465,74 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
367
465
|
customer_context: options.customerContext,
|
|
368
466
|
}));
|
|
369
467
|
},
|
|
370
|
-
});
|
|
468
|
+
}, isPcCurrent);
|
|
371
469
|
};
|
|
372
|
-
const wireSync = (channel) => {
|
|
470
|
+
const wireSync = (channel, isPcCurrent) => {
|
|
373
471
|
bindDataChannel(channel, {
|
|
374
472
|
kind: "sync",
|
|
375
473
|
label: VOICE_SYNC_CHANNEL_LABEL,
|
|
376
474
|
openField: "syncChannelOpen",
|
|
475
|
+
getAssigned: () => syncChannel,
|
|
377
476
|
assign: (next) => {
|
|
378
477
|
syncChannel = next;
|
|
379
478
|
},
|
|
380
|
-
});
|
|
479
|
+
}, isPcCurrent);
|
|
381
480
|
};
|
|
382
|
-
const onServerOffer = async (sdp) => {
|
|
481
|
+
const onServerOffer = async (sdp, offerGeneration) => {
|
|
383
482
|
let step = "reset_peer_connection";
|
|
384
483
|
const startedAtMs = Date.now();
|
|
385
484
|
const logOfferStep = (name) => {
|
|
386
485
|
debug?.info("signaling", "offer_step", `${name} elapsed_ms=${Date.now() - startedAtMs}`);
|
|
387
486
|
};
|
|
487
|
+
const isOfferCurrent = () => offerGeneration === negotiationGeneration && !gracefulDisconnect;
|
|
388
488
|
try {
|
|
489
|
+
if (!isOfferCurrent())
|
|
490
|
+
return;
|
|
491
|
+
assertReplacementAllowed();
|
|
389
492
|
if (pc) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
pc = null;
|
|
394
|
-
pendingIce.length = 0;
|
|
395
|
-
controlChannel = null;
|
|
396
|
-
syncChannel = null;
|
|
397
|
-
connectionState = "new";
|
|
398
|
-
updateConnectionSnapshot({
|
|
399
|
-
peerConnectionState: "new",
|
|
400
|
-
inboundAudioTrack: false,
|
|
401
|
-
outboundAudioTrack: false,
|
|
402
|
-
controlChannelOpen: false,
|
|
403
|
-
syncChannelOpen: false,
|
|
493
|
+
step = "await_previous_peer_close";
|
|
494
|
+
const closeResult = await retirePeerConnection({
|
|
495
|
+
preserveConnectedWait: true,
|
|
404
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
|
+
}
|
|
405
513
|
}
|
|
406
514
|
logOfferStep(step);
|
|
515
|
+
assertReplacementAllowed();
|
|
407
516
|
step = "create_peer_connection";
|
|
408
517
|
ensureConnectedPromise();
|
|
409
|
-
|
|
518
|
+
const localPc = new runtime.RTCPeerConnection({
|
|
410
519
|
iceServers,
|
|
411
520
|
...(options.iceTransportPolicy
|
|
412
521
|
? { iceTransportPolicy: options.iceTransportPolicy }
|
|
413
522
|
: {}),
|
|
414
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;
|
|
415
532
|
logOfferStep(step);
|
|
416
|
-
|
|
533
|
+
localPc.ontrack = (event) => {
|
|
534
|
+
if (!isPcCurrent())
|
|
535
|
+
return;
|
|
417
536
|
if (event.track.kind !== "audio")
|
|
418
537
|
return;
|
|
419
538
|
const stream = event.streams[0] ?? new MediaStream([event.track]);
|
|
@@ -425,15 +544,19 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
425
544
|
updateConnectionSnapshot({ inboundAudioTrack: true });
|
|
426
545
|
debug?.info("webrtc", "agent_audio_track");
|
|
427
546
|
};
|
|
428
|
-
|
|
547
|
+
localPc.ondatachannel = (event) => {
|
|
548
|
+
if (!isPcCurrent())
|
|
549
|
+
return;
|
|
429
550
|
if (event.channel.label === VOICE_CONTROL_CHANNEL_LABEL) {
|
|
430
|
-
wireControl(event.channel);
|
|
551
|
+
wireControl(event.channel, isPcCurrent);
|
|
431
552
|
}
|
|
432
553
|
else if (event.channel.label === VOICE_SYNC_CHANNEL_LABEL) {
|
|
433
|
-
wireSync(event.channel);
|
|
554
|
+
wireSync(event.channel, isPcCurrent);
|
|
434
555
|
}
|
|
435
556
|
};
|
|
436
|
-
|
|
557
|
+
localPc.onicecandidate = (event) => {
|
|
558
|
+
if (!isPcCurrent())
|
|
559
|
+
return;
|
|
437
560
|
if (event.candidate) {
|
|
438
561
|
sendToServer({
|
|
439
562
|
type: "ice-candidate",
|
|
@@ -442,41 +565,60 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
442
565
|
});
|
|
443
566
|
}
|
|
444
567
|
};
|
|
445
|
-
|
|
446
|
-
|
|
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";
|
|
447
577
|
debug?.info("webrtc", "connection_state", connectionState);
|
|
448
578
|
updateConnectionSnapshot({ peerConnectionState: connectionState });
|
|
449
579
|
if (connectionState === "connected") {
|
|
580
|
+
if (!isPcCurrent())
|
|
581
|
+
return;
|
|
450
582
|
autoReconnectAttempts = 0;
|
|
451
583
|
stopMicPump?.();
|
|
452
584
|
stopMicPump = null;
|
|
453
585
|
if (micStream && (options.micPump ?? "silent") === "silent") {
|
|
454
|
-
stopMicPump = createMicPump(micStream, () => pc
|
|
586
|
+
stopMicPump = createMicPump(micStream, () => pc === localPc && localPc.connectionState === "connected", debug);
|
|
455
587
|
}
|
|
456
588
|
syncOutboundAudioTrack();
|
|
457
589
|
}
|
|
458
590
|
else if (connectionState === "failed") {
|
|
591
|
+
if (!isPcCurrent())
|
|
592
|
+
return;
|
|
459
593
|
handleTransportFailure("failed", "webrtc_failed");
|
|
460
594
|
}
|
|
461
595
|
else if (connectionState === "closed") {
|
|
462
|
-
if (
|
|
596
|
+
if (intentionalRetire || gracefulDisconnect) {
|
|
463
597
|
if (gracefulDisconnect) {
|
|
464
598
|
rejectConnectedWait(new Error(`peer connection ${connectionState}`), false);
|
|
465
599
|
}
|
|
466
600
|
return;
|
|
467
601
|
}
|
|
602
|
+
if (!isPcCurrent())
|
|
603
|
+
return;
|
|
468
604
|
handleTransportFailure("closed", "webrtc_closed");
|
|
469
605
|
}
|
|
470
606
|
};
|
|
471
|
-
|
|
472
|
-
|
|
607
|
+
localPc.oniceconnectionstatechange = () => {
|
|
608
|
+
if (!isPcCurrent())
|
|
609
|
+
return;
|
|
610
|
+
debug?.info("webrtc", "ice_connection_state", localPc.iceConnectionState ?? "unknown");
|
|
473
611
|
};
|
|
474
|
-
|
|
475
|
-
|
|
612
|
+
localPc.onicegatheringstatechange = () => {
|
|
613
|
+
if (!isPcCurrent())
|
|
614
|
+
return;
|
|
615
|
+
debug?.info("webrtc", "ice_gathering_state", localPc.iceGatheringState ?? "unknown");
|
|
476
616
|
};
|
|
477
617
|
if (micStream) {
|
|
478
618
|
step = "attach_mic";
|
|
479
|
-
await attachMicTracks(
|
|
619
|
+
await attachMicTracks(localPc, micStream);
|
|
620
|
+
if (!isPcCurrent())
|
|
621
|
+
return;
|
|
480
622
|
syncOutboundAudioTrack();
|
|
481
623
|
if ((options.micPump ?? "silent") === "external") {
|
|
482
624
|
for (const track of micStream.getAudioTracks()) {
|
|
@@ -491,35 +633,49 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
491
633
|
logOfferStep(step);
|
|
492
634
|
}
|
|
493
635
|
step = "set_remote_description";
|
|
636
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
637
|
+
return;
|
|
494
638
|
if (!remoteOfferHasIceUfrag(sdp)) {
|
|
495
639
|
throw new Error("set_remote_description called with no ice-ufrag (remote offer missing a=ice-ufrag)");
|
|
496
640
|
}
|
|
497
|
-
await
|
|
641
|
+
await localPc.setRemoteDescription(sdp);
|
|
642
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
643
|
+
return;
|
|
498
644
|
logOfferStep(step);
|
|
499
645
|
step = "drain_pending_ice";
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
646
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
647
|
+
return;
|
|
648
|
+
await drainPendingIce(localPc, offerGeneration);
|
|
503
649
|
logOfferStep(step);
|
|
504
650
|
step = "create_answer";
|
|
505
|
-
|
|
651
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
652
|
+
return;
|
|
653
|
+
const answer = await localPc.createAnswer();
|
|
654
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
655
|
+
return;
|
|
506
656
|
logOfferStep(step);
|
|
507
657
|
step = "set_local_description";
|
|
508
|
-
await
|
|
658
|
+
await localPc.setLocalDescription(answer);
|
|
659
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
660
|
+
return;
|
|
509
661
|
logOfferStep(step);
|
|
510
662
|
step = "wait_ice_gathering";
|
|
511
|
-
await waitForIceGatheringComplete(
|
|
663
|
+
await waitForIceGatheringComplete(localPc);
|
|
664
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
665
|
+
return;
|
|
512
666
|
logOfferStep(step);
|
|
513
667
|
step = "send_answer";
|
|
514
668
|
sendToServer({
|
|
515
669
|
type: "answer",
|
|
516
670
|
targetPeerId: VOICE_AGENT_SERVER_PEER_ID,
|
|
517
|
-
sdp:
|
|
671
|
+
sdp: localPc.localDescription,
|
|
518
672
|
});
|
|
519
673
|
debug?.info("signaling", "answer_sent");
|
|
520
674
|
logOfferStep(step);
|
|
521
675
|
}
|
|
522
676
|
catch (error) {
|
|
677
|
+
if (!isOfferCurrent())
|
|
678
|
+
return;
|
|
523
679
|
const detail = error instanceof Error ? error.message : String(error);
|
|
524
680
|
const message = `WebRTC offer handler failed at ${step}: ${detail}`;
|
|
525
681
|
debug?.error("signaling", "offer_handler_failed", `${message} elapsed_ms=${Date.now() - startedAtMs}`);
|
|
@@ -539,6 +695,20 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
539
695
|
}
|
|
540
696
|
}
|
|
541
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
|
+
};
|
|
542
712
|
const scheduleAutoReconnect = (reason) => {
|
|
543
713
|
if (gracefulDisconnect || reconnectPolicy === "new-session")
|
|
544
714
|
return;
|
|
@@ -558,27 +728,44 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
558
728
|
});
|
|
559
729
|
}, delayMs);
|
|
560
730
|
};
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
731
|
+
const attachWsHandlers = (localWs, boundEpoch) => {
|
|
732
|
+
const isCurrentWs = () => ws === localWs && boundEpoch === signalingEpoch && !gracefulDisconnect;
|
|
733
|
+
localWs.onmessage = (event) => {
|
|
734
|
+
if (!isCurrentWs())
|
|
735
|
+
return;
|
|
565
736
|
const message = JSON.parse(String(event.data));
|
|
566
737
|
debug?.debug("signaling", message.type, message.peerId);
|
|
567
738
|
switch (message.type) {
|
|
568
739
|
case "offer":
|
|
569
740
|
if (message.peerId === VOICE_AGENT_SERVER_PEER_ID && message.sdp) {
|
|
570
|
-
|
|
741
|
+
enqueueServerOffer(message.sdp);
|
|
571
742
|
}
|
|
572
743
|
break;
|
|
573
744
|
case "ice-candidate":
|
|
574
745
|
if (message.peerId === VOICE_AGENT_SERVER_PEER_ID &&
|
|
575
746
|
message.candidate &&
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
+
}
|
|
579
765
|
}
|
|
580
766
|
else {
|
|
581
|
-
|
|
767
|
+
// PC not yet materialized for this generation — queue for drain.
|
|
768
|
+
queuePendingIce(iceGeneration, message.candidate);
|
|
582
769
|
}
|
|
583
770
|
}
|
|
584
771
|
break;
|
|
@@ -586,28 +773,86 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
586
773
|
break;
|
|
587
774
|
}
|
|
588
775
|
};
|
|
776
|
+
localWs.onclose = () => {
|
|
777
|
+
if (!isCurrentWs())
|
|
778
|
+
return;
|
|
779
|
+
if (gracefulDisconnect || reconnectPolicy === "new-session")
|
|
780
|
+
return;
|
|
781
|
+
scheduleAutoReconnect("signaling_closed");
|
|
782
|
+
};
|
|
589
783
|
};
|
|
590
|
-
const joinSignalingRoom = async (isReconnect) => {
|
|
784
|
+
const joinSignalingRoom = async (isReconnect, boundEpoch) => {
|
|
591
785
|
if (isReconnect) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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}`);
|
|
597
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
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (boundEpoch !== signalingEpoch) {
|
|
824
|
+
return;
|
|
598
825
|
}
|
|
599
|
-
|
|
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;
|
|
600
838
|
await new Promise((resolve, reject) => {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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.
|
|
604
845
|
sendSignal({ type: "join", room: roomId, peerId });
|
|
605
846
|
debug?.info("signaling", "join_sent", `room=${roomId} peer=${peerId}`);
|
|
606
847
|
debug?.info("signaling", isReconnect ? "rejoined" : "joined", roomId);
|
|
607
848
|
updateConnectionSnapshot({ signalingJoined: true });
|
|
608
849
|
resolve();
|
|
609
850
|
};
|
|
610
|
-
|
|
851
|
+
nextWs.onerror = () => {
|
|
852
|
+
if (ws !== nextWs || boundEpoch !== signalingEpoch) {
|
|
853
|
+
reject(new Error("WebSocket superseded during reconnect"));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
611
856
|
dispatchConnectionError(createConnectionError("WebSocket error", {
|
|
612
857
|
subsystem: "webrtc",
|
|
613
858
|
sessionId: orchestratorSessionId,
|
|
@@ -618,31 +863,86 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
618
863
|
reject(new Error("WebSocket error"));
|
|
619
864
|
};
|
|
620
865
|
});
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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);
|
|
627
881
|
if (isReconnect) {
|
|
628
882
|
debug?.info("session", "same_session_reconnect", orchestratorSessionId);
|
|
629
883
|
}
|
|
630
884
|
};
|
|
631
|
-
const reconnectSignaling =
|
|
632
|
-
|
|
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
|
+
});
|
|
633
926
|
};
|
|
634
|
-
|
|
927
|
+
try {
|
|
928
|
+
await joinSignalingRoom(false, signalingEpoch);
|
|
929
|
+
}
|
|
930
|
+
catch (error) {
|
|
931
|
+
cleanupFailedInitialJoin();
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
635
934
|
publishConnectionStatus();
|
|
935
|
+
// Do not log on every failed send — open/close transitions are already
|
|
936
|
+
// emitted once via bindDataChannel (`dc/open`, `dc/close`). Callers (e.g. e2e
|
|
937
|
+
// readiness polls) may probe often while connecting.
|
|
636
938
|
const requireOpenControl = () => {
|
|
637
939
|
if (!controlChannel || controlChannel.readyState !== "open") {
|
|
638
|
-
debug?.error("dc", "control_not_open");
|
|
639
940
|
throw new Error("voice-control data channel is not open");
|
|
640
941
|
}
|
|
641
942
|
return controlChannel;
|
|
642
943
|
};
|
|
643
944
|
const requireOpenSync = () => {
|
|
644
945
|
if (!syncChannel || syncChannel.readyState !== "open") {
|
|
645
|
-
debug?.error("dc", "sync_not_open");
|
|
646
946
|
throw new Error("voicethere-sync data channel is not open");
|
|
647
947
|
}
|
|
648
948
|
return syncChannel;
|
|
@@ -664,21 +964,27 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
664
964
|
throw error;
|
|
665
965
|
};
|
|
666
966
|
while (true) {
|
|
667
|
-
if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
|
|
967
|
+
if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
|
|
968
|
+
clearConnectedWait();
|
|
668
969
|
return;
|
|
970
|
+
}
|
|
669
971
|
if (pendingConnectFailure) {
|
|
670
972
|
throw pendingConnectFailure;
|
|
671
973
|
}
|
|
974
|
+
if (gracefulDisconnect) {
|
|
975
|
+
throw new Error("disconnected");
|
|
976
|
+
}
|
|
672
977
|
const remainingMs = deadlineMs - Date.now();
|
|
673
978
|
if (remainingMs <= 0) {
|
|
674
979
|
throwConnectTimeout();
|
|
675
980
|
}
|
|
676
981
|
const waitPromise = ensureConnectedPromise();
|
|
982
|
+
let timer;
|
|
677
983
|
try {
|
|
678
984
|
await Promise.race([
|
|
679
985
|
waitPromise,
|
|
680
986
|
new Promise((_, reject) => {
|
|
681
|
-
setTimeout(() => {
|
|
987
|
+
timer = setTimeout(() => {
|
|
682
988
|
reject(new Error("__wait_for_connected_timeout__"));
|
|
683
989
|
}, remainingMs);
|
|
684
990
|
}),
|
|
@@ -695,8 +1001,15 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
695
1001
|
}
|
|
696
1002
|
throw error;
|
|
697
1003
|
}
|
|
698
|
-
|
|
1004
|
+
finally {
|
|
1005
|
+
// Success, retry, and disconnect paths must not leave a live timer.
|
|
1006
|
+
if (timer)
|
|
1007
|
+
clearTimeout(timer);
|
|
1008
|
+
}
|
|
1009
|
+
if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
|
|
1010
|
+
clearConnectedWait();
|
|
699
1011
|
return;
|
|
1012
|
+
}
|
|
700
1013
|
}
|
|
701
1014
|
};
|
|
702
1015
|
return {
|
|
@@ -750,19 +1063,30 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
750
1063
|
disconnect: () => {
|
|
751
1064
|
// Sync terminal invalidation — must not await native close (reconnect opens WS promptly).
|
|
752
1065
|
gracefulDisconnect = true;
|
|
1066
|
+
negotiationGeneration += 1;
|
|
1067
|
+
activePcGeneration = 0;
|
|
1068
|
+
clearPendingIceGenerations();
|
|
1069
|
+
// Invalidate in-flight reconnect WS handlers / epoch.
|
|
1070
|
+
signalingEpoch += 1;
|
|
753
1071
|
if (reconnectTimer)
|
|
754
1072
|
clearTimeout(reconnectTimer);
|
|
755
1073
|
reconnectTimer = undefined;
|
|
756
1074
|
stopMicPump?.();
|
|
757
1075
|
stopMicPump = null;
|
|
1076
|
+
rejectConnected?.(new Error("disconnected"));
|
|
1077
|
+
clearConnectedWait();
|
|
1078
|
+
pendingConnectFailure = null;
|
|
1079
|
+
const asyncOwnsPeerClose = disconnectAsyncInFlight !== null;
|
|
758
1080
|
const localControl = controlChannel;
|
|
759
1081
|
const localSync = syncChannel;
|
|
760
|
-
const localPc = pc;
|
|
1082
|
+
const localPc = asyncOwnsPeerClose ? null : pc;
|
|
761
1083
|
const localWs = ws;
|
|
762
1084
|
const localMic = micStream;
|
|
763
1085
|
controlChannel = null;
|
|
764
1086
|
syncChannel = null;
|
|
765
|
-
|
|
1087
|
+
if (!asyncOwnsPeerClose) {
|
|
1088
|
+
pc = null;
|
|
1089
|
+
}
|
|
766
1090
|
ws = null;
|
|
767
1091
|
micStream = null;
|
|
768
1092
|
try {
|
|
@@ -788,11 +1112,28 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
788
1112
|
/* ignore */
|
|
789
1113
|
}
|
|
790
1114
|
}
|
|
791
|
-
|
|
792
|
-
|
|
1115
|
+
// Do not sync-close while disconnectAsync owns native close — that masks
|
|
1116
|
+
// timed_out/failed outcomes from soak callers awaiting the async barrier.
|
|
1117
|
+
// Also do not sync-close a quarantined PC (native close already timed out).
|
|
1118
|
+
if (localPc && localPc !== quarantinedPc) {
|
|
1119
|
+
intentionallyRetiringPcs.add(localPc);
|
|
1120
|
+
try {
|
|
1121
|
+
localPc.close();
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
/* ignore */
|
|
1125
|
+
}
|
|
793
1126
|
}
|
|
794
|
-
|
|
795
|
-
|
|
1127
|
+
// If sync disconnect wins on an async-capable runtime, record an explicit
|
|
1128
|
+
// sync terminal outcome — later disconnectAsync must not claim async close.
|
|
1129
|
+
// Prefer retained replacement-blocked failure over inventing closed.
|
|
1130
|
+
if (!asyncOwnsPeerClose && !terminalDisconnectResult) {
|
|
1131
|
+
terminalDisconnectResult = replacementBlockedResult ?? {
|
|
1132
|
+
status: "closed",
|
|
1133
|
+
mode: "sync",
|
|
1134
|
+
durationMs: 0,
|
|
1135
|
+
timedOut: false,
|
|
1136
|
+
};
|
|
796
1137
|
}
|
|
797
1138
|
localMic?.getTracks().forEach((track) => track.stop());
|
|
798
1139
|
updateConnectionSnapshot({
|
|
@@ -806,66 +1147,107 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
806
1147
|
debug?.info("session", "disconnected");
|
|
807
1148
|
},
|
|
808
1149
|
disconnectAsync: async () => {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
clearTimeout(reconnectTimer);
|
|
812
|
-
reconnectTimer = undefined;
|
|
813
|
-
stopMicPump?.();
|
|
814
|
-
stopMicPump = null;
|
|
815
|
-
const localControl = controlChannel;
|
|
816
|
-
const localSync = syncChannel;
|
|
817
|
-
const localPc = pc;
|
|
818
|
-
const localWs = ws;
|
|
819
|
-
const localMic = micStream;
|
|
820
|
-
controlChannel = null;
|
|
821
|
-
syncChannel = null;
|
|
822
|
-
pc = null;
|
|
823
|
-
ws = null;
|
|
824
|
-
micStream = null;
|
|
825
|
-
try {
|
|
826
|
-
localControl?.close();
|
|
1150
|
+
if (terminalDisconnectResult) {
|
|
1151
|
+
return terminalDisconnectResult;
|
|
827
1152
|
}
|
|
828
|
-
|
|
829
|
-
|
|
1153
|
+
if (replacementBlockedResult) {
|
|
1154
|
+
// Unsafe old PC already retired/quarantined — surface that strict failure.
|
|
1155
|
+
terminalDisconnectResult = replacementBlockedResult;
|
|
1156
|
+
return replacementBlockedResult;
|
|
830
1157
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
catch {
|
|
835
|
-
/* ignore */
|
|
1158
|
+
if (disconnectAsyncInFlight) {
|
|
1159
|
+
return disconnectAsyncInFlight;
|
|
836
1160
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1161
|
+
disconnectAsyncInFlight = (async () => {
|
|
1162
|
+
gracefulDisconnect = true;
|
|
1163
|
+
negotiationGeneration += 1;
|
|
1164
|
+
activePcGeneration = 0;
|
|
1165
|
+
clearPendingIceGenerations();
|
|
1166
|
+
signalingEpoch += 1;
|
|
1167
|
+
if (reconnectTimer)
|
|
1168
|
+
clearTimeout(reconnectTimer);
|
|
1169
|
+
reconnectTimer = undefined;
|
|
1170
|
+
stopMicPump?.();
|
|
1171
|
+
stopMicPump = null;
|
|
1172
|
+
rejectConnected?.(new Error("disconnected"));
|
|
1173
|
+
clearConnectedWait();
|
|
1174
|
+
pendingConnectFailure = null;
|
|
1175
|
+
const localControl = controlChannel;
|
|
1176
|
+
const localSync = syncChannel;
|
|
1177
|
+
const localPc = pc;
|
|
1178
|
+
const localWs = ws;
|
|
1179
|
+
const localMic = micStream;
|
|
1180
|
+
controlChannel = null;
|
|
1181
|
+
syncChannel = null;
|
|
1182
|
+
pc = null;
|
|
1183
|
+
ws = null;
|
|
1184
|
+
micStream = null;
|
|
841
1185
|
try {
|
|
842
|
-
|
|
1186
|
+
localControl?.close();
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
/* ignore */
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
localSync?.close();
|
|
843
1193
|
}
|
|
844
1194
|
catch {
|
|
845
1195
|
/* ignore */
|
|
846
1196
|
}
|
|
1197
|
+
if (localWs) {
|
|
1198
|
+
localWs.onclose = null;
|
|
1199
|
+
localWs.onmessage = null;
|
|
1200
|
+
localWs.onerror = null;
|
|
1201
|
+
try {
|
|
1202
|
+
localWs.close();
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
/* ignore */
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
localMic?.getTracks().forEach((track) => track.stop());
|
|
1209
|
+
// Never retry native close on a quarantined PC after timed_out/failed.
|
|
1210
|
+
const closeTarget = localPc && localPc !== quarantinedPc ? localPc : null;
|
|
1211
|
+
if (closeTarget) {
|
|
1212
|
+
intentionallyRetiringPcs.add(closeTarget);
|
|
1213
|
+
}
|
|
1214
|
+
const closeResult = closeTarget
|
|
1215
|
+
? await closePeerConnectionAwaitable(closeTarget)
|
|
1216
|
+
: (replacementBlockedResult ?? {
|
|
1217
|
+
status: "closed",
|
|
1218
|
+
mode: "sync",
|
|
1219
|
+
durationMs: 0,
|
|
1220
|
+
timedOut: false,
|
|
1221
|
+
});
|
|
1222
|
+
terminalDisconnectResult = closeResult;
|
|
1223
|
+
emitDiagnosticSafely(options.onDiagnosticEvent, {
|
|
1224
|
+
type: "peer_close",
|
|
1225
|
+
status: closeResult.status,
|
|
1226
|
+
mode: closeResult.mode,
|
|
1227
|
+
durationMs: closeResult.durationMs,
|
|
1228
|
+
timedOut: closeResult.timedOut,
|
|
1229
|
+
context: "disconnect",
|
|
1230
|
+
...(closeResult.error !== undefined
|
|
1231
|
+
? { error: redactDiagnosticDetail(closeResult.error) }
|
|
1232
|
+
: {}),
|
|
1233
|
+
});
|
|
1234
|
+
updateConnectionSnapshot({
|
|
1235
|
+
signalingJoined: false,
|
|
1236
|
+
peerConnectionState: "closed",
|
|
1237
|
+
inboundAudioTrack: false,
|
|
1238
|
+
outboundAudioTrack: false,
|
|
1239
|
+
controlChannelOpen: false,
|
|
1240
|
+
syncChannelOpen: false,
|
|
1241
|
+
});
|
|
1242
|
+
debug?.info("session", "disconnected", `peer_close=${closeResult.status}`);
|
|
1243
|
+
return closeResult;
|
|
1244
|
+
})();
|
|
1245
|
+
try {
|
|
1246
|
+
return await disconnectAsyncInFlight;
|
|
1247
|
+
}
|
|
1248
|
+
finally {
|
|
1249
|
+
disconnectAsyncInFlight = null;
|
|
847
1250
|
}
|
|
848
|
-
localMic?.getTracks().forEach((track) => track.stop());
|
|
849
|
-
const closeResult = await closePeerConnectionAwaitable(localPc);
|
|
850
|
-
emitDiagnosticSafely(options.onDiagnosticEvent, {
|
|
851
|
-
type: "peer_close",
|
|
852
|
-
mode: closeResult.mode,
|
|
853
|
-
durationMs: closeResult.durationMs,
|
|
854
|
-
timedOut: closeResult.timedOut,
|
|
855
|
-
context: "disconnect",
|
|
856
|
-
...(closeResult.error !== undefined
|
|
857
|
-
? { error: redactDiagnosticDetail(closeResult.error) }
|
|
858
|
-
: {}),
|
|
859
|
-
});
|
|
860
|
-
updateConnectionSnapshot({
|
|
861
|
-
signalingJoined: false,
|
|
862
|
-
peerConnectionState: "closed",
|
|
863
|
-
inboundAudioTrack: false,
|
|
864
|
-
outboundAudioTrack: false,
|
|
865
|
-
controlChannelOpen: false,
|
|
866
|
-
syncChannelOpen: false,
|
|
867
|
-
});
|
|
868
|
-
debug?.info("session", "disconnected");
|
|
869
1251
|
},
|
|
870
1252
|
};
|
|
871
1253
|
}
|