@voicethere/client 0.7.14 → 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.
- package/CHANGELOG.md +11 -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 +527 -146
- 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,20 +863,74 @@ 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();
|
|
636
935
|
const requireOpenControl = () => {
|
|
637
936
|
if (!controlChannel || controlChannel.readyState !== "open") {
|
|
@@ -664,21 +963,27 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
664
963
|
throw error;
|
|
665
964
|
};
|
|
666
965
|
while (true) {
|
|
667
|
-
if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
|
|
966
|
+
if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
|
|
967
|
+
clearConnectedWait();
|
|
668
968
|
return;
|
|
969
|
+
}
|
|
669
970
|
if (pendingConnectFailure) {
|
|
670
971
|
throw pendingConnectFailure;
|
|
671
972
|
}
|
|
973
|
+
if (gracefulDisconnect) {
|
|
974
|
+
throw new Error("disconnected");
|
|
975
|
+
}
|
|
672
976
|
const remainingMs = deadlineMs - Date.now();
|
|
673
977
|
if (remainingMs <= 0) {
|
|
674
978
|
throwConnectTimeout();
|
|
675
979
|
}
|
|
676
980
|
const waitPromise = ensureConnectedPromise();
|
|
981
|
+
let timer;
|
|
677
982
|
try {
|
|
678
983
|
await Promise.race([
|
|
679
984
|
waitPromise,
|
|
680
985
|
new Promise((_, reject) => {
|
|
681
|
-
setTimeout(() => {
|
|
986
|
+
timer = setTimeout(() => {
|
|
682
987
|
reject(new Error("__wait_for_connected_timeout__"));
|
|
683
988
|
}, remainingMs);
|
|
684
989
|
}),
|
|
@@ -695,8 +1000,15 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
695
1000
|
}
|
|
696
1001
|
throw error;
|
|
697
1002
|
}
|
|
698
|
-
|
|
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();
|
|
699
1010
|
return;
|
|
1011
|
+
}
|
|
700
1012
|
}
|
|
701
1013
|
};
|
|
702
1014
|
return {
|
|
@@ -750,19 +1062,30 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
750
1062
|
disconnect: () => {
|
|
751
1063
|
// Sync terminal invalidation — must not await native close (reconnect opens WS promptly).
|
|
752
1064
|
gracefulDisconnect = true;
|
|
1065
|
+
negotiationGeneration += 1;
|
|
1066
|
+
activePcGeneration = 0;
|
|
1067
|
+
clearPendingIceGenerations();
|
|
1068
|
+
// Invalidate in-flight reconnect WS handlers / epoch.
|
|
1069
|
+
signalingEpoch += 1;
|
|
753
1070
|
if (reconnectTimer)
|
|
754
1071
|
clearTimeout(reconnectTimer);
|
|
755
1072
|
reconnectTimer = undefined;
|
|
756
1073
|
stopMicPump?.();
|
|
757
1074
|
stopMicPump = null;
|
|
1075
|
+
rejectConnected?.(new Error("disconnected"));
|
|
1076
|
+
clearConnectedWait();
|
|
1077
|
+
pendingConnectFailure = null;
|
|
1078
|
+
const asyncOwnsPeerClose = disconnectAsyncInFlight !== null;
|
|
758
1079
|
const localControl = controlChannel;
|
|
759
1080
|
const localSync = syncChannel;
|
|
760
|
-
const localPc = pc;
|
|
1081
|
+
const localPc = asyncOwnsPeerClose ? null : pc;
|
|
761
1082
|
const localWs = ws;
|
|
762
1083
|
const localMic = micStream;
|
|
763
1084
|
controlChannel = null;
|
|
764
1085
|
syncChannel = null;
|
|
765
|
-
|
|
1086
|
+
if (!asyncOwnsPeerClose) {
|
|
1087
|
+
pc = null;
|
|
1088
|
+
}
|
|
766
1089
|
ws = null;
|
|
767
1090
|
micStream = null;
|
|
768
1091
|
try {
|
|
@@ -788,11 +1111,28 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
788
1111
|
/* ignore */
|
|
789
1112
|
}
|
|
790
1113
|
}
|
|
791
|
-
|
|
792
|
-
|
|
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
|
+
}
|
|
793
1125
|
}
|
|
794
|
-
|
|
795
|
-
|
|
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
|
+
};
|
|
796
1136
|
}
|
|
797
1137
|
localMic?.getTracks().forEach((track) => track.stop());
|
|
798
1138
|
updateConnectionSnapshot({
|
|
@@ -806,66 +1146,107 @@ export async function connectBrowserVoiceSession(options) {
|
|
|
806
1146
|
debug?.info("session", "disconnected");
|
|
807
1147
|
},
|
|
808
1148
|
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();
|
|
1149
|
+
if (terminalDisconnectResult) {
|
|
1150
|
+
return terminalDisconnectResult;
|
|
827
1151
|
}
|
|
828
|
-
|
|
829
|
-
|
|
1152
|
+
if (replacementBlockedResult) {
|
|
1153
|
+
// Unsafe old PC already retired/quarantined — surface that strict failure.
|
|
1154
|
+
terminalDisconnectResult = replacementBlockedResult;
|
|
1155
|
+
return replacementBlockedResult;
|
|
830
1156
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
catch {
|
|
835
|
-
/* ignore */
|
|
1157
|
+
if (disconnectAsyncInFlight) {
|
|
1158
|
+
return disconnectAsyncInFlight;
|
|
836
1159
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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;
|
|
841
1184
|
try {
|
|
842
|
-
|
|
1185
|
+
localControl?.close();
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
/* ignore */
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
localSync?.close();
|
|
843
1192
|
}
|
|
844
1193
|
catch {
|
|
845
1194
|
/* ignore */
|
|
846
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;
|
|
847
1249
|
}
|
|
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
1250
|
},
|
|
870
1251
|
};
|
|
871
1252
|
}
|