@@ -420,25 +420,32 @@ public async Task HandleIncomingPairHandshakeAsync(WebSocket ws, string remoteIp
420420
421421 // Capture the TCS before releasing the lock
422422 TaskCompletionSource < bool > tcs ;
423+ bool isDuplicate ;
423424 lock ( _stateLock )
424425 {
425- if ( _pendingPairRequests . Count >= 1 )
426+ isDuplicate = _pendingPairRequests . Count >= 1 ;
427+ if ( ! isDuplicate )
426428 {
427- // Already handling a pair request — deny immediately.
428- // Await the send so the message is delivered before the socket is dropped.
429- _ = Task . Run ( async ( ) =>
430- {
431- try
432- {
433- await SendAsync ( ws , BridgeMessage . Create ( BridgeMessageTypes . FiestaPairResponse ,
434- new FiestaPairResponsePayload { RequestId = req . RequestId , Approved = false } ) , ct ) ;
435- }
436- catch { }
437- } ) ;
438- return ;
429+ _pendingPairRequests [ req . RequestId ] = pending ;
430+ tcs = pending . CompletionSource ;
439431 }
440- _pendingPairRequests [ req . RequestId ] = pending ;
441- tcs = pending . CompletionSource ;
432+ else
433+ {
434+ tcs = null ! ; // won't be used
435+ }
436+ }
437+
438+ if ( isDuplicate )
439+ {
440+ // Already handling a pair request — deny inline so the send completes
441+ // before this method returns and the caller closes the socket.
442+ try
443+ {
444+ await SendAsync ( ws , BridgeMessage . Create ( BridgeMessageTypes . FiestaPairResponse ,
445+ new FiestaPairResponsePayload { RequestId = req . RequestId , Approved = false } ) , ct ) ;
446+ }
447+ catch { }
448+ return ;
442449 }
443450
444451 OnPairRequested ? . Invoke ( req . RequestId , req . HostName , remoteIp ) ;
@@ -450,6 +457,9 @@ await SendAsync(ws, BridgeMessage.Create(BridgeMessageTypes.FiestaPairResponse,
450457 try
451458 {
452459 await tcs . Task . WaitAsync ( expiryCts . Token ) ;
460+ // Winner's send is in-flight — wait for it to complete before returning so the
461+ // caller's finally (socket close) doesn't race the outgoing message.
462+ await pending . SendComplete . Task . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ;
453463 }
454464 catch ( OperationCanceledException )
455465 {
@@ -463,8 +473,16 @@ await SendAsync(ws, BridgeMessage.Create(BridgeMessageTypes.FiestaPairResponse,
463473 new FiestaPairResponsePayload { RequestId = req . RequestId , Approved = false } ) , CancellationToken . None ) ;
464474 }
465475 catch { }
476+ finally
477+ {
478+ pending . SendComplete . TrySetResult ( ) ;
479+ }
480+ }
481+ else
482+ {
483+ // Approve already won — wait for its send to finish before closing socket
484+ try { await pending . SendComplete . Task . WaitAsync ( TimeSpan . FromSeconds ( 5 ) ) ; } catch { }
466485 }
467- // else: Approve already won the race — skip sending a deny
468486 }
469487 finally
470488 {
@@ -511,6 +529,12 @@ await SendAsync(pending.Socket, BridgeMessage.Create(
511529 Console . WriteLine ( $ "[Fiesta] Failed to send pair approval: { ex . Message } ") ;
512530 return false ;
513531 }
532+ finally
533+ {
534+ // Signal that our send is complete so HandleIncomingPairHandshakeAsync
535+ // can safely return (allowing the caller to close the socket).
536+ pending . SendComplete . TrySetResult ( ) ;
537+ }
514538 }
515539
516540 public async Task DenyPairRequestAsync ( string requestId )
@@ -527,7 +551,6 @@ public async Task DenyPairRequestAsync(string requestId)
527551 if ( ! tcs . TrySetResult ( false ) )
528552 return ; // approve already won, don't race on the socket
529553
530- // Send before anything unblocks HandleIncomingPairHandshakeAsync.
531554 try
532555 {
533556 await SendAsync ( pending . Socket , BridgeMessage . Create (
@@ -536,7 +559,11 @@ await SendAsync(pending.Socket, BridgeMessage.Create(
536559 CancellationToken . None ) ;
537560 }
538561 catch { }
539- // TCS was already resolved above — HandleIncomingPairHandshakeAsync's await unblocks here.
562+ finally
563+ {
564+ // Signal send complete so HandleIncomingPairHandshakeAsync can safely return.
565+ pending . SendComplete . TrySetResult ( ) ;
566+ }
540567 }
541568
542569 // Keep a synchronous shim for callers that can't await (e.g., Blazor @onclick non-async)
@@ -605,6 +632,7 @@ await SendAsync(ws, BridgeMessage.Create(
605632 var result = await ws . ReceiveAsync ( buffer , ct ) ;
606633 if ( result . MessageType == WebSocketMessageType . Close ) return null ;
607634 sb . Append ( Encoding . UTF8 . GetString ( buffer , 0 , result . Count ) ) ;
635+ if ( sb . Length > 256 * 1024 ) return null ; // guard against unbounded frames on unauthenticated /pair path
608636 if ( result . EndOfMessage ) break ;
609637 }
610638 return BridgeMessage . Deserialize ( sb . ToString ( ) ) ;
0 commit comments