From 6aa92373f40896c49c106525c25d37695627bafa Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 6 May 2026 08:52:17 -0700 Subject: [PATCH 1/2] fix(screencast): unblock frame ack when an async client disconnects Fixes: https://github.com/microsoft/playwright/issues/40658 --- packages/playwright-core/src/server/screencast.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index f8729b0ac80d5..dd6c35b1b84ae 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ManualPromise } from '@isomorphic/manualPromise'; import { renderTitleForCall } from '@isomorphic/protocolFormatter'; import { debugLogger } from '@utils/debugLogger'; import { Page } from './page'; @@ -40,6 +41,7 @@ type ActionOptions = { export class Screencast implements InstrumentationListener { readonly page: Page; private _clients = new Set(); + private _disconnected = new Map>(); private _actions: ActionOptions | undefined; private _size: types.Size | undefined; private _lastFrame: types.ScreencastFrame | undefined; @@ -76,6 +78,7 @@ export class Screencast implements InstrumentationListener { addClient(client: ScreencastClient): { size: types.Size } { const isFirst = this._clients.size === 0; this._clients.add(client); + this._disconnected.set(client, new ManualPromise()); if (isFirst) { this._startScreencast(client.size, client.quality); } else if (this._lastFrame) { @@ -95,6 +98,9 @@ export class Screencast implements InstrumentationListener { if (!this._clients.has(client)) return; this._clients.delete(client); + // A departing client must not block frame acks for the remaining clients. + this._disconnected.get(client)!.resolve(); + this._disconnected.delete(client); if (!this._clients.size) this._stopScreencast(); } @@ -133,8 +139,9 @@ export class Screencast implements InstrumentationListener { const asyncResults: Promise[] = []; for (const client of this._clients) { const result = client.onFrame(frame); - if (result) - asyncResults.push(result); + if (!result) + continue; + asyncResults.push(Promise.race([result.catch(() => {}), this._disconnected.get(client)!])); } if (ack) { // Ack when any client resolves (OR logic). This ensures that even if From fbb00104288b7625272c3ec8a4036a0af6dacf18 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 6 May 2026 09:59:51 -0700 Subject: [PATCH 2/2] chore: collapse _clients set + _disconnected map into a single map --- .../playwright-core/src/server/screencast.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index dd6c35b1b84ae..6401075dec993 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -40,8 +40,7 @@ type ActionOptions = { export class Screencast implements InstrumentationListener { readonly page: Page; - private _clients = new Set(); - private _disconnected = new Map>(); + private _clients = new Map>(); private _actions: ActionOptions | undefined; private _size: types.Size | undefined; private _lastFrame: types.ScreencastFrame | undefined; @@ -52,7 +51,7 @@ export class Screencast implements InstrumentationListener { } async handlePageOrContextClose() { - const clients = [...this._clients]; + const clients = [...this._clients.keys()]; this._clients.clear(); for (const client of clients) { if (client.gracefulClose) @@ -61,7 +60,7 @@ export class Screencast implements InstrumentationListener { } dispose() { - for (const client of this._clients) + for (const client of this._clients.keys()) client.dispose(); this._clients.clear(); this.page.instrumentation.removeListener(this); @@ -77,8 +76,7 @@ export class Screencast implements InstrumentationListener { addClient(client: ScreencastClient): { size: types.Size } { const isFirst = this._clients.size === 0; - this._clients.add(client); - this._disconnected.set(client, new ManualPromise()); + this._clients.set(client, new ManualPromise()); if (isFirst) { this._startScreencast(client.size, client.quality); } else if (this._lastFrame) { @@ -95,12 +93,12 @@ export class Screencast implements InstrumentationListener { } removeClient(client: ScreencastClient) { - if (!this._clients.has(client)) + const disconnected = this._clients.get(client); + if (!disconnected) return; this._clients.delete(client); // A departing client must not block frame acks for the remaining clients. - this._disconnected.get(client)!.resolve(); - this._disconnected.delete(client); + disconnected.resolve(); if (!this._clients.size) this._stopScreencast(); } @@ -137,11 +135,11 @@ export class Screencast implements InstrumentationListener { onScreencastFrame(frame: types.ScreencastFrame, ack?: () => void) { this._lastFrame = frame; const asyncResults: Promise[] = []; - for (const client of this._clients) { + for (const [client, disconnected] of this._clients) { const result = client.onFrame(frame); if (!result) continue; - asyncResults.push(Promise.race([result.catch(() => {}), this._disconnected.get(client)!])); + asyncResults.push(Promise.race([result.catch(() => {}), disconnected])); } if (ack) { // Ack when any client resolves (OR logic). This ensures that even if