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();