diff --git a/src/Aspire.Hosting.Browsers/BrowserPageSession.cs b/src/Aspire.Hosting.Browsers/BrowserPageSession.cs index 3a493fb62cf..2678f5777c8 100644 --- a/src/Aspire.Hosting.Browsers/BrowserPageSession.cs +++ b/src/Aspire.Hosting.Browsers/BrowserPageSession.cs @@ -190,6 +190,9 @@ public async ValueTask DisposeAsync() try { var connection = _connection; + // The ReferenceEquals check is technically redundant today (connection was just read from _connection + // under the lock), but guards against future refactors that may read _connection earlier or release + // and re-acquire the lock before reaching this point. if (connection is not null && ReferenceEquals(connection, _connection) && _targetId is not null) { try @@ -416,7 +419,7 @@ private async Task TryReconnectAsync(Exception connectionError) try { - await Task.Delay(s_connectionRecoveryDelay, _stopCts.Token).ConfigureAwait(false); + await Task.Delay(s_connectionRecoveryDelay, _timeProvider, _stopCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) { diff --git a/tests/Aspire.Hosting.Browsers.Tests/BrowserPageSessionTests.cs b/tests/Aspire.Hosting.Browsers.Tests/BrowserPageSessionTests.cs index 5f534c369a7..5541ed2d325 100644 --- a/tests/Aspire.Hosting.Browsers.Tests/BrowserPageSessionTests.cs +++ b/tests/Aspire.Hosting.Browsers.Tests/BrowserPageSessionTests.cs @@ -267,6 +267,69 @@ await secondConnection.RaiseEventAsync(new BrowserLogsTargetDestroyedEvent( await session.DisposeAsync(); } + [Fact] + public async Task MonitorAsync_CompletesWithConnectionLostWhenReconnectTimesOut() + { + var host = new TestBrowserHost(); + var firstConnection = new FakeBrowserLogsCdpConnection + { + CreatedTargetId = "target-1", + AttachSessionId = "target-session-1" + }; + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 26, 0, 0, 0, TimeSpan.Zero)); + + // All reconnect attempts after the first connection will fail. + var reconnectAttempts = 0; + BrowserLogsCdpConnectionFactory connectionFactory = (eventHandler, logger, cancellationToken) => + { + if (reconnectAttempts == 0) + { + reconnectAttempts++; + firstConnection.SetEventHandler(eventHandler); + return Task.FromResult(firstConnection); + } + + reconnectAttempts++; + throw new InvalidOperationException("Simulated connection failure."); + }; + + var session = await BrowserPageSession.StartAsync( + host, + "session-0001", + new Uri("https://localhost:5001/"), + new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance), + connectionFactory, + static _ => ValueTask.CompletedTask, + NullLogger.Instance, + timeProvider, + reuseInitialBlankTarget: false, + CancellationToken.None); + + Assert.Equal("target-1", session.TargetId); + + // Trigger connection loss to start the reconnect loop. + firstConnection.FailCompletion(new InvalidOperationException("Socket reset.")); + + // Advance time in a concurrent task so that each Task.Delay timer in the reconnect loop fires, + // allowing the loop to iterate and eventually exceed the 5-second recovery deadline. + _ = Task.Run(async () => + { + while (!session.Completion.IsCompleted) + { + await Task.Delay(10); + timeProvider.Advance(TimeSpan.FromSeconds(1)); + } + }); + + var result = await session.Completion.DefaultTimeout(); + Assert.Equal(BrowserPageSessionCompletionKind.ConnectionLost, result.CompletionKind); + Assert.NotNull(result.Error); + Assert.True(firstConnection.Disposed); + Assert.True(reconnectAttempts > 1, $"Expected multiple reconnect attempts but got {reconnectAttempts}."); + + await session.DisposeAsync(); + } + private static BrowserLogsCdpConnectionFactory CreateConnectionFactory(params FakeBrowserLogsCdpConnection[] connections) { var nextConnectionIndex = 0;