diff --git a/tests/Wisp.Telemetry.Tests/TelemetryPacketDiagnosticsTests.cs b/tests/Wisp.Telemetry.Tests/TelemetryPacketDiagnosticsTests.cs index 0189976b..2a7cf956 100644 --- a/tests/Wisp.Telemetry.Tests/TelemetryPacketDiagnosticsTests.cs +++ b/tests/Wisp.Telemetry.Tests/TelemetryPacketDiagnosticsTests.cs @@ -27,8 +27,7 @@ public async Task DisabledRecordingAndThrowingObserversPreserveParsing(int obser }; } receiver.PacketAvailable += (_, _) => accepted.Release(); - var port = GetAvailablePort(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); var endpoint = new IPEndPoint(IPAddress.Loopback, port); var started = Stopwatch.GetTimestamp(); @@ -101,8 +100,7 @@ public async Task DrainedDatagramsKeepTheirOwnIdentityAndNewestParseTimestamp(bo throw new InvalidOperationException("Drained observer failure"); }; receiver.PacketAvailable += (_, _) => accepted.Release(); - var port = GetAvailablePort(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); var endpoint = new IPEndPoint(IPAddress.Loopback, port); try @@ -176,6 +174,24 @@ private static byte[] PacketForCar(int car) return packet; } + private static async Task StartOnAvailablePortAsync(TelemetryUdpReceiver receiver) + { + // Probing releases the port before the receiver binds it. Another test's + // probe or listener can bind it in between; retry only that setup race. + for (var attempt = 0; ; attempt++) + { + var port = GetAvailablePort(); + try + { + await receiver.StartAsync(port, TestContext.Current.CancellationToken); + return port; + } + catch (SocketException error) when (error.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < 7) + { + } + } + } + private static int GetAvailablePort() { using var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp) diff --git a/tests/Wisp.Telemetry.Tests/TelemetryUdpReceiverTests.cs b/tests/Wisp.Telemetry.Tests/TelemetryUdpReceiverTests.cs index 83005b24..e4cbe7d5 100644 --- a/tests/Wisp.Telemetry.Tests/TelemetryUdpReceiverTests.cs +++ b/tests/Wisp.Telemetry.Tests/TelemetryUdpReceiverTests.cs @@ -10,7 +10,7 @@ public sealed class TelemetryUdpReceiverTests public async Task UnexpectedSocketClosureRetainsFaultCodeAndRestartClearsIt() { await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(GetAvailablePort(), TestContext.Current.CancellationToken); + await StartOnAvailablePortAsync(receiver); var session = typeof(TelemetryUdpReceiver).GetField("_session", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(receiver)!; var socket = (Socket)session.GetType().GetProperty("Socket")!.GetValue(session)!; var receive = (Task)session.GetType().GetProperty("ReceiveTask")!.GetValue(session)!; @@ -19,7 +19,7 @@ public async Task UnexpectedSocketClosureRetainsFaultCodeAndRestartClearsIt() Assert.NotNull(receiver.GetStatistics(DateTimeOffset.UtcNow).ListenerError); Assert.NotNull(receiver.ListenerFailure); Assert.NotEqual(0, receiver.ListenerFailure.Code); - await receiver.RestartAsync(GetAvailablePort(), TestContext.Current.CancellationToken); + await RestartOnAvailablePortAsync(receiver); Assert.Null(receiver.ListenerFailure); Assert.Null(receiver.GetStatistics(DateTimeOffset.UtcNow).ListenerError); await receiver.StopAsync(); @@ -44,10 +44,8 @@ public void AcceptsDefaultPort() [Fact] public async Task ReceivesAndParsesLocalhostDatagramEndToEnd() { - var port = GetAvailablePort(); - await using var receiver = new TelemetryUdpReceiver { CapturePublicationTimings = true }; - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); var packet = Fh6PacketFixture.Create(); await sender.SendAsync(packet, new IPEndPoint(IPAddress.Loopback, port), TestContext.Current.CancellationToken); @@ -70,11 +68,10 @@ public async Task ReceivesAndParsesLocalhostDatagramEndToEnd() [Fact] public async Task FailedRestartKeepsExistingListenerAndSettingsStateAlive() { - var originalPort = GetAvailablePort(); using var reservation = CreateExclusiveReservation(); var occupiedPort = ((IPEndPoint)reservation.LocalEndPoint!).Port; await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(originalPort, TestContext.Current.CancellationToken); + var originalPort = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); await sender.SendAsync(Fh6PacketFixture.Create(), new IPEndPoint(IPAddress.Loopback, originalPort), TestContext.Current.CancellationToken); await WaitForCarAsync(receiver, 2468); @@ -96,17 +93,15 @@ public async Task FailedRestartKeepsExistingListenerAndSettingsStateAlive() [Fact] public async Task SuccessfulRestartClearsSessionStateAndReleasesOldPort() { - var originalPort = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(originalPort, TestContext.Current.CancellationToken); + var originalPort = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); await sender.SendAsync(Fh6PacketFixture.Create(), new IPEndPoint(IPAddress.Loopback, originalPort), TestContext.Current.CancellationToken); await WaitForCarAsync(receiver, 2468); // Probe after the sender has auto-bound, so its ephemeral source port // cannot claim our released replacement port before the restart. - var replacementPort = GetAvailablePortExcept(originalPort); - await receiver.RestartAsync(replacementPort, TestContext.Current.CancellationToken); + var replacementPort = await RestartOnAvailablePortAsync(receiver, originalPort); Assert.Null(receiver.Latest); Assert.Equal(replacementPort, receiver.ListeningPort); @@ -128,9 +123,8 @@ public async Task SuccessfulRestartClearsSessionStateAndReleasesOldPort() [Fact] public async Task ConcurrentStopsAreIdempotentAndClearLatestState() { - var port = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); await sender.SendAsync(Fh6PacketFixture.Create(), new IPEndPoint(IPAddress.Loopback, port), TestContext.Current.CancellationToken); await WaitForCarAsync(receiver, 2468); @@ -162,10 +156,9 @@ public async Task FailedInitialBindDoesNotLeaveReceiverInRunningState() [Fact] public async Task PacketSubscriberFailureDoesNotTerminateListener() { - var port = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); receiver.PacketAvailable += (_, _) => throw new InvalidOperationException("Test subscriber failure"); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); await sender.SendAsync(Fh6PacketFixture.Create(), new IPEndPoint(IPAddress.Loopback, port), TestContext.Current.CancellationToken); @@ -183,9 +176,8 @@ public async Task PacketSubscriberFailureDoesNotTerminateListener() [Fact] public async Task PacketBurstRetainsNewestDatagram() { - var port = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); for (var index = 0; index < 16; index++) @@ -206,9 +198,8 @@ public async Task PacketBurstRetainsNewestDatagram() [Fact] public async Task MalformedTrafficIsCountedWithoutPretendingValidTelemetryIsFresh() { - var port = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); await sender.SendAsync(new byte[8], new IPEndPoint(IPAddress.Loopback, port), TestContext.Current.CancellationToken); @@ -229,9 +220,8 @@ public async Task MalformedTrafficIsCountedWithoutPretendingValidTelemetryIsFres [InlineData(4096)] public async Task OversizedDatagramIsRejectedWithoutStoppingTheListener(int size) { - var port = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); var endpoint = new IPEndPoint(IPAddress.Loopback, port); await sender.SendAsync(new byte[size], endpoint, TestContext.Current.CancellationToken); @@ -245,9 +235,8 @@ public async Task OversizedDatagramIsRejectedWithoutStoppingTheListener(int size [Fact] public async Task OversizedDatagramInABurstLeavesTheNewestValidPacket() { - var port = GetAvailablePort(); await using var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); using var sender = new UdpClient(AddressFamily.InterNetwork); var endpoint = new IPEndPoint(IPAddress.Loopback, port); // One burst fits the socket buffer; the listener may drain the later datagrams. @@ -274,9 +263,8 @@ private static async Task WaitForStatistics(TelemetryUdpReceiver receiver, Func< [Fact] public async Task DisposeReleasesPortAndPreventsRestart() { - var port = GetAvailablePort(); var receiver = new TelemetryUdpReceiver(); - await receiver.StartAsync(port, TestContext.Current.CancellationToken); + var port = await StartOnAvailablePortAsync(receiver); await receiver.DisposeAsync(); @@ -290,6 +278,30 @@ public async Task DisposeReleasesPortAndPreventsRestart() await Assert.ThrowsAsync(() => receiver.RestartAsync(GetAvailablePortExcept(port), TestContext.Current.CancellationToken)); } + private static Task StartOnAvailablePortAsync(TelemetryUdpReceiver receiver) => + BindAvailablePortAsync(port => receiver.StartAsync(port, TestContext.Current.CancellationToken)); + + private static Task RestartOnAvailablePortAsync(TelemetryUdpReceiver receiver, int excludedPort = 0) => + BindAvailablePortAsync(port => receiver.RestartAsync(port, TestContext.Current.CancellationToken), excludedPort); + + private static async Task BindAvailablePortAsync(Func bind, int excludedPort = 0) + { + // Probing releases the port before the receiver binds it. Another test's + // probe or listener can bind it in between; retry only that setup race. + for (var attempt = 0; ; attempt++) + { + var port = GetAvailablePortExcept(excludedPort); + try + { + await bind(port); + return port; + } + catch (SocketException error) when (error.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < 7) + { + } + } + } + private static int GetAvailablePort() { using var reservation = CreateExclusiveReservation();