From 45f1620318c7e6f5312be129026db65d29488cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 17 Oct 2026 08:32:20 +0300 Subject: [PATCH] test: the loopback probe's closed endpoint is a port no parallel test can be handed (#421) ClosedEndpointAsync disposed a server and returned its released ephemeral port, which a parallel test binding port 0 could be handed before the probe connected, so a refusal turned into a connect. The closed endpoint is now a free port below every platform's ephemeral range (Windows/macOS 49152+, Linux 32768+). --- .../Discovery/LoopbackTransportTests.cs | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) 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