@@ -30,33 +30,33 @@ import {
3030import type { ServerPairingHandoff , ServerRemoteExposure } from './serverExposure.js' ;
3131import { bindUiArchiveChannels , safeChannelSend } from './uiArchiveTransfer.js' ;
3232import {
33- applyHostedLaneDiagnostic ,
3433 collectHostIceAddresses ,
3534 createHandshakeJoinQueue ,
3635 DEVICE_HOST_AVAILABILITY_MS ,
3736 deviceHostRefreshDelayMs ,
3837 type HostedIceServer ,
39- HostedGenerationSet ,
38+ HostedLivePeerRegistry ,
4039 HostedPeerLifecycle ,
4140 hostedPeerConfiguration ,
41+ requiredLaneClosed ,
4242 resolveIceRecoveryGraceMs ,
43- shouldFailHostedStall ,
4443} from './hostedPeerLifecycle.js' ;
4544import { createHostedStreamDiagnostics , frameByteLength } from './hostedStreamDiagnostics.js' ;
4645
4746export {
48- applyHostedLaneDiagnostic ,
4947 collectHostIceAddresses ,
5048 createHandshakeJoinQueue ,
5149 DEFAULT_HOSTED_ICE_SERVERS ,
5250 DEFAULT_ICE_RECOVERY_GRACE_MS ,
5351 DEVICE_HOST_AVAILABILITY_MS ,
5452 DEVICE_REFRESH_LEAD_MS ,
5553 deviceHostRefreshDelayMs ,
56- HostedGenerationSet ,
54+ HostedLivePeerRegistry ,
5755 HostedPeerLifecycle ,
5856 hostedPeerConfiguration ,
5957 parseHostedIceServers ,
58+ REQUIRED_LANES ,
59+ requiredLaneClosed ,
6060 resolveHostedIceServers ,
6161 resolveIceRecoveryGraceMs ,
6262} from './hostedPeerLifecycle.js' ;
@@ -166,14 +166,12 @@ export type HostedPairingDiagnostic = Readonly<{
166166 readonly firstInboundAgeMs ?: number | null ;
167167 readonly firstOutboundAgeMs ?: number | null ;
168168 readonly liveGenerationCount ?: number ;
169- readonly stallIgnored ?: boolean ;
170169 readonly hangup ?: boolean ;
171170 readonly inboundKind ?: 'bytes' | 'blob' | 'string' | 'empty' | 'other' | undefined ;
172171 readonly droppedFrames ?: number ;
173172 readonly droppedClass ?: 'bytes' | 'blob' | 'string' | 'empty' | 'other' ;
174173 readonly sendFailures ?: number ;
175174 readonly sendFailure ?: boolean ;
176- readonly stallClass ?: 'no-outbound' | 'outbound-stalled' ;
177175 readonly first ?: 'inbound' | 'outbound' ;
178176 readonly summary ?: boolean ;
179177 readonly reasonClass ?: string ;
@@ -204,8 +202,8 @@ export async function startHostedPairingHost(
204202 const archive = options . getUiArchive
205203 ? await options . getUiArchive ( )
206204 : createMinimalUiArchive ( ) ;
207- const generations = new HostedGenerationSet ( ) ;
208- const context = { archive, options, generations } ;
205+ const livePeers = new HostedLivePeerRegistry ( ) ;
206+ const context = { archive, options, livePeers } ;
209207 const joinQueue = createHandshakeJoinQueue ( ) ;
210208 let handshakeGeneration = 0 ;
211209 let handshake :
@@ -234,14 +232,30 @@ export async function startHostedPairingHost(
234232 clearTimeout ( pairingRefreshTimer ) ;
235233 clearTimeout ( deviceRefreshTimer ) ;
236234 handshake ?. peer . close ( ) ;
237- generations . closeAll ( ) ;
235+ await livePeers . closeAll ( ) ;
238236 pairingGeneration += 1 ;
239237 deviceGeneration += 1 ;
240238 closeSocket ( pairingSocket ) ;
241239 closeSocket ( deviceSocket ) ;
242240 } ;
243241
244242 async function addHandshakePeer ( socket : WebSocket , scope : SignalScope ) : Promise < void > {
243+ // A device gets one live peer. Retire the connection this join replaces
244+ // before its replacement exists, so the two never overlap and a late
245+ // teardown cannot reach the new session's terminals.
246+ if ( scope . kind === 'device' ) {
247+ const replaced = await livePeers . close ( scope . deviceId ) ;
248+ if ( replaced !== undefined ) {
249+ diagnose ( { type : 'peer-closed' , reasonClass : 'replaced-by-rejoin' } ) ;
250+ // Report the retirement here rather than relying on the native
251+ // datachannel emitting `close` before its peer is torn down. That
252+ // event is not guaranteed to arrive, and a missed one would leave a
253+ // replaced connection listed as live for the rest of the session.
254+ if ( replaced . connectionId !== undefined ) {
255+ options . onPeerDisconnected ?.( replaced . connectionId ) ;
256+ }
257+ }
258+ }
245259 const generation = ++ handshakeGeneration ;
246260 if ( handshake ) {
247261 handshake . peer . close ( ) ;
@@ -253,10 +267,18 @@ export async function startHostedPairingHost(
253267 scope ,
254268 context ,
255269 ( connection , peer ) => {
256- if ( handshake ?. peer === next ) {
257- generations . add ( { peer : next , connection } ) ;
258- handshake = undefined ;
270+ // A newer join replaced this handshake, or the host stopped, while
271+ // this one was authenticating. Retire it rather than leaving an
272+ // untracked live connection for the device: nothing would close it,
273+ // and it is exactly the superseded generation this design removes.
274+ if ( handshake ?. peer !== next || closed ) {
275+ void connection . close ( ) ;
276+ next . close ( ) ;
277+ diagnose ( { type : 'peer-closed' , reasonClass : 'replaced-by-rejoin' } ) ;
278+ return ;
259279 }
280+ livePeers . set ( peer . deviceId , { peer : next , connection, connectionId : peer . connectionId } ) ;
281+ handshake = undefined ;
260282 options . onPeerConnected ?.( peer ) ;
261283 if ( scope . kind === 'pairing' ) {
262284 clearTimeout ( pairingRefreshTimer ) ;
@@ -266,8 +288,8 @@ export async function startHostedPairingHost(
266288 pairingRefreshTimer . unref ?.( ) ;
267289 }
268290 } ,
269- ( retired ) => {
270- generations . drop ( retired ) ;
291+ ( deviceId , retired ) => {
292+ if ( deviceId !== undefined ) livePeers . drop ( deviceId , retired ) ;
271293 } ,
272294 ) ;
273295 if ( closed || generation !== handshakeGeneration ) {
@@ -742,11 +764,11 @@ async function startPeer(
742764 scope : SignalScope ,
743765 context : Readonly < {
744766 archive : MinimalArchive ;
745- generations : HostedGenerationSet ;
767+ livePeers : HostedLivePeerRegistry ;
746768 options : HostedPairingHostOptions ;
747769 } > ,
748770 onApplication : ( connection : ServerConnectionLike , peer : HostedConnectedPeer ) => void ,
749- onRetire : ( peer : WeriftPeer ) => void ,
771+ onRetire : ( deviceId : string | undefined , peer : WeriftPeer ) => void ,
750772) : Promise < WeriftPeer > {
751773 const native = new Peer (
752774 hostedPeerConfiguration (
@@ -760,15 +782,10 @@ async function startPeer(
760782 let wrapped : WeriftPeer ;
761783 const stream = createHostedStreamDiagnostics ( {
762784 emit : ( event ) => {
763- const enriched = {
785+ context . options . onDiagnostic ?. ( {
764786 ...event ,
765- liveGenerationCount : context . generations . size ,
766- ...( event . stallClass === undefined
767- ? { }
768- : { stallIgnored : ! shouldFailHostedStall ( event ) } ) ,
769- } ;
770- context . options . onDiagnostic ?.( enriched ) ;
771- applyHostedLaneDiagnostic ( lifecycle , enriched ) ;
787+ liveGenerationCount : context . livePeers . size ,
788+ } ) ;
772789 } ,
773790 } ) ;
774791 lifecycle = new HostedPeerLifecycle (
@@ -777,7 +794,7 @@ async function startPeer(
777794 ( reason ) => {
778795 stream . peerClosed ( reason ) ;
779796 const connectionId = session . peer ?. connectionId ;
780- onRetire ( wrapped ) ;
797+ onRetire ( session . peer ?. deviceId , wrapped ) ;
781798 void session . connection ?. close ( ) ;
782799 try {
783800 native . close ( ) ;
@@ -807,12 +824,14 @@ async function startPeer(
807824 } ) ;
808825 for ( const label of CHANNELS ) {
809826 const channel = channels [ label ] ! ;
827+ // A lane that never opened is still negotiating; only a lane that has
828+ // carried traffic and then left `open` proves this peer cannot deliver.
829+ let everOpened = false ;
810830 const emitState = ( ) => {
811- stream . channelState ( label , channel . readyState ) ;
812- applyHostedLaneDiagnostic ( lifecycle , {
813- channel : label ,
814- channelState : channel . readyState ,
815- } ) ;
831+ if ( channel . readyState === 'open' ) everOpened = true ;
832+ const hangup = requiredLaneClosed ( label , channel . readyState , everOpened ) ;
833+ stream . channelState ( label , channel . readyState , hangup ) ;
834+ if ( hangup ) lifecycle . fail ( `WebRTC ${ label } lane ${ channel . readyState } .` ) ;
816835 } ;
817836 channel . addEventListener ( 'open' , emitState ) ;
818837 channel . addEventListener ( 'close' , emitState ) ;
@@ -1177,7 +1196,11 @@ function bindControl(
11771196 deviceName : device ?. deviceName ?. trim ( ) || 'Browser' ,
11781197 } ) ;
11791198 onApplication ( connection , peer ) ;
1199+ // The application lane closing ends this generation. Releasing the
1200+ // server connection here frees exactly this connection's attachments;
1201+ // the peer-level required-lane handler retires the peer itself.
11801202 application . addEventListener ( 'close' , ( ) => {
1203+ void connection . close ( ) ;
11811204 context . options . onPeerDisconnected ?.( peer . connectionId ) ;
11821205 } ) ;
11831206 void connection . start ( ) . catch ( ( error ) => {
0 commit comments