Skip to content

Commit e0df7f7

Browse files
Fix race condition that caused LaunchAsync to never resolve for chrome (#2214)
* Fix #2202 Only process received messages after the discovered targets have been stored. * Change the await to only apply to attachedToTarget, leaving other messages unchanged. This fixes some of the unit tests, which were failing due to changes in the order of execution of initialization messages. * Remove the await for OnAttachedToTarget call, and also included a missing return when ignoring a target. * * Fixed a race condition if a message is received before the Browser._logger field is initialized. * Fixed a deadlock that could happen if the connection is closed on the thread that is processing received messages. TaskQueue could not be disposed on the same thread that held the semaphore. * Fixed a race condition if targets are created/changed concurrently before the TargetHandler is registered as an event handler. * Previous commit introduced a new race condition. It was possible that thread A could invoke `TaskQueue.Dispose()` and set `_isDisposed = 1`, which would then allow thread B to finish work setting `_held = false` but without releasing the semaphore, and then thread A would attempt `_semaphore.Wait()` entering a deadlock. * It was possible for the TargetManager initialization to finish without having discovered all targets. This was causing unit tests such as PuppeteerConnectTests.ShouldSupportTargetFilter to fail because the test executed faster than the target discovery. * PR review * Rolling back Target.setDiscoverTargets to be sent from the constructor * Handle exceptions in OnAttachedToTarget * OnAttachedToTarget should be executed synchronously if possible, so that new targets are added to `_attachedTargetsByTargetId` inside of the semaphore. Also fixes `Page.CloseAsync()` which was returning before `Target.CloseTask` resolved. This affected BrowserContextTests.ShouldFireTargetEvents on which it was possible for the test to finish before the `TargetDestroy` event. * Fix PuppeteerConnectTests.ShouldSupportTargetFilter. It was possible for the InitializeAsync to finish without all targets being initialized, and consequently the test would read an empty list of targets. The _targetDiscoveryCompletionSource should be awaited before logic that depends on _targetsIdsForInit inside of message processing, to make sure this collection was already initialized during the browser launch. * Fix OOPIFTests.ShouldDetectExistingOopifsWhenPuppeteerConnectsToAnExistingPage. Disposing the `browser1` was closing the page, which then caused the `Page.CloseAsync()` in `PuppeteerPageBaseTest` to fail. The test code now matches upstream puppeteer. * Revert unintentional line ending changes. * Use the launcher timeout when awaiting for `_targetDiscoveryCompletionSource`, as a defensive measure against deadlocks.
1 parent f5b5d87 commit e0df7f7

File tree

7 files changed

+153
-45
lines changed

7 files changed

+153
-45
lines changed

lib/PuppeteerSharp.Tests/LauncherTests/PuppeteerConnectTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ await Task.WhenAll(
9191
[SkipBrowserFact(skipFirefox: true)]
9292
public async Task ShouldSupportTargetFilter()
9393
{
94-
await using (var originalBrowser = await Puppeteer.LaunchAsync(TestConstants.DefaultBrowserOptions()))
94+
await using (var originalBrowser = await Puppeteer.LaunchAsync(TestConstants.DefaultBrowserOptions(), TestConstants.LoggerFactory))
9595
{
9696
var page1 = await originalBrowser.NewPageAsync();
9797
await page1.GoToAsync(TestConstants.EmptyPage);
@@ -102,7 +102,7 @@ public async Task ShouldSupportTargetFilter()
102102
var browser = await Puppeteer.ConnectAsync(new ConnectOptions {
103103
BrowserWSEndpoint = originalBrowser.WebSocketEndpoint,
104104
TargetFilter = (TargetInfo targetInfo) => !targetInfo.Url.Contains("should-be-ignored"),
105-
});
105+
}, TestConstants.LoggerFactory);
106106

107107
var pages = await browser.PagesAsync();
108108

lib/PuppeteerSharp.Tests/OOPIFTests/OOPIFTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,12 @@ public async Task ShouldDetectExistingOopifsWhenPuppeteerConnectsToAnExistingPag
334334
Assert.Equal(2, Page.Frames.Length);
335335

336336
var browserURL = $"http://127.0.0.1:{_port}";
337-
using var browser1 = await Puppeteer.ConnectAsync(new (){ BrowserURL = browserURL });
337+
var browser1 = await Puppeteer.ConnectAsync(new (){ BrowserURL = browserURL }, TestConstants.LoggerFactory);
338338
var target = await browser1.WaitForTargetAsync((target) =>
339339
target.Url.EndsWith("dynamic-oopif.html")
340340
).WithTimeout();
341341
await target.PageAsync();
342+
browser1.Disconnect();
342343
}
343344

344345
[PuppeteerTest("oopif.spec.ts", "OOPIF", "should support lazy OOP frames")]

lib/PuppeteerSharp.Tests/UtilitiesTests/TaskQueueTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,39 @@ public async Task ShouldNotThrowWhenDisposingMultipleTimesAsync()
4949
await taskQueue.DisposeAsync().ConfigureAwait(false);
5050
}
5151

52+
[Fact]
53+
public async Task CanDisposeWhileSemaphoreIsHeld()
54+
{
55+
var taskQueue = new TaskQueue();
56+
57+
await taskQueue.Enqueue(() =>
58+
{
59+
taskQueue.Dispose();
60+
return Task.CompletedTask;
61+
});
62+
63+
var semaphore = GetSemaphore(taskQueue);
64+
Assert.Throws<ObjectDisposedException>(() => semaphore.AvailableWaitHandle);
65+
66+
taskQueue.Dispose();
67+
}
68+
69+
[Fact]
70+
public async Task CanDisposeWhileSemaphoreIsHeldAsync()
71+
{
72+
var taskQueue = new TaskQueue();
73+
74+
await taskQueue.Enqueue(async () =>
75+
{
76+
await taskQueue.DisposeAsync();
77+
});
78+
79+
var semaphore = GetSemaphore(taskQueue);
80+
Assert.Throws<ObjectDisposedException>(() => semaphore.AvailableWaitHandle);
81+
82+
await taskQueue.DisposeAsync();
83+
}
84+
5285
private static SemaphoreSlim GetSemaphore(TaskQueue queue) =>
5386
(SemaphoreSlim)typeof(TaskQueue).GetField("_semaphore", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(queue);
5487
}

lib/PuppeteerSharp/Browser.cs

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ internal Browser(
7373
TargetManager = new ChromeTargetManager(
7474
connection,
7575
CreateTarget,
76-
_targetFilterCallback);
76+
_targetFilterCallback,
77+
launcher?.Options?.Timeout ?? Puppeteer.DefaultTimeout);
7778
}
7879
}
7980

@@ -201,12 +202,6 @@ public async Task<ITarget> WaitForTargetAsync(Func<ITarget, bool> predicate, Wai
201202
}
202203

203204
var timeout = options?.Timeout ?? DefaultWaitForTimeout;
204-
var existingTarget = Targets().FirstOrDefault(predicate);
205-
if (existingTarget != null)
206-
{
207-
return existingTarget;
208-
}
209-
210205
var targetCompletionSource = new TaskCompletionSource<ITarget>(TaskCreationOptions.RunContinuationsAsynchronously);
211206

212207
void TargetHandler(object sender, TargetChangedArgs e)
@@ -219,17 +214,15 @@ void TargetHandler(object sender, TargetChangedArgs e)
219214

220215
try
221216
{
222-
foreach (var target in Targets())
223-
{
224-
if (predicate(target))
225-
{
226-
return target;
227-
}
228-
}
229-
230217
TargetCreated += TargetHandler;
231218
TargetChanged += TargetHandler;
232219

220+
var existingTarget = Targets().FirstOrDefault(predicate);
221+
if (existingTarget != null)
222+
{
223+
return existingTarget;
224+
}
225+
233226
return await targetCompletionSource.Task.WithTimeout(timeout).ConfigureAwait(false);
234227
}
235228
finally

lib/PuppeteerSharp/ChromeTargetManager.cs

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Threading.Tasks;
77
using Microsoft.Extensions.Logging;
88
using Newtonsoft.Json.Linq;
9+
using PuppeteerSharp.Helpers;
910
using PuppeteerSharp.Helpers.Json;
1011
using PuppeteerSharp.Messaging;
1112

@@ -25,17 +26,23 @@ internal class ChromeTargetManager : ITargetManager
2526
private readonly List<string> _targetsIdsForInit = new();
2627
private readonly TaskCompletionSource<bool> _initializeCompletionSource = new();
2728

29+
// Needed for .NET only to prevent race conditions between StoreExistingTargetsForInit and OnAttachedToTarget
30+
private readonly int _targetDiscoveryTimeout;
31+
private readonly TaskCompletionSource<bool> _targetDiscoveryCompletionSource = new();
32+
2833
public ChromeTargetManager(
2934
Connection connection,
3035
Func<TargetInfo, CDPSession, Target> targetFactoryFunc,
31-
Func<TargetInfo, bool> targetFilterFunc)
36+
Func<TargetInfo, bool> targetFilterFunc,
37+
int targetDiscoveryTimeout = 0)
3238
{
3339
_connection = connection;
3440
_targetFilterFunc = targetFilterFunc;
3541
_targetFactoryFunc = targetFactoryFunc;
3642
_logger = _connection.LoggerFactory.CreateLogger<ChromeTargetManager>();
3743
_connection.MessageReceived += OnMessageReceived;
3844
_connection.SessionDetached += Connection_SessionDetached;
45+
_targetDiscoveryTimeout = targetDiscoveryTimeout;
3946

4047
_ = _connection.SendAsync("Target.setDiscoverTargets", new TargetSetDiscoverTargetsRequest
4148
{
@@ -52,13 +59,20 @@ public ChromeTargetManager(
5259
}).ContinueWith(
5360
t =>
5461
{
55-
if (t.IsFaulted)
62+
try
5663
{
57-
_logger.LogError(t.Exception, "Target.setDiscoverTargets failed");
64+
if (t.IsFaulted)
65+
{
66+
_logger.LogError(t.Exception, "Target.setDiscoverTargets failed");
67+
}
68+
else
69+
{
70+
StoreExistingTargetsForInit();
71+
}
5872
}
59-
else
73+
finally
6074
{
61-
StoreExistingTargetsForInit();
75+
_targetDiscoveryCompletionSource.SetResult(true);
6276
}
6377
},
6478
TaskScheduler.Default);
@@ -83,7 +97,9 @@ public async Task InitializeAsync()
8397
AutoAttach = true,
8498
}).ConfigureAwait(false);
8599

100+
await _targetDiscoveryCompletionSource.Task.ConfigureAwait(false);
86101
FinishInitializationIfReady();
102+
87103
await _initializeCompletionSource.Task.ConfigureAwait(false);
88104
}
89105

@@ -115,18 +131,32 @@ private void StoreExistingTargetsForInit()
115131
}
116132
}
117133

118-
private async void OnMessageReceived(object sender, MessageEventArgs e)
134+
private async Task EnsureTargetsIdsForInit()
135+
{
136+
if (_targetDiscoveryTimeout > 0)
137+
{
138+
await _targetDiscoveryCompletionSource.Task.WithTimeout(_targetDiscoveryTimeout).ConfigureAwait(false);
139+
}
140+
else
141+
{
142+
await _targetDiscoveryCompletionSource.Task.ConfigureAwait(false);
143+
}
144+
}
145+
146+
private void OnMessageReceived(object sender, MessageEventArgs e)
119147
{
120148
try
121149
{
122150
switch (e.MessageID)
123151
{
124152
case "Target.attachedToTarget":
125-
await OnAttachedToTarget(sender, e.MessageData.ToObject<TargetAttachedToTargetResponse>(true)).ConfigureAwait(false);
153+
_ = OnAttachedToTargetHandlingExceptionsAsync(sender, e.MessageID, e.MessageData.ToObject<TargetAttachedToTargetResponse>(true));
126154
return;
155+
127156
case "Target.detachedFromTarget":
128157
OnDetachedFromTarget(sender, e.MessageData.ToObject<TargetDetachedFromTargetResponse>(true));
129158
return;
159+
130160
case "Target.targetCreated":
131161
OnTargetCreated(e.MessageData.ToObject<TargetCreatedResponse>(true));
132162
return;
@@ -142,9 +172,7 @@ private async void OnMessageReceived(object sender, MessageEventArgs e)
142172
}
143173
catch (Exception ex)
144174
{
145-
var message = $"Browser failed to process {e.MessageID}. {ex.Message}. {ex.StackTrace}";
146-
_logger.LogError(ex, message);
147-
_connection.Close(message);
175+
HandleExceptionOnMessageReceived(e.MessageID, ex);
148176
}
149177
}
150178

@@ -172,9 +200,10 @@ private void OnTargetCreated(TargetCreatedResponse e)
172200
}
173201
}
174202

175-
private void OnTargetDestroyed(TargetDestroyedResponse e)
203+
private async void OnTargetDestroyed(TargetDestroyedResponse e)
176204
{
177205
_discoveredTargetsByTargetId.TryRemove(e.TargetId, out var targetInfo);
206+
await EnsureTargetsIdsForInit().ConfigureAwait(false);
178207
FinishInitializationIfReady(e.TargetId);
179208

180209
if (targetInfo?.Type == TargetType.ServiceWorker && _attachedTargetsByTargetId.TryRemove(e.TargetId, out var target))
@@ -235,6 +264,7 @@ await parent.SendAsync(
235264
if (targetInfo.Type == TargetType.ServiceWorker &&
236265
_connection.IsAutoAttached(targetInfo.TargetId))
237266
{
267+
await EnsureTargetsIdsForInit().ConfigureAwait(false);
238268
FinishInitializationIfReady(targetInfo.TargetId);
239269
await silentDetach().ConfigureAwait(false);
240270
if (_attachedTargetsByTargetId.ContainsKey(targetInfo.TargetId))
@@ -251,8 +281,10 @@ await parent.SendAsync(
251281
if (_targetFilterFunc?.Invoke(targetInfo) == false)
252282
{
253283
_ignoredTargets.Add(targetInfo.TargetId);
284+
await EnsureTargetsIdsForInit().ConfigureAwait(false);
254285
FinishInitializationIfReady(targetInfo.TargetId);
255286
await silentDetach().ConfigureAwait(false);
287+
return;
256288
}
257289

258290
var existingTarget = _attachedTargetsByTargetId.TryGetValue(targetInfo.TargetId, out var target);
@@ -287,6 +319,7 @@ await parent.SendAsync(
287319
}
288320
}
289321

322+
await EnsureTargetsIdsForInit().ConfigureAwait(false);
290323
_targetsIdsForInit.Remove(target.TargetId);
291324

292325
if (!existingTarget)
@@ -313,6 +346,25 @@ await Task.WhenAll(
313346
}
314347
}
315348

349+
private async Task OnAttachedToTargetHandlingExceptionsAsync(object sender, string messageId, TargetAttachedToTargetResponse e)
350+
{
351+
try
352+
{
353+
await OnAttachedToTarget(sender, e).ConfigureAwait(false);
354+
}
355+
catch (Exception ex)
356+
{
357+
HandleExceptionOnMessageReceived(messageId, ex);
358+
}
359+
}
360+
361+
private void HandleExceptionOnMessageReceived(string messageId, Exception ex)
362+
{
363+
var message = $"Browser failed to process {messageId}. {ex.Message}. {ex.StackTrace}";
364+
_logger.LogError(ex, message);
365+
_connection.Close(message);
366+
}
367+
316368
private void FinishInitializationIfReady(string targetId = null)
317369
{
318370
if (targetId != null)

lib/PuppeteerSharp/Helpers/TaskQueue.cs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ namespace PuppeteerSharp.Helpers
77
internal sealed class TaskQueue : IDisposable, IAsyncDisposable
88
{
99
private readonly SemaphoreSlim _semaphore;
10+
private readonly AsyncLocal<bool> _held = new AsyncLocal<bool>();
1011
private int _disposed;
1112

1213
internal TaskQueue() => _semaphore = new SemaphoreSlim(1);
@@ -18,7 +19,11 @@ public void Dispose()
1819
return;
1920
}
2021

21-
_semaphore.Wait();
22+
if (!_held.Value)
23+
{
24+
_semaphore.Wait();
25+
}
26+
2227
_semaphore.Dispose();
2328
}
2429

@@ -29,7 +34,10 @@ public async ValueTask DisposeAsync()
2934
return;
3035
}
3136

32-
await _semaphore.WaitAsync().ConfigureAwait(false);
37+
if (!_held.Value)
38+
{
39+
await _semaphore.WaitAsync().ConfigureAwait(false);
40+
}
3341

3442
_semaphore.Dispose();
3543
}
@@ -39,11 +47,13 @@ internal async Task<T> Enqueue<T>(Func<Task<T>> taskGenerator)
3947
await _semaphore.WaitAsync().ConfigureAwait(false);
4048
try
4149
{
50+
_held.Value = true;
4251
return await taskGenerator().ConfigureAwait(false);
4352
}
4453
finally
4554
{
46-
_semaphore.Release();
55+
TryRelease(_semaphore);
56+
_held.Value = false;
4757
}
4858
}
4959

@@ -52,11 +62,26 @@ internal async Task Enqueue(Func<Task> taskGenerator)
5262
await _semaphore.WaitAsync().ConfigureAwait(false);
5363
try
5464
{
65+
_held.Value = true;
5566
await taskGenerator().ConfigureAwait(false);
5667
}
5768
finally
5869
{
59-
_semaphore.Release();
70+
TryRelease(_semaphore);
71+
_held.Value = false;
72+
}
73+
}
74+
75+
private void TryRelease(SemaphoreSlim semaphore)
76+
{
77+
try
78+
{
79+
semaphore.Release();
80+
}
81+
catch (ObjectDisposedException)
82+
{
83+
// If semaphore has already been disposed, then Release() will fail
84+
// but we can safely ignore it
6085
}
6186
}
6287
}

0 commit comments

Comments
 (0)