diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 3c21127246..9897419368 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -39,6 +39,7 @@ + diff --git a/PolyPilot.Tests/ServerManagerTests.cs b/PolyPilot.Tests/ServerManagerTests.cs new file mode 100644 index 0000000000..61bf174a17 --- /dev/null +++ b/PolyPilot.Tests/ServerManagerTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Sockets; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +/// +/// Tests for ServerManager.CheckServerRunning to verify socket exceptions +/// are properly observed and don't cause UnobservedTaskException crashes. +/// +public class ServerManagerTests +{ + [Fact] + public void CheckServerRunning_ReturnsFalse_WhenNoServerListening() + { + var manager = new ServerManager(); + // Port 19999 should not have a listener in test environment + var result = manager.CheckServerRunning("localhost", 19999); + Assert.False(result); + } + + [Fact] + public void CheckServerRunning_ReturnsTrue_WhenServerListening() + { + // Start a temporary TCP listener + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var manager = new ServerManager(); + var result = manager.CheckServerRunning("localhost", port); + + Assert.True(result); + listener.Stop(); + } + + [Fact] + public void CheckServerRunning_NoUnobservedTaskException_OnConnectionRefused() + { + // Verify the fix: CheckServerRunning on a non-listening port must NOT leave + // unobserved Task exceptions that fire TaskScheduler.UnobservedTaskException. + using var unobservedSignal = new ManualResetEventSlim(false); + Exception? unobservedException = null; + EventHandler handler = (sender, args) => + { + if (args.Exception?.InnerException is SocketException) + { + unobservedException = args.Exception; + unobservedSignal.Set(); + } + }; + + TaskScheduler.UnobservedTaskException += handler; + try + { + var manager = new ServerManager(); + for (int i = 0; i < 5; i++) + { + manager.CheckServerRunning("localhost", 19999); + } + + // Force GC to finalize any abandoned Tasks + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // If any unobserved Task exception exists, the finalizer will signal within the window. + // With the fix in place, no such tasks are left and the signal stays unset. + unobservedSignal.Wait(TimeSpan.FromMilliseconds(500)); + Assert.Null(unobservedException); + } + finally + { + TaskScheduler.UnobservedTaskException -= handler; + } + } + + [Fact] + public void CheckServerRunning_DefaultPort_UsesServerPort() + { + var manager = new ServerManager(); + // The no-arg overload should use ServerPort (4321). We can't predict the result + // (persistent server may or may not be running), but we verify it doesn't throw + // and returns a valid result, then confirm the explicit-port overload also works. + var defaultResult = manager.CheckServerRunning(); + var customResult = manager.CheckServerRunning("localhost", 19998); + Assert.True(defaultResult || !defaultResult); // completed without throwing + Assert.False(customResult); + } +} diff --git a/PolyPilot/Services/ServerManager.cs b/PolyPilot/Services/ServerManager.cs index dcaf7df443..d951154cd7 100644 --- a/PolyPilot/Services/ServerManager.cs +++ b/PolyPilot/Services/ServerManager.cs @@ -35,14 +35,9 @@ public bool CheckServerRunning(string host = "localhost", int? port = null) try { using var client = new TcpClient(); - var result = client.BeginConnect(host, port.Value, null, null); - var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(1)); - if (success && client.Connected) - { - client.EndConnect(result); - return true; - } - return false; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + client.ConnectAsync(host, port.Value, cts.Token).AsTask().GetAwaiter().GetResult(); + return true; } catch { diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index c5739c2e4e..d484580e8f 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -122,6 +122,8 @@ public async Task ConnectAsync(string wsUrl, string? authToken = null, Cancellat if (completed == timeoutTask) { Console.WriteLine($"[WsBridgeClient] Connection timed out after 15s!"); + // Observe the abandoned connectTask's exception to prevent UnobservedTaskException + _ = connectTask.ContinueWith(static t => { _ = t.Exception; }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); invoker?.Dispose(); _ws.Dispose(); _ws = new ClientWebSocket();