From 103097fbe395414a9608ad6a2d1d5add272d5114 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 16 Oct 2026 14:15:36 -0700 Subject: [PATCH 1/3] fix(threading): snapshot collection getters under the dispatch lock Pages, ChildFrames, Workers, ServiceWorkers, Frames and Contexts now return copies taken under Connection.DispatchLock, and the message pump adds and removes elements under the same lock. Enumerating them while the pump mutates them can no longer throw or observe a torn copy. --- src/Playwright/Core/Browser.cs | 9 ++++--- src/Playwright/Core/BrowserContext.cs | 21 ++++++++++++----- src/Playwright/Core/BrowserType.cs | 4 ++-- src/Playwright/Core/Frame.cs | 2 +- src/Playwright/Core/Page.cs | 30 +++++++++++++++++------- src/Playwright/Core/Worker.cs | 10 +++----- src/Playwright/Transport/ChannelOwner.cs | 10 ++++++++ src/Playwright/Transport/Connection.cs | 7 ++++++ 8 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/Playwright/Core/Browser.cs b/src/Playwright/Core/Browser.cs index e5e2b874d..384785c58 100644 --- a/src/Playwright/Core/Browser.cs +++ b/src/Playwright/Core/Browser.cs @@ -57,7 +57,7 @@ internal Browser(ChannelOwner parent, string guid, BrowserInitializer initialize public event EventHandler? Context; - public IReadOnlyList Contexts => _contexts.ToArray(); + public IReadOnlyList Contexts => Snapshot(_contexts); public bool IsConnected { get; private set; } @@ -281,7 +281,7 @@ internal void ConnectToBrowserType(BrowserType browserType, string? tracesDir) // This is why browser type is not wired up in the constructor, and instead this separate method is called later on. _browserType = browserType; _tracesDir = tracesDir; - foreach (var context in _contexts) + foreach (var context in Snapshot(_contexts)) { context._tracing._tracesDir = this._tracesDir; context._request._tracing._tracesDir = this._tracesDir; @@ -292,7 +292,10 @@ internal void ConnectToBrowserType(BrowserType browserType, string? tracesDir) private void DidCreateContext(BrowserContext context) { context._browser = this; - _contexts.Add(context); + lock (_connection.DispatchLock) + { + _contexts.Add(context); + } // Note: when connecting to a browser, initial contexts arrive before `_browserType` is set, // and will be configured later in `ConnectToBrowserType`. if (_browserType != null) diff --git a/src/Playwright/Core/BrowserContext.cs b/src/Playwright/Core/BrowserContext.cs index 6f41d2d44..41e708969 100644 --- a/src/Playwright/Core/BrowserContext.cs +++ b/src/Playwright/Core/BrowserContext.cs @@ -159,7 +159,7 @@ public event EventHandler RequestFailed public IBrowser? Browser => _browser; - public IReadOnlyList Pages => _pages; + public IReadOnlyList Pages => Snapshot(_pages); internal Page? OwnerPage { get; set; } @@ -173,7 +173,7 @@ public event EventHandler RequestFailed public IAPIRequestContext APIRequest => _request; - public IReadOnlyList ServiceWorkers => _serviceWorkers; + public IReadOnlyList ServiceWorkers => Snapshot(_serviceWorkers); public IReadOnlyList BackgroundPages => new List(); @@ -245,7 +245,10 @@ internal override void OnMessage(string method, JsonElement serverParams) { var serviceWorker = serverParams.GetProperty("worker").ToObject(_connection.DefaultJsonSerializerOptions)!; ((Worker)serviceWorker).Context = this; - _serviceWorkers.Add(serviceWorker); + lock (_connection.DispatchLock) + { + _serviceWorkers.Add(serviceWorker); + } ServiceWorker?.Invoke(this, serviceWorker); break; } @@ -858,7 +861,10 @@ internal void OnClose() ClosingOrClosed = true; if (Browser != null) { - ((Browser)Browser)._contexts.Remove(this); + lock (_connection.DispatchLock) + { + ((Browser)Browser)._contexts.Remove(this); + } ((Browser)Browser)._browserType.Playwright._selectors._contextsForSelectors.Remove(this); } @@ -884,7 +890,10 @@ internal void OnClose() private void Channel_OnPage(object sender, Page page) { page.Context = this; - _pages.Add(page); + lock (_connection.DispatchLock) + { + _pages.Add(page); + } Page?.Invoke(this, page); if (page.Opener?.IsClosed == false) @@ -905,7 +914,7 @@ private void Channel_BindingCall(BindingCall bindingCall) private async Task ExposeBindingAsync(string name, Delegate callback) { - foreach (var page in _pages) + foreach (var page in Snapshot(_pages)) { if (page.Bindings.ContainsKey(name)) { diff --git a/src/Playwright/Core/BrowserType.cs b/src/Playwright/Core/BrowserType.cs index 18a81c90e..cdb4d6011 100644 --- a/src/Playwright/Core/BrowserType.cs +++ b/src/Playwright/Core/BrowserType.cs @@ -187,9 +187,9 @@ void ClosePipe() void OnPipeClosed(string? reason = null) { // Emulate all pages, contexts and the browser closing upon disconnect. - foreach (BrowserContext context in browser?._contexts.ToArray() ?? Array.Empty()) + foreach (BrowserContext context in browser?.Snapshot(browser._contexts) ?? Array.Empty()) { - foreach (Page page in context._pages.ToArray()) + foreach (Page page in context.Snapshot(context._pages)) { page.OnClose(); } diff --git a/src/Playwright/Core/Frame.cs b/src/Playwright/Core/Frame.cs index ee9551cf4..bdfbc3f60 100644 --- a/src/Playwright/Core/Frame.cs +++ b/src/Playwright/Core/Frame.cs @@ -64,7 +64,7 @@ internal Frame(ChannelOwner parent, string guid, FrameInitializer initializer) : /// public event EventHandler? LoadState; - public IReadOnlyList