@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.
- package/CHANGELOG.md +25 -0
- package/README.md +14 -11
- package/dist/browser/browser-session.d.ts +7 -1
- package/dist/browser/browser-session.d.ts.map +1 -1
- package/dist/browser/browser-session.js +6 -1
- package/dist/browser/browser-session.js.map +1 -1
- package/dist/browser/browser-voice-session.d.ts +19 -0
- package/dist/browser/browser-voice-session.d.ts.map +1 -1
- package/dist/browser/browser-voice-session.js +580 -93
- package/dist/browser/browser-voice-session.js.map +1 -1
- package/dist/browser/client-capabilities.d.ts +15 -0
- package/dist/browser/client-capabilities.d.ts.map +1 -0
- package/dist/browser/client-capabilities.js +14 -0
- package/dist/browser/client-capabilities.js.map +1 -0
- package/dist/browser/peer-connection-close.d.ts +34 -0
- package/dist/browser/peer-connection-close.d.ts.map +1 -0
- package/dist/browser/peer-connection-close.js +98 -0
- package/dist/browser/peer-connection-close.js.map +1 -0
- 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 +26 -0
- package/dist/browser/voice-session-diagnostics.d.ts.map +1 -0
- package/dist/browser/voice-session-diagnostics.js +48 -0
- package/dist/browser/voice-session-diagnostics.js.map +1 -0
- package/dist/browser/webrtc-runtime.d.ts +4 -0
- package/dist/browser/webrtc-runtime.d.ts.map +1 -1
- package/dist/browser/webrtc-runtime.js +8 -0
- package/dist/browser/webrtc-runtime.js.map +1 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
465
|
-
|
|
607
|
+
localPc.oniceconnectionstatechange = () => {
|
|
608
|
+
if (!isPcCurrent())
|
|
609
|
+
return;
|
|
610
|
+
debug?.info("webrtc", "ice_connection_state", localPc.iceConnectionState ?? "unknown");
|
|
466
611
|
};
|
|
467
|
-
|
|
468
|
-
|
|
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(
|
|
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
|
|
641
|
+
await localPc.setRemoteDescription(sdp);
|
|
642
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
643
|
+
return;
|
|
491
644
|
logOfferStep(step);
|
|
492
645
|
step = "drain_pending_ice";
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
646
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
647
|
+
return;
|
|
648
|
+
await drainPendingIce(localPc, offerGeneration);
|
|
496
649
|
logOfferStep(step);
|
|
497
650
|
step = "create_answer";
|
|
498
|
-
|
|
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
|
|
658
|
+
await localPc.setLocalDescription(answer);
|
|
659
|
+
if (!isOfferCurrent() || !isPcCurrent())
|
|
660
|
+
return;
|
|
502
661
|
logOfferStep(step);
|
|
503
662
|
step = "wait_ice_gathering";
|
|
504
|
-
await waitForIceGatheringComplete(
|
|
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:
|
|
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
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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 =
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|