Skip to content

Commit 7d3734f

Browse files
PureWeenCopilot
andcommitted
fix: PR #322 round 3 -- SendComplete guard, inline deny, 256KB size limit, test isolation
- ApprovePairRequestAsync and DenyPairRequestAsync both set pending.SendComplete in finally block after their send, so HandleIncomingPairHandshakeAsync can await it before returning (prevents caller's finally from closing the socket while a send is in-flight) - Duplicate-request deny is now awaited inline instead of Task.Run fire-and-forget; both the lock check and the SendAsync happen outside the lock to avoid re-entrance - ReadSingleMessageAsync: added 256KB limit to guard unauthenticated /pair path against OOM - FiestaPairingTests constructor sets _bridgeServer.ServerPassword = 'test-token-isolation' so EnsureServerPassword() never falls through to ConnectionSettings.Load()/Save() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b865a92 commit 7d3734f

3 files changed

Lines changed: 52 additions & 18 deletions

File tree

PolyPilot.Tests/FiestaPairingTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public class FiestaPairingTests : IDisposable
2323
public FiestaPairingTests()
2424
{
2525
_bridgeServer = new WsBridgeServer();
26+
// Pre-set the server password so EnsureServerPassword() never falls through to
27+
// ConnectionSettings.Load()/Save(), which would touch the real ~/.polypilot/settings.json.
28+
_bridgeServer.ServerPassword = "test-token-isolation";
2629
_copilot = new CopilotService(
2730
new StubChatDatabase(),
2831
new StubServerManager(),

PolyPilot/Models/FiestaModels.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,8 @@ internal class PendingPairRequest
9898
public string RemoteIp { get; set; } = "";
9999
public WebSocket Socket { get; set; } = null!;
100100
public TaskCompletionSource<bool> CompletionSource { get; set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
101+
/// <summary>Resolved by the winner after its SendAsync completes, so HandleIncomingPairHandshakeAsync
102+
/// can wait for the send to finish before returning (which lets the caller close the socket safely).</summary>
103+
public TaskCompletionSource SendComplete { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
101104
public DateTime ExpiresAt { get; set; }
102105
}

PolyPilot/Services/FiestaService.cs

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)