From 6aa92373f40896c49c106525c25d37695627bafa Mon Sep 17 00:00:00 2001
From: Pavel Feldman <pavel.feldman@gmail.com>
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<ScreencastClient>();
+  private _disconnected = new Map<ScreencastClient, ManualPromise<void>>();
   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<void>());
     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<void>[] = [];
     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 <pavel.feldman@gmail.com>
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<ScreencastClient>();
-  private _disconnected = new Map<ScreencastClient, ManualPromise<void>>();
+  private _clients = new Map<ScreencastClient, ManualPromise<void>>();
   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<void>());
+    this._clients.set(client, new ManualPromise<void>());
     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<void>[] = [];
-    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
