From 8c0f515f939c2b642ad4a3b6833ed538315eddd2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:33:40 +0100 Subject: [PATCH 1/4] fix(floci): select Service Bus host ports below the ephemeral range The Service Bus endpoint allocator took OS-assigned ephemeral ports and released them before Docker bound them. DCP allocates project proxy ports from the same range and could receive the same port. On Windows the proxy's loopback listener then shadows Docker's wildcard bind, so AMQP clients reach the proxy instead of the broker; on Linux Docker's bind fails and the broker never starts. Select random free ports in 20000-32767 instead, below every OS ephemeral range. Explicit ports are unchanged. Fixes #2240 Co-Authored-By: Claude Opus 5.5 --- .../FlociServiceBusEndpointAllocator.cs | 57 +++++++++++++++++-- .../AzureServiceBusResourceTests.cs | 11 +++- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs index 399956e41..59b9a3db3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs @@ -9,6 +9,14 @@ namespace CommunityToolkit.Aspire.Hosting.Floci; internal sealed class FlociServiceBusEndpointAllocator : IDistributedApplicationEventingSubscriber { + // Random host ports are taken from below every OS ephemeral range (Linux starts at 32768, + // Windows and macOS at 49152). An OS-assigned port is released before Docker binds it, and DCP + // can hand it to a project proxy in the meantime: on Windows the proxy's loopback listener then + // shadows Docker's wildcard bind, and on Linux Docker's bind fails. + private const int MinSelectedPort = 20000; + private const int MaxSelectedPortExclusive = 32768; + private const int MaxSelectionAttempts = 100; + public Task SubscribeAsync( IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, @@ -88,11 +96,48 @@ await eventing.PublishAsync( private static int SelectPort(List listeners) { - // Match Docker's bind address, as in the k3s allocator. This probes availability; - // it cannot reserve the port through Docker startup, which needs the socket released. - var listener = new TcpListener(IPAddress.Any, 0); - listeners.Add(listener); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; + for (int attempt = 0; attempt < MaxSelectionAttempts; attempt++) + { + int port = Random.Shared.Next(MinSelectedPort, MaxSelectedPortExclusive); + + // Match Docker's bind address, as in the k3s allocator, and also probe loopback, where + // another process's listener would shadow Docker's wildcard bind. This probes availability; + // it cannot reserve the port through Docker startup, which needs the socket released. + if (!IsAvailable(IPAddress.Loopback, port)) + { + continue; + } + + var listener = new TcpListener(IPAddress.Any, port); + try + { + listener.Start(); + } + catch (SocketException) + { + listener.Dispose(); + continue; + } + + listeners.Add(listener); + return port; + } + + throw new DistributedApplicationException( + $"No free Service Bus host port was found in {MinSelectedPort}-{MaxSelectedPortExclusive - 1}."); + } + + private static bool IsAvailable(IPAddress address, int port) + { + using var listener = new TcpListener(address, port); + try + { + listener.Start(); + return true; + } + catch (SocketException) + { + return false; + } } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs index 90196287d..f039b3460 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs @@ -95,8 +95,6 @@ await builder.Eventing.PublishAsync( .GetValueAsync(TestContext.Current.CancellationToken); string? amqpTlsPort = await serviceBus.Resource.AmqpTlsEndpoint.Property(EndpointProperty.Port) .GetValueAsync(TestContext.Current.CancellationToken); - Assert.InRange(int.Parse(amqpPort!), 1, 65535); - Assert.InRange(int.Parse(amqpTlsPort!), 1, 65535); Assert.NotEqual(amqpPort, amqpTlsPort); foreach (var endpoint in new[] { serviceBus.Resource.AmqpEndpoint, serviceBus.Resource.AmqpTlsEndpoint }) { @@ -110,10 +108,19 @@ await builder.Eventing.PublishAsync( { Assert.Equal(configuredAmqpPort.Value.ToString(), amqpPort); } + else + { + // Selected ports stay below every OS ephemeral range, where DCP allocates proxy ports. + Assert.InRange(int.Parse(amqpPort!), 20000, 32767); + } if (configuredAmqpTlsPort.HasValue) { Assert.Equal(configuredAmqpTlsPort.Value.ToString(), amqpTlsPort); } + else + { + Assert.InRange(int.Parse(amqpTlsPort!), 20000, 32767); + } var resource = Assert.Single(appModel.Resources.OfType()); Assert.True(resource.TryGetAnnotationsOfType(out IEnumerable? envAnnotations)); From cf78e8a1d00a8ab9be2c94d5ba1734bdb80c890c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:45:44 +0100 Subject: [PATCH 2/4] Skip app-model configured ports when selecting Floci Service Bus ports A port configured explicitly on any endpoint (including one of the two Service Bus endpoints) is bound later, so the availability probe cannot see it. Exclude every configured app-model port from selection, and scan the whole range from a random offset instead of a fixed number of random attempts. Co-Authored-By: Claude Opus 5.5 --- .../FlociServiceBusEndpointAllocator.cs | 23 ++++++++++---- .../AzureServiceBusResourceTests.cs | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs index 59b9a3db3..7dea6106e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs @@ -15,7 +15,6 @@ internal sealed class FlociServiceBusEndpointAllocator : IDistributedApplication // shadows Docker's wildcard bind, and on Linux Docker's bind fails. private const int MinSelectedPort = 20000; private const int MaxSelectedPortExclusive = 32768; - private const int MaxSelectionAttempts = 100; public Task SubscribeAsync( IDistributedApplicationEventing eventing, @@ -38,6 +37,12 @@ private static async Task AllocateEndpointsAsync( // DCP does not allocate endpoints on these custom resources. Allocate before the // parent's environment is evaluated, otherwise resolving its child's ports blocks startup. var listeners = new List(); + // Ports configured anywhere in the app model are bound later, so probing cannot see them. + var reservedPorts = @event.Model.Resources + .SelectMany(resource => resource.Annotations.OfType()) + .Select(endpoint => endpoint.Port) + .OfType() + .ToHashSet(); var allocatedResources = new List(); try { @@ -60,7 +65,7 @@ private static async Task AllocateEndpointsAsync( continue; } - endpoint.Port ??= SelectPort(listeners); + endpoint.Port ??= SelectPort(listeners, reservedPorts); endpoint.TargetPort = endpoint.Port; endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, KnownHostNames.Localhost, endpoint.Port.Value, EndpointBindingMode.SingleAddress, null, KnownNetworkIdentifiers.LocalhostNetwork); @@ -94,11 +99,18 @@ await eventing.PublishAsync( } } - private static int SelectPort(List listeners) + internal static int SelectPort(List listeners, HashSet reservedPorts) { - for (int attempt = 0; attempt < MaxSelectionAttempts; attempt++) + // Start at a random offset so concurrent AppHosts spread out, then scan the whole range. + int rangeSize = MaxSelectedPortExclusive - MinSelectedPort; + int offset = Random.Shared.Next(rangeSize); + for (int i = 0; i < rangeSize; i++) { - int port = Random.Shared.Next(MinSelectedPort, MaxSelectedPortExclusive); + int port = MinSelectedPort + (offset + i) % rangeSize; + if (reservedPorts.Contains(port)) + { + continue; + } // Match Docker's bind address, as in the k3s allocator, and also probe loopback, where // another process's listener would shadow Docker's wildcard bind. This probes availability; @@ -120,6 +132,7 @@ private static int SelectPort(List listeners) } listeners.Add(listener); + reservedPorts.Add(port); return port; } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs index f039b3460..ff49a4bc8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs @@ -144,6 +144,36 @@ await builder.Eventing.PublishAsync( .GetValueAsync(TestContext.Current.CancellationToken)); } + [Fact] + public void SelectPortSkipsReservedPorts() + { + // Leave exactly one free, unreserved port in the selection range. + int freePort = Enumerable.Range(20000, 12768).First(port => + { + using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Any, port); + try + { + probe.Start(); + return true; + } + catch (System.Net.Sockets.SocketException) + { + return false; + } + }); + var reserved = Enumerable.Range(20000, 12768).Where(port => port != freePort).ToHashSet(); + var listeners = new List(); + try + { + Assert.Equal(freePort, FlociServiceBusEndpointAllocator.SelectPort(listeners, reserved)); + Assert.Contains(freePort, reserved); + } + finally + { + listeners.ForEach(listener => listener.Stop()); + } + } + [Fact] public async Task WithServiceBusDoesNotConfigureTheDataPlaneInPublishMode() { From c54a8fa7198e04ab82ceb917d2a76d9cc70a19d1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:59:44 +0100 Subject: [PATCH 3/4] Probe IPv6 loopback when selecting Floci Service Bus ports Docker also binds [::]:, and a process listening on ::1 shadows it for IPv6 localhost connections, as on IPv4 loopback. Probe ::1 too when the OS supports IPv6. Co-Authored-By: Claude Opus 5.5 --- .../FlociServiceBusEndpointAllocator.cs | 8 ++-- .../AzureServiceBusResourceTests.cs | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs index 7dea6106e..73f298e6f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs @@ -101,7 +101,8 @@ await eventing.PublishAsync( internal static int SelectPort(List listeners, HashSet reservedPorts) { - // Start at a random offset so concurrent AppHosts spread out, then scan the whole range. + // Start at a random offset so concurrent AppHosts spread out, then scan the whole range so a + // free port is always found when one exists. Reserved ports are skipped without probing. int rangeSize = MaxSelectedPortExclusive - MinSelectedPort; int offset = Random.Shared.Next(rangeSize); for (int i = 0; i < rangeSize; i++) @@ -112,10 +113,11 @@ internal static int SelectPort(List listeners, HashSet reserve continue; } - // Match Docker's bind address, as in the k3s allocator, and also probe loopback, where + // Match Docker's bind address, as in the k3s allocator, and also probe IPv4 and IPv6 loopback, where // another process's listener would shadow Docker's wildcard bind. This probes availability; // it cannot reserve the port through Docker startup, which needs the socket released. - if (!IsAvailable(IPAddress.Loopback, port)) + if (!IsAvailable(IPAddress.Loopback, port) + || (Socket.OSSupportsIPv6 && !IsAvailable(IPAddress.IPv6Loopback, port))) { continue; } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs index ff49a4bc8..d70b0002f 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs @@ -174,6 +174,44 @@ public void SelectPortSkipsReservedPorts() } } + [Fact] + public void SelectPortSkipsPortsHeldOnIPv6Loopback() + { + Assert.SkipUnless(System.Net.Sockets.Socket.OSSupportsIPv6, "IPv6 is not available."); + + // Hold one port on ::1 only, and leave it plus one other free port unreserved. + var free = Enumerable.Range(20000, 12768).Where(IsFreeEverywhere).Take(2).ToArray(); + using var ipv6Holder = new System.Net.Sockets.TcpListener(System.Net.IPAddress.IPv6Loopback, free[0]); + ipv6Holder.Start(); + var reserved = Enumerable.Range(20000, 12768).Where(port => !free.Contains(port)).ToHashSet(); + var listeners = new List(); + try + { + Assert.Equal(free[1], FlociServiceBusEndpointAllocator.SelectPort(listeners, reserved)); + } + finally + { + listeners.ForEach(listener => listener.Stop()); + } + } + + private static bool IsFreeEverywhere(int port) + { + foreach (var address in new[] { System.Net.IPAddress.Any, System.Net.IPAddress.IPv6Loopback }) + { + using var probe = new System.Net.Sockets.TcpListener(address, port); + try + { + probe.Start(); + } + catch (System.Net.Sockets.SocketException) + { + return false; + } + } + return true; + } + [Fact] public async Task WithServiceBusDoesNotConfigureTheDataPlaneInPublishMode() { From ef2af1da65c0b82bd036e3f19a13ee6f9bd10d77 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:31:49 +0100 Subject: [PATCH 4/4] Reserve already-allocated endpoint ports when selecting Floci Service Bus ports Co-Authored-By: Claude Opus 5.5 --- .../FlociServiceBusEndpointAllocator.cs | 19 +++++++++++++------ .../AzureServiceBusResourceTests.cs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs index 73f298e6f..0991af23f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociServiceBusEndpointAllocator.cs @@ -37,12 +37,7 @@ private static async Task AllocateEndpointsAsync( // DCP does not allocate endpoints on these custom resources. Allocate before the // parent's environment is evaluated, otherwise resolving its child's ports blocks startup. var listeners = new List(); - // Ports configured anywhere in the app model are bound later, so probing cannot see them. - var reservedPorts = @event.Model.Resources - .SelectMany(resource => resource.Annotations.OfType()) - .Select(endpoint => endpoint.Port) - .OfType() - .ToHashSet(); + var reservedPorts = GetReservedPorts(@event.Model); var allocatedResources = new List(); try { @@ -99,6 +94,18 @@ await eventing.PublishAsync( } } + internal static HashSet GetReservedPorts(DistributedApplicationModel model) + { + // Ports configured anywhere in the app model are bound later, so probing cannot see them. + // Both reads are synchronous: Port is the configured value and AllocatedEndpoint is null until + // allocation completes, so endpoints that are still being allocated never block startup. + return model.Resources + .SelectMany(resource => resource.Annotations.OfType()) + .SelectMany(endpoint => new[] { endpoint.Port, endpoint.AllocatedEndpoint?.Port }) + .OfType() + .ToHashSet(); + } + internal static int SelectPort(List listeners, HashSet reservedPorts) { // Start at a random offset so concurrent AppHosts spread out, then scan the whole range so a diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs index d70b0002f..00c083d02 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs @@ -144,6 +144,22 @@ await builder.Eventing.PublishAsync( .GetValueAsync(TestContext.Current.CancellationToken)); } + [Fact] + public void GetReservedPortsIncludesConfiguredAndAllocatedPortsOnly() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddContainer("configured", "image").WithEndpoint("tcp", endpoint => endpoint.Port = 21000); + builder.AddContainer("allocated", "image").WithEndpoint("tcp", endpoint => + endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 22000)); + builder.AddContainer("pending", "image").WithEndpoint(targetPort: 8080, name: "tcp"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + Assert.Equal([21000, 22000], FlociServiceBusEndpointAllocator.GetReservedPorts(appModel).Order()); + } + [Fact] public void SelectPortSkipsReservedPorts() {