Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<Compile Include="../PolyPilot/Services/NotificationMessageBuilder.cs" Link="Shared/NotificationMessageBuilder.cs" />
<Compile Include="../PolyPilot/Services/IChatDatabase.cs" Link="Shared/IChatDatabase.cs" />
<Compile Include="../PolyPilot/Services/IServerManager.cs" Link="Shared/IServerManager.cs" />
<Compile Include="../PolyPilot/Services/ServerManager.cs" Link="Shared/ServerManager.cs" />
<Compile Include="../PolyPilot/Services/IWsBridgeClient.cs" Link="Shared/IWsBridgeClient.cs" />
<Compile Include="../PolyPilot/Services/IDemoService.cs" Link="Shared/IDemoService.cs" />
<Compile Include="../PolyPilot/Services/INotificationManagerService.cs" Link="Shared/INotificationManagerService.cs" />
Expand Down
90 changes: 90 additions & 0 deletions PolyPilot.Tests/ServerManagerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Net;
using System.Net.Sockets;
using PolyPilot.Services;

namespace PolyPilot.Tests;

/// <summary>
/// Tests for ServerManager.CheckServerRunning to verify socket exceptions
/// are properly observed and don't cause UnobservedTaskException crashes.
/// </summary>
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<UnobservedTaskExceptionEventArgs> 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);
}
}
11 changes: 3 additions & 8 deletions PolyPilot/Services/ServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 2 additions & 0 deletions PolyPilot/Services/WsBridgeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down