Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -22,6 +23,13 @@ namespace OpenCode.Sdk.Tests.Hosting.BackgroundService.Discovery;
/// </summary>
public sealed class LoopbackTransportTests
{
/// <summary>The first port the closed endpoint tries; below every platform's ephemeral range.</summary>
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/")]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));

Expand All @@ -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);

Expand Down Expand Up @@ -251,11 +259,42 @@ public async Task DisableSynRetransmission_Should_Be_Accepted_On_Windows_And_Not

private static LoopbackTransport PlatformTransport() => new(new LoopbackSocketConnector());

/// <summary>A loopback port that was listening a moment ago and is closed now.</summary>
private static async Task<Uri> ClosedEndpointAsync()
/// <summary>
/// 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.
/// </summary>
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.");
}

/// <summary>Whether a bind to the loopback port succeeds; the probe socket is released at once.</summary>
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
Expand Down
Loading