diff --git a/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Discovery/LoopbackTransportTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Discovery/LoopbackTransportTests.cs
index ebd7da87b..4594a8448 100644
--- a/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Discovery/LoopbackTransportTests.cs
+++ b/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Discovery/LoopbackTransportTests.cs
@@ -1,9 +1,10 @@
+using System.Globalization;
using System.Net;
using System.Net.Sockets;
using OpenCode.Sdk.Internal;
using OpenCode.Sdk.Tests.Hosting.BackgroundService.Support;
-using OpenCode.Sdk.Tests.Support;
#if !NET
+using OpenCode.Sdk.Tests.Support;
using TUnit.Assertions.Enums;
#endif
@@ -22,6 +23,13 @@ namespace OpenCode.Sdk.Tests.Hosting.BackgroundService.Discovery;
///
public sealed class LoopbackTransportTests
{
+ /// The first port the closed endpoint tries; below every platform's ephemeral range.
+ private const int FirstClosedPortCandidate = 20000;
+
+ private const int ClosedPortCandidates = 1000;
+
+ private static int s_lastClosedPortCandidate = FirstClosedPortCandidate - 1;
+
[Test]
[Arguments("http://127.0.0.1:4096/")]
[Arguments("http://[::1]:4096/")]
@@ -71,7 +79,7 @@ public async Task CreateHandler_Should_Keep_The_Default_Connect_For_Another_Endp
[Test]
public async Task CreateHandler_Should_Connect_A_Windows_Loopback_Exchange_On_A_Socket_That_Took_The_Option()
{
- var endpoint = await ClosedEndpointAsync();
+ var endpoint = ClosedEndpoint();
var connector = new RecordingLoopbackConnector();
using var handler = new LoopbackTransport(connector).CreateHandler(endpoint);
using var invoker = new HttpMessageInvoker(handler, disposeHandler: false);
@@ -100,7 +108,7 @@ public async Task CreateHandler_Should_Connect_A_Windows_Loopback_Exchange_On_A_
[Test]
public async Task IsListeningAsync_Should_Report_A_Refused_Loopback_Port_As_Not_Listening()
{
- var endpoint = await ClosedEndpointAsync();
+ var endpoint = ClosedEndpoint();
var connector = new RecordingLoopbackConnector();
var listening = await new LoopbackTransport(connector).IsListeningAsync(endpoint, CancellationToken.None);
@@ -142,7 +150,7 @@ public async Task IsListeningAsync_Should_Connect_Without_The_Token()
[Test]
public async Task IsListeningAsync_Should_Report_A_Refusal_That_Completes_While_The_Token_Is_Cancelled_As_Not_Listening()
{
- var endpoint = await ClosedEndpointAsync();
+ var endpoint = ClosedEndpoint();
using var bound = new CancellationTokenSource();
var connector = new ScriptedLoopbackConnector(bound, new SocketException((int)SocketError.ConnectionRefused));
@@ -159,7 +167,7 @@ public async Task IsListeningAsync_Should_Report_A_Refusal_That_Completes_While_
[MethodDataSource(nameof(ClosedSocketFailures))]
public async Task IsListeningAsync_Should_Throw_The_Token_Cancellation_For_A_Connect_The_Token_Cut_Short(Exception failure)
{
- var endpoint = await ClosedEndpointAsync();
+ var endpoint = ClosedEndpoint();
using var bound = new CancellationTokenSource();
var connector = new ScriptedLoopbackConnector(bound, failure);
@@ -251,11 +259,42 @@ public async Task DisableSynRetransmission_Should_Be_Accepted_On_Windows_And_Not
private static LoopbackTransport PlatformTransport() => new(new LoopbackSocketConnector());
- /// A loopback port that was listening a moment ago and is closed now.
- private static async Task ClosedEndpointAsync()
+ ///
+ /// A loopback port nothing listens on, found free by a bind and released. It lies below every
+ /// platform's ephemeral range (Windows and macOS from 49152, Linux from 32768), so no parallel
+ /// test binding port 0 can be handed it and turn the refusal into a connect. A port a disposed
+ /// server had just released from that range could be (#421). Every call takes its own
+ /// candidates, so a parallel caller is never binding the port this one connects to: macOS times
+ /// a bound, non-listening port out instead of refusing it.
+ ///
+ private static Uri ClosedEndpoint()
{
- await using var server = LoopbackHttpServer.Start(static _ => new LoopbackHttpResponse { StatusCode = HttpStatusCode.OK });
- return server.Endpoint;
+ for (var attempt = 0; attempt < ClosedPortCandidates; attempt++)
+ {
+ var port = Interlocked.Increment(ref s_lastClosedPortCandidate);
+ if (IsFree(port))
+ {
+ return new Uri($"http://127.0.0.1:{port.ToString(CultureInfo.InvariantCulture)}");
+ }
+ }
+
+ throw new InvalidOperationException("No free loopback port below the ephemeral ranges was found for the closed endpoint.");
+ }
+
+ /// Whether a bind to the loopback port succeeds; the probe socket is released at once.
+ private static bool IsFree(int port)
+ {
+ using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ try
+ {
+ probe.Bind(new IPEndPoint(IPAddress.Loopback, port));
+ return true;
+ }
+ catch (SocketException exception) when (exception.SocketErrorCode is SocketError.AddressAlreadyInUse or SocketError.AccessDenied)
+ {
+ // Taken, or inside a range Windows has excluded (WinNAT, Hyper-V); any other fault propagates.
+ return false;
+ }
}
private static bool UseProxy(HttpMessageHandler handler) => handler switch