Skip to content

Commit 6e30221

Browse files
committed
Improve browser stability and transport robustness
Add multiple hardening changes to tolerate transient browser failures and concurrent load: - Retry context/page creation in MSTest and NUnit bases to ride through browser restarts. - Use fixture.CloseContextAsync and swallow cleanup exceptions so teardown doesn't mask test failures. - Introduce a context concurrency throttle (BrowserFixture.s_contextThrottle) and CloseContextAsync to limit concurrent browser contexts and always release slots. - Harden BrowserContext.NewPageAsync: retry transient TaskCanceledException, enforce a PageInitTimeout, copy/cleanup partially-created targets/sessions on failure, apply context options and storage/state reliably, and fire lifecycle hooks. - Treat MotusTargetClosedException like CdpDisconnectedException in several catch blocks to avoid noisy errors when targets disconnect. - Serialize websocket sends in BiDiTransport and CdpTransport with a send lock, copy received message bytes before deserialization to avoid buffer reuse, throw specific disconnected exceptions when disposed, and dispose the send lock during shutdown. These changes reduce flakes under parallel CI load and improve resilience to browser/process crashes.
1 parent 77d5d30 commit 6e30221

8 files changed

Lines changed: 289 additions & 105 deletions

File tree

src/Motus.Testing.MSTest/MotusTestBase.cs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,25 @@ public static async Task CloseBrowserAsync()
6969
[TestInitialize]
7070
public async Task MotusTestInitialize()
7171
{
72-
_context = await s_fixture.NewContextAsync(ContextOptions).ConfigureAwait(false);
73-
_page = await _context.NewPageAsync().ConfigureAwait(false);
72+
// If Chrome crashed during a previous test, the fixture auto-restarts.
73+
// Retry context+page creation to ride through the restart window.
74+
const int maxAttempts = 3;
75+
for (int attempt = 1; ; attempt++)
76+
{
77+
try
78+
{
79+
_context = await s_fixture.NewContextAsync(ContextOptions).ConfigureAwait(false);
80+
_page = await _context.NewPageAsync().ConfigureAwait(false);
81+
break;
82+
}
83+
catch when (attempt < maxAttempts)
84+
{
85+
// Give the browser fixture time to restart Chrome.
86+
_context = null;
87+
_page = null;
88+
await Task.Delay(1000 * attempt).ConfigureAwait(false);
89+
}
90+
}
7491

7592
_failureTracing = new FailureTracing();
7693
await _failureTracing.StartIfEnabledAsync(_context).ConfigureAwait(false);
@@ -96,13 +113,24 @@ public async Task MotusTestCleanup()
96113

97114
if (_context is not null)
98115
{
99-
var testFailed = TestContext?.CurrentTestOutcome != UnitTestOutcome.Passed;
100-
if (_failureTracing is not null)
101-
await _failureTracing.StopAsync(_context, testFailed).ConfigureAwait(false);
102-
103-
await _context.CloseAsync().ConfigureAwait(false);
104-
_context = null;
105-
_page = null;
116+
try
117+
{
118+
var testFailed = TestContext?.CurrentTestOutcome != UnitTestOutcome.Passed;
119+
if (_failureTracing is not null)
120+
await _failureTracing.StopAsync(_context, testFailed).ConfigureAwait(false);
121+
122+
await s_fixture.CloseContextAsync(_context).ConfigureAwait(false);
123+
}
124+
catch (Exception) when (_context is not null)
125+
{
126+
// Browser may have crashed or disconnected; swallow so we don't
127+
// mask the original test failure with a cleanup exception.
128+
}
129+
finally
130+
{
131+
_context = null;
132+
_page = null;
133+
}
106134
}
107135
}
108136
}

src/Motus.Testing.NUnit/MotusTestBase.cs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,22 @@ public async Task OneTimeTearDown()
6262
[SetUp]
6363
public async Task SetUp()
6464
{
65-
_context = await _fixture.NewContextAsync(ContextOptions);
66-
_page = await _context.NewPageAsync();
65+
const int maxAttempts = 3;
66+
for (int attempt = 1; ; attempt++)
67+
{
68+
try
69+
{
70+
_context = await _fixture.NewContextAsync(ContextOptions);
71+
_page = await _context.NewPageAsync();
72+
break;
73+
}
74+
catch when (attempt < maxAttempts)
75+
{
76+
_context = null;
77+
_page = null;
78+
await Task.Delay(1000 * attempt);
79+
}
80+
}
6781

6882
_failureTracing = new FailureTracing();
6983
await _failureTracing.StartIfEnabledAsync(_context);
@@ -89,13 +103,24 @@ public async Task TearDown()
89103

90104
if (_context is not null)
91105
{
92-
var testFailed = TestContext.CurrentContext.Result.Outcome.Status == global::NUnit.Framework.Interfaces.TestStatus.Failed;
93-
if (_failureTracing is not null)
94-
await _failureTracing.StopAsync(_context, testFailed);
106+
try
107+
{
108+
var testFailed = TestContext.CurrentContext.Result.Outcome.Status == global::NUnit.Framework.Interfaces.TestStatus.Failed;
109+
if (_failureTracing is not null)
110+
await _failureTracing.StopAsync(_context, testFailed);
95111

96-
await _context.CloseAsync();
97-
_context = null;
98-
_page = null;
112+
await _fixture.CloseContextAsync(_context);
113+
}
114+
catch (Exception) when (_context is not null)
115+
{
116+
// Browser may have crashed or disconnected; swallow so we don't
117+
// mask the original test failure with a cleanup exception.
118+
}
119+
finally
120+
{
121+
_context = null;
122+
_page = null;
123+
}
99124
}
100125
}
101126
}

src/Motus.Testing/BrowserFixture.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ public sealed class BrowserFixture : IAsyncDisposable
1616
private SemaphoreSlim _restartGate = new(1, 1);
1717
private int _disposed;
1818

19+
/// <summary>
20+
/// Limits concurrent browser contexts to prevent Chrome from becoming
21+
/// unresponsive under heavy parallel test load. Chrome's renderer process
22+
/// model can stall when too many targets compete for resources.
23+
/// </summary>
24+
private static readonly SemaphoreSlim s_contextThrottle = new(4, 4);
25+
1926
/// <summary>
2027
/// Maximum number of launch attempts before giving up.
2128
/// Browser startup can fail transiently on CI runners or when antivirus
@@ -47,20 +54,44 @@ public async Task InitializeAsync(LaunchOptions? options = null)
4754
/// <summary>
4855
/// Creates a new isolated browser context. If a restart is in progress
4956
/// (due to a browser crash), callers block until the new browser is ready.
57+
/// The context throttle limits concurrent contexts to avoid overwhelming Chrome.
5058
/// </summary>
5159
public async Task<IBrowserContext> NewContextAsync(ContextOptions? options = null)
5260
{
61+
await s_contextThrottle.WaitAsync().ConfigureAwait(false);
62+
5363
await _restartGate.WaitAsync().ConfigureAwait(false);
5464
try
5565
{
5666
return await Browser.NewContextAsync(options).ConfigureAwait(false);
5767
}
68+
catch
69+
{
70+
s_contextThrottle.Release();
71+
throw;
72+
}
5873
finally
5974
{
6075
_restartGate.Release();
6176
}
6277
}
6378

79+
/// <summary>
80+
/// Closes a context and releases its concurrency throttle slot.
81+
/// Always releases the slot even if close fails (browser crashed).
82+
/// </summary>
83+
public async Task CloseContextAsync(IBrowserContext context)
84+
{
85+
try
86+
{
87+
await context.CloseAsync().ConfigureAwait(false);
88+
}
89+
finally
90+
{
91+
s_contextThrottle.Release();
92+
}
93+
}
94+
6495
public async ValueTask DisposeAsync()
6596
{
6697
if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0)

src/Motus/Browser/Browser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ await _registry.BrowserSession.SendAsync(
119119
CdpJsonContext.Default.BrowserCloseResult,
120120
CancellationToken.None).ConfigureAwait(false);
121121
}
122-
catch (CdpDisconnectedException)
122+
catch (Exception ex) when (ex is CdpDisconnectedException or MotusTargetClosedException)
123123
{
124124
// Expected: browser closes the WebSocket on shutdown
125125
}

0 commit comments

Comments
 (0)