Skip to content

Commit 8716c47

Browse files
committed
Add browser heartbeat and auto-restart
Introduce proactive detection and recovery for crashed or unresponsive browsers. - Add IBrowser.IsHealthy to expose connection+process liveness. - Implement BrowserHeartbeat to periodically ping Chrome (Browser.getVersion) and call back when the browser becomes unresponsive. - Update Browser to start/stop the heartbeat for launched processes, handle process exit, fault pending CDP commands, and avoid duplicate disconnect notifications via a flag. - Enhance BrowserFixture to automatically restart the browser on disconnect, serialize restarts with a semaphore, and retry launches to reduce flaky tests. - Update BrowserPool to track disconnected browsers, dispose crashed instances, release capacity and proactively warm up replacements toward MinInstances. - Add necessary using and small helper methods (e.g. process exit handler unregistration). These changes improve reliability by detecting frozen browsers, failing pending commands fast, and automatically recovering pool/fixture state for subsequent operations.
1 parent 3402af6 commit 8716c47

5 files changed

Lines changed: 314 additions & 62 deletions

File tree

src/Motus.Abstractions/IBrowser.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ public interface IBrowser : IAsyncDisposable
1010
/// </summary>
1111
bool IsConnected { get; }
1212

13+
/// <summary>
14+
/// Gets whether the browser is connected and its process is alive.
15+
/// For browsers connected via ConnectAsync (no process ownership), equivalent to IsConnected.
16+
/// </summary>
17+
bool IsHealthy => IsConnected;
18+
1319
/// <summary>
1420
/// Gets all browser contexts.
1521
/// </summary>
Lines changed: 137 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,137 @@
1-
using Motus.Abstractions;
2-
3-
namespace Motus.Testing;
4-
5-
/// <summary>
6-
/// Manages a single browser instance for use in test fixtures.
7-
/// Call <see cref="InitializeAsync"/> once (typically in assembly/collection setup),
8-
/// then create isolated contexts per test via <see cref="NewContextAsync"/>.
9-
/// </summary>
10-
public sealed class BrowserFixture : IAsyncDisposable
11-
{
12-
private IBrowser? _browser;
13-
14-
/// <summary>
15-
/// Maximum number of launch attempts before giving up.
16-
/// Browser startup can fail transiently on CI runners or when antivirus
17-
/// delays process creation, so retrying avoids flaky test runs.
18-
/// </summary>
19-
private const int MaxLaunchAttempts = 3;
20-
21-
/// <summary>
22-
/// Launches a browser instance with the given options, retrying on transient failures.
23-
/// </summary>
24-
public async Task InitializeAsync(LaunchOptions? options = null)
25-
{
26-
for (int attempt = 1; attempt <= MaxLaunchAttempts; attempt++)
27-
{
28-
try
29-
{
30-
_browser = await MotusLauncher.LaunchAsync(options).ConfigureAwait(false);
31-
return;
32-
}
33-
catch when (attempt < MaxLaunchAttempts)
34-
{
35-
await Task.Delay(1000 * attempt).ConfigureAwait(false);
36-
}
37-
}
38-
}
39-
40-
/// <summary>
41-
/// The launched browser instance.
42-
/// </summary>
43-
public IBrowser Browser => _browser ?? throw new InvalidOperationException(
44-
"Browser not initialized. Call InitializeAsync first.");
45-
46-
/// <summary>
47-
/// Creates a new isolated browser context.
48-
/// </summary>
49-
public async Task<IBrowserContext> NewContextAsync(ContextOptions? options = null)
50-
=> await Browser.NewContextAsync(options);
51-
52-
public async ValueTask DisposeAsync()
53-
{
54-
if (_browser is not null)
55-
{
56-
await _browser.DisposeAsync();
57-
_browser = null;
58-
}
59-
}
60-
}
1+
using Motus.Abstractions;
2+
3+
namespace Motus.Testing;
4+
5+
/// <summary>
6+
/// Manages a single browser instance for use in test fixtures.
7+
/// Call <see cref="InitializeAsync"/> once (typically in assembly/collection setup),
8+
/// then create isolated contexts per test via <see cref="NewContextAsync"/>.
9+
/// If the browser crashes or becomes unresponsive, the fixture automatically
10+
/// restarts Chrome so subsequent tests proceed against a fresh browser.
11+
/// </summary>
12+
public sealed class BrowserFixture : IAsyncDisposable
13+
{
14+
private IBrowser? _browser;
15+
private LaunchOptions? _launchOptions;
16+
private readonly SemaphoreSlim _restartGate = new(1, 1);
17+
private int _disposed;
18+
19+
/// <summary>
20+
/// Maximum number of launch attempts before giving up.
21+
/// Browser startup can fail transiently on CI runners or when antivirus
22+
/// delays process creation, so retrying avoids flaky test runs.
23+
/// </summary>
24+
private const int MaxLaunchAttempts = 3;
25+
26+
/// <summary>
27+
/// Launches a browser instance with the given options, retrying on transient failures.
28+
/// </summary>
29+
public async Task InitializeAsync(LaunchOptions? options = null)
30+
{
31+
_launchOptions = options;
32+
await LaunchWithRetryAsync(options).ConfigureAwait(false);
33+
SubscribeDisconnected(_browser!);
34+
}
35+
36+
/// <summary>
37+
/// The launched browser instance.
38+
/// </summary>
39+
public IBrowser Browser => _browser ?? throw new InvalidOperationException(
40+
"Browser not initialized. Call InitializeAsync first.");
41+
42+
/// <summary>
43+
/// Creates a new isolated browser context. If a restart is in progress
44+
/// (due to a browser crash), callers block until the new browser is ready.
45+
/// </summary>
46+
public async Task<IBrowserContext> NewContextAsync(ContextOptions? options = null)
47+
{
48+
await _restartGate.WaitAsync().ConfigureAwait(false);
49+
try
50+
{
51+
return await Browser.NewContextAsync(options).ConfigureAwait(false);
52+
}
53+
finally
54+
{
55+
_restartGate.Release();
56+
}
57+
}
58+
59+
public async ValueTask DisposeAsync()
60+
{
61+
if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0)
62+
return;
63+
64+
await _restartGate.WaitAsync().ConfigureAwait(false);
65+
try
66+
{
67+
if (_browser is not null)
68+
{
69+
_browser.Disconnected -= OnBrowserDisconnected;
70+
await _browser.DisposeAsync().ConfigureAwait(false);
71+
_browser = null;
72+
}
73+
}
74+
finally
75+
{
76+
_restartGate.Release();
77+
_restartGate.Dispose();
78+
}
79+
}
80+
81+
private void OnBrowserDisconnected(object? sender, EventArgs e)
82+
{
83+
if (sender is IBrowser old)
84+
old.Disconnected -= OnBrowserDisconnected;
85+
86+
_ = Task.Run(RestartAsync);
87+
}
88+
89+
private async Task RestartAsync()
90+
{
91+
if (Volatile.Read(ref _disposed) != 0)
92+
return;
93+
94+
await _restartGate.WaitAsync().ConfigureAwait(false);
95+
try
96+
{
97+
if (Volatile.Read(ref _disposed) != 0)
98+
return;
99+
100+
// Dispose the dead browser (best-effort)
101+
var old = Interlocked.Exchange(ref _browser, null);
102+
if (old is not null)
103+
{
104+
try { await old.DisposeAsync().ConfigureAwait(false); }
105+
catch { /* already dead */ }
106+
}
107+
108+
await LaunchWithRetryAsync(_launchOptions).ConfigureAwait(false);
109+
110+
if (_browser is not null)
111+
SubscribeDisconnected(_browser);
112+
}
113+
finally
114+
{
115+
_restartGate.Release();
116+
}
117+
}
118+
119+
private async Task LaunchWithRetryAsync(LaunchOptions? options)
120+
{
121+
for (int attempt = 1; attempt <= MaxLaunchAttempts; attempt++)
122+
{
123+
try
124+
{
125+
_browser = await MotusLauncher.LaunchAsync(options).ConfigureAwait(false);
126+
return;
127+
}
128+
catch when (attempt < MaxLaunchAttempts)
129+
{
130+
await Task.Delay(1000 * attempt).ConfigureAwait(false);
131+
}
132+
}
133+
}
134+
135+
private void SubscribeDisconnected(IBrowser browser)
136+
=> browser.Disconnected += OnBrowserDisconnected;
137+
}

src/Motus/Browser/Browser.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ internal sealed class Browser : IBrowser
1919
private readonly List<BrowserContext> _contexts = [];
2020

2121
private volatile bool _isConnected;
22+
private int _disconnectedFlag;
23+
private BrowserHeartbeat? _heartbeat;
2224
private ConsoleCancelEventHandler? _cancelHandler;
2325
private EventHandler? _processExitHandler;
2426

@@ -40,10 +42,18 @@ internal Browser(
4042
_launchOptions = launchOptions ?? new LaunchOptions();
4143

4244
_transport.Disconnected += OnTransportDisconnected;
45+
46+
if (_process is not null)
47+
{
48+
_process.EnableRaisingEvents = true;
49+
_process.Exited += OnProcessExited;
50+
}
4351
}
4452

4553
public bool IsConnected => _isConnected;
4654

55+
public bool IsHealthy => _isConnected && (_process is null || !_process.HasExited);
56+
4757
public string Version { get; private set; } = string.Empty;
4858

4959
public IReadOnlyList<IBrowserContext> Contexts
@@ -68,6 +78,12 @@ internal async Task InitializeAsync(CancellationToken ct)
6878
_isConnected = true;
6979

7080
RegisterSignalHandlers();
81+
82+
if (_process is not null)
83+
{
84+
_heartbeat = new BrowserHeartbeat(_registry.BrowserSession, OnHeartbeatFailed);
85+
_heartbeat.Start();
86+
}
7187
}
7288

7389
public async Task CloseAsync()
@@ -77,7 +93,11 @@ public async Task CloseAsync()
7793

7894
_isConnected = false;
7995

96+
if (_heartbeat is not null)
97+
await _heartbeat.DisposeAsync().ConfigureAwait(false);
98+
8099
UnregisterSignalHandlers();
100+
UnregisterProcessExitHandler();
81101

82102
// Close all contexts first
83103
List<BrowserContext> contextsToClose;
@@ -123,7 +143,11 @@ await _registry.BrowserSession.SendAsync(
123143

124144
public async ValueTask DisposeAsync()
125145
{
146+
if (_heartbeat is not null)
147+
await _heartbeat.DisposeAsync().ConfigureAwait(false);
148+
126149
UnregisterSignalHandlers();
150+
UnregisterProcessExitHandler();
127151

128152
_isConnected = false;
129153

@@ -201,10 +225,45 @@ internal void RemoveContext(BrowserContext context)
201225

202226
private void OnTransportDisconnected(Exception? ex)
203227
{
228+
if (Interlocked.CompareExchange(ref _disconnectedFlag, 1, 0) != 0)
229+
return;
230+
204231
_isConnected = false;
205232
Disconnected?.Invoke(this, EventArgs.Empty);
206233
}
207234

235+
private void OnProcessExited(object? sender, EventArgs e)
236+
{
237+
if (Interlocked.CompareExchange(ref _disconnectedFlag, 1, 0) != 0)
238+
return;
239+
240+
_isConnected = false;
241+
242+
// Dispose transport to fault all pending CDP commands immediately
243+
_ = _transport.DisposeAsync().AsTask();
244+
245+
Disconnected?.Invoke(this, EventArgs.Empty);
246+
}
247+
248+
private void OnHeartbeatFailed(Exception? ex)
249+
{
250+
if (Interlocked.CompareExchange(ref _disconnectedFlag, 1, 0) != 0)
251+
return;
252+
253+
_isConnected = false;
254+
255+
// Dispose transport to fault all pending CDP commands (browser is frozen)
256+
_ = _transport.DisposeAsync().AsTask();
257+
258+
Disconnected?.Invoke(this, EventArgs.Empty);
259+
}
260+
261+
private void UnregisterProcessExitHandler()
262+
{
263+
if (_process is not null)
264+
_process.Exited -= OnProcessExited;
265+
}
266+
208267
private void RegisterSignalHandlers()
209268
{
210269
if (_process is null)

0 commit comments

Comments
 (0)