From ba43cc2bce60dbde219f2fd1da071a3e00486950 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 11:01:44 +0200
Subject: [PATCH 1/7] chore(cli): mirror cli-daemon spawn pattern in `cli show`

Follow-up to #40725. Replace the dedicated fd-3 byte-pipe ready signal
with the text-protocol used by the cli-daemon (`### Success`/`### Error`
markers on stdout, terminated by `<EOF>`), and trigger child shutdown by
closing the stdin pipe (handled by `selfDestructOnParentGone`) instead
of `SIGKILL`.

Refs https://github.com/microsoft/playwright/pull/40725
---
 .../src/tools/cli-client/program.ts           |  32 +++--
 .../src/tools/dashboard/dashboardApp.ts       | 114 +++++++++++-------
 2 files changed, 91 insertions(+), 55 deletions(-)

diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts
index 1c8d21574d34c..c3a3204311835 100644
--- a/packages/playwright-core/src/tools/cli-client/program.ts
+++ b/packages/playwright-core/src/tools/cli-client/program.ts
@@ -33,7 +33,6 @@ import { minimist } from './minimist';
 import type { ListData, ListedBrowser, Output } from './output';
 import type { ClientInfo, SessionFile } from './registry';
 import type { MinimistArgs } from './minimist';
-import type { Readable } from 'stream';
 
 type GlobalOptions = {
   help?: boolean;
@@ -230,35 +229,48 @@ export async function program(options?: { embedderVersion?: string}) {
       const foreground = args.port !== undefined;
       const child = spawn(process.execPath, daemonArgs, {
         detached: !foreground,
-        stdio: foreground ? 'inherit' : ['ignore', 'ignore', 'ignore', 'pipe'],
+        stdio: foreground ? 'inherit' : ['pipe', 'pipe', 'ignore'],
       });
       if (foreground) {
         await new Promise<void>(resolve => child.on('exit', () => resolve()));
         return;
       }
-      const readyStream = (child.stdio as unknown as Readable[])[3];
       try {
         await new Promise<void>((resolve, reject) => {
+          let outLog = '';
           const settle = (err?: Error) => {
             clearTimeout(timer);
-            readyStream.destroy();
+            child.stdout!.removeAllListeners();
+            child.removeAllListeners('exit');
             if (err)
               reject(err);
             else
               resolve();
           };
-          const timer = setTimeout(() => settle(new Error('Dashboard daemon did not spin up within 60s, killing it')), 60_000);
-          readyStream.once('data', () => settle());
-          readyStream.once('error', err => settle(err));
+          const timer = setTimeout(() => settle(new Error('Dashboard daemon did not spin up within 60s')), 60_000);
+          child.stdout!.on('data', data => {
+            outLog += data.toString();
+            if (!outLog.includes('<EOF>'))
+              return;
+            const errorMatch = outLog.match(/### Error\n([\s\S]*)<EOF>/);
+            if (errorMatch) {
+              settle(new Error(errorMatch[1].trim()));
+              return;
+            }
+            if (outLog.match(/### Success\n[\s\S]*<EOF>/))
+              settle();
+          });
+          child.stdout!.once('error', err => settle(err));
           child.once('exit', (code, signal) => settle(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY`)));
         });
       } catch (err) {
-        if (child.exitCode === null && child.signalCode === null) {
-          child.kill('SIGKILL');
+        child.stdin!.destroy();
+        child.stdout!.destroy();
+        if (child.exitCode === null && child.signalCode === null)
           await new Promise<void>(resolve => child.once('exit', () => resolve()));
-        }
         throw err;
       }
+      child.stdout!.destroy();
       child.unref();
       output.show(sessionName, child.pid);
       return;
diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index b0cec86fee003..343e860bf166b 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -311,52 +311,76 @@ export async function openDashboardApp() {
     selfDestructOnParentGone();
     return;
   }
-  let server: net.Server | undefined;
-  process.on('exit', () => server?.close());
+  // Self-destruct if the parent CLI dies before we signal READY. Unregistered
+  // after success so the daemon outlives the parent.
+  const stopSelfDestruct = selfDestructOnParentGone();
   try {
-    server = await acquireSingleton(options);
-  } catch {
-    return;
-  }
-  const statePromise = innerOpenDashboardApp(options);
-  server?.on('connection', socket => {
-    let buffer = '';
-    socket.on('data', data => {
-      buffer += data.toString();
-      const newlineIndex = buffer.indexOf('\n');
-      if (newlineIndex === -1)
-        return;
-      const line = buffer.slice(0, newlineIndex);
-      buffer = buffer.slice(newlineIndex + 1);
-      let parsed: DashboardOptions | undefined;
-      try {
-        parsed = JSON.parse(line);
-      } catch {
-        // no-op
-      }
-      if (!parsed) {
-        socket.end();
-        return;
-      }
-      void statePromise.then(({ page, server: dashboard }) => {
-        if (parsed.annotate) {
-          page?.bringToFront().catch(() => {});
-          dashboard.reveal(parsed);
-          dashboard.triggerAnnotate();
-          dashboard.registerAnnotateWaiter(socket);
-        } else if (parsed.kill) {
-          socket.end();
-          gracefullyProcessExitDoNotHang(0);
-        } else {
-          page?.bringToFront().catch(() => {});
-          dashboard.reveal(parsed);
+    let server: net.Server;
+    try {
+      server = await acquireSingleton(options);
+    } catch {
+      // Another daemon is already running; acquireSingleton forwarded our
+      // options to it. Signal success so the parent doesn't treat our clean
+      // exit as a startup failure.
+      // eslint-disable-next-line no-console
+      console.log('### Success\nDashboard already running');
+      // eslint-disable-next-line no-console
+      console.log('<EOF>');
+      return;
+    }
+    process.on('exit', () => server.close());
+    const statePromise = innerOpenDashboardApp(options);
+    server.on('connection', socket => {
+      let buffer = '';
+      socket.on('data', data => {
+        buffer += data.toString();
+        const newlineIndex = buffer.indexOf('\n');
+        if (newlineIndex === -1)
+          return;
+        const line = buffer.slice(0, newlineIndex);
+        buffer = buffer.slice(newlineIndex + 1);
+        let parsed: DashboardOptions | undefined;
+        try {
+          parsed = JSON.parse(line);
+        } catch {
+          // no-op
+        }
+        if (!parsed) {
           socket.end();
+          return;
         }
+        void statePromise.then(({ page, server: dashboard }) => {
+          if (parsed.annotate) {
+            page?.bringToFront().catch(() => {});
+            dashboard.reveal(parsed);
+            dashboard.triggerAnnotate();
+            dashboard.registerAnnotateWaiter(socket);
+          } else if (parsed.kill) {
+            socket.end();
+            gracefullyProcessExitDoNotHang(0);
+          } else {
+            page?.bringToFront().catch(() => {});
+            dashboard.reveal(parsed);
+            socket.end();
+          }
+        });
       });
     });
-  });
-  await statePromise;
-  try { fs.writeSync(3, '.'); fs.closeSync(3); } catch {}
+    await statePromise;
+    // eslint-disable-next-line no-console
+    console.log('### Success\nDashboard ready');
+    // eslint-disable-next-line no-console
+    console.log('<EOF>');
+  } catch (error) {
+    const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message;
+    // eslint-disable-next-line no-console
+    console.log(`### Error\n${message}`);
+    // eslint-disable-next-line no-console
+    console.log('<EOF>');
+    gracefullyProcessExitDoNotHang(1);
+    return;
+  }
+  stopSelfDestruct();
 }
 
 export async function openDashboardForContext(context: api.BrowserContext): Promise<void> {
@@ -426,8 +450,8 @@ async function runAnnotateClient(options: DashboardOptions): Promise<void> {
   console.log(text);
 }
 
-function selfDestructOnParentGone() {
-  process.stdin.on('close', () => {
-    gracefullyProcessExitDoNotHang(0);
-  });
+function selfDestructOnParentGone(): () => void {
+  const onClose = () => gracefullyProcessExitDoNotHang(0);
+  process.stdin.on('close', onClose);
+  return () => process.stdin.off('close', onClose);
 }

From f9d72b7ceddd1916bbe61772a1740610403eab99 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 12:03:21 +0200
Subject: [PATCH 2/7] fix(cli show): address PR feedback

- Fix race: stopSelfDestruct() before printing success markers
- Already-running branch now also calls stopSelfDestruct()
- Drop PWDEBUGIMPL gating; always include error stack
- Drop ### Error regex parsing; surface full child output on failure
- Destroy stdin on success in parent
- Singleton holder writes its pid on connect; success markers carry
  pid=<holder pid> so the parent reports the holder pid (not the
  spawned-and-exited child pid)
- Move process.on('exit', () => server.close()) into acquireSingleton
- Add regression test asserting concurrent cli show invocations both
  succeed and report the same dashboardPid
---
 .../src/tools/cli-client/program.ts           |  18 +--
 .../src/tools/cli-client/session.ts           |  16 +--
 .../src/tools/dashboard/dashboardApp.ts       | 132 ++++++++++--------
 tests/mcp/dashboard.spec.ts                   |  11 ++
 4 files changed, 104 insertions(+), 73 deletions(-)

diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts
index c3a3204311835..400d4c347fb8e 100644
--- a/packages/playwright-core/src/tools/cli-client/program.ts
+++ b/packages/playwright-core/src/tools/cli-client/program.ts
@@ -235,6 +235,7 @@ export async function program(options?: { embedderVersion?: string}) {
         await new Promise<void>(resolve => child.on('exit', () => resolve()));
         return;
       }
+      let dashboardPid: number | undefined;
       try {
         await new Promise<void>((resolve, reject) => {
           let outLog = '';
@@ -252,16 +253,16 @@ export async function program(options?: { embedderVersion?: string}) {
             outLog += data.toString();
             if (!outLog.includes('<EOF>'))
               return;
-            const errorMatch = outLog.match(/### Error\n([\s\S]*)<EOF>/);
-            if (errorMatch) {
-              settle(new Error(errorMatch[1].trim()));
-              return;
-            }
-            if (outLog.match(/### Success\n[\s\S]*<EOF>/))
+            const successMatch = outLog.match(/### Success\n[^\n]*pid=(\d+)[^\n]*\n<EOF>/);
+            if (successMatch) {
+              dashboardPid = +successMatch[1];
               settle();
+            } else {
+              settle(new Error(outLog.trim()));
+            }
           });
           child.stdout!.once('error', err => settle(err));
-          child.once('exit', (code, signal) => settle(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY`)));
+          child.once('exit', (code, signal) => settle(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? '\n' + outLog : ''}`)));
         });
       } catch (err) {
         child.stdin!.destroy();
@@ -270,9 +271,10 @@ export async function program(options?: { embedderVersion?: string}) {
           await new Promise<void>(resolve => child.once('exit', () => resolve()));
         throw err;
       }
+      child.stdin!.destroy();
       child.stdout!.destroy();
       child.unref();
-      output.show(sessionName, child.pid);
+      output.show(sessionName, dashboardPid);
       return;
     }
     default: {
diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts
index ce533cbe420cc..86329934b2404 100644
--- a/packages/playwright-core/src/tools/cli-client/session.ts
+++ b/packages/playwright-core/src/tools/cli-client/session.ts
@@ -168,21 +168,17 @@ export class Session {
         outLog += data.toString();
         if (!outLog.includes('<EOF>'))
           return;
-        const errorMatch = outLog.match(/### Error\n([\s\S]*)<EOF>/);
-        const error = errorMatch ? errorMatch[1].trim() : undefined;
-        if (error) {
-          const errLogContent = fs.readFileSync(errLog, 'utf-8');
-          rejectWithPid(reject, error + (errLogContent ? '\n' + errLogContent : ''));
-        }
-
-        const successMatch = outLog.match(/### Success\nDaemon listening on (.*)\n<EOF>/);
-        if (successMatch)
+        if (outLog.match(/### Success\nDaemon listening on (.*)\n<EOF>/)) {
           resolve();
+          return;
+        }
+        const errLogContent = fs.readFileSync(errLog, 'utf-8');
+        rejectWithPid(reject, outLog.trim() + (errLogContent ? '\n' + errLogContent : ''));
       });
       child.on('close', code => {
         if (!signalled) {
           const errLogContent = fs.readFileSync(errLog, 'utf-8');
-          rejectWithPid(reject, `Daemon process exited with code ${code}` + (errLogContent ? '\n' + errLogContent : ''));
+          rejectWithPid(reject, `Daemon process exited with code ${code}` + (outLog ? '\n' + outLog : '') + (errLogContent ? '\n' + errLogContent : ''));
         }
       });
     });
diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index 343e860bf166b..a3d7437d4cc71 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -266,25 +266,46 @@ function parseOpenArgs(): DashboardOptions {
   };
 }
 
-async function acquireSingleton(options: DashboardOptions): Promise<net.Server> {
+async function acquireSingleton(options: DashboardOptions): Promise<{ server: net.Server } | { peerPid: number }> {
   const socketPath = dashboardSocketPath();
   if (process.platform !== 'win32')
     await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
 
   return await new Promise((resolve, reject) => {
-    const server = net.createServer();
-    server.listen(socketPath, () => resolve(server));
+    const server = net.createServer(socket => {
+      socket.write(JSON.stringify({ pid: process.pid }) + '\n');
+    });
+    server.listen(socketPath, () => {
+      process.on('exit', () => server.close());
+      resolve({ server });
+    });
     server.on('error', (err: NodeJS.ErrnoException) => {
       if (err.code !== 'EADDRINUSE')
         return reject(err);
       const client = net.connect(socketPath, () => {
-        client.write(JSON.stringify(options) + '\n');
-        reject(new Error('already running'));
+        let buffer = '';
+        client.on('data', data => {
+          buffer += data.toString();
+          const newlineIndex = buffer.indexOf('\n');
+          if (newlineIndex === -1)
+            return;
+          client.removeAllListeners('data');
+          try {
+            const { pid } = JSON.parse(buffer.slice(0, newlineIndex));
+            client.write(JSON.stringify(options) + '\n');
+            resolve({ peerPid: pid });
+          } catch (e) {
+            reject(e);
+          }
+        });
       });
       client.on('error', () => {
         if (process.platform !== 'win32')
           fs.unlinkSync(socketPath);
-        server.listen(socketPath, () => resolve(server));
+        server.listen(socketPath, () => {
+          process.on('exit', () => server.close());
+          resolve({ server });
+        });
       });
     });
   });
@@ -312,75 +333,76 @@ export async function openDashboardApp() {
     return;
   }
   // Self-destruct if the parent CLI dies before we signal READY. Unregistered
-  // after success so the daemon outlives the parent.
+  // before we signal so the daemon outlives the parent.
   const stopSelfDestruct = selfDestructOnParentGone();
   try {
-    let server: net.Server;
-    try {
-      server = await acquireSingleton(options);
-    } catch {
+    const acquired = await acquireSingleton(options);
+    if ('peerPid' in acquired) {
       // Another daemon is already running; acquireSingleton forwarded our
       // options to it. Signal success so the parent doesn't treat our clean
       // exit as a startup failure.
+      stopSelfDestruct();
       // eslint-disable-next-line no-console
-      console.log('### Success\nDashboard already running');
+      console.log(`### Success\nDashboard already running pid=${acquired.peerPid}`);
       // eslint-disable-next-line no-console
       console.log('<EOF>');
       return;
     }
-    process.on('exit', () => server.close());
-    const statePromise = innerOpenDashboardApp(options);
-    server.on('connection', socket => {
-      let buffer = '';
-      socket.on('data', data => {
-        buffer += data.toString();
-        const newlineIndex = buffer.indexOf('\n');
-        if (newlineIndex === -1)
-          return;
-        const line = buffer.slice(0, newlineIndex);
-        buffer = buffer.slice(newlineIndex + 1);
-        let parsed: DashboardOptions | undefined;
-        try {
-          parsed = JSON.parse(line);
-        } catch {
-          // no-op
-        }
-        if (!parsed) {
-          socket.end();
-          return;
-        }
-        void statePromise.then(({ page, server: dashboard }) => {
-          if (parsed.annotate) {
-            page?.bringToFront().catch(() => {});
-            dashboard.reveal(parsed);
-            dashboard.triggerAnnotate();
-            dashboard.registerAnnotateWaiter(socket);
-          } else if (parsed.kill) {
-            socket.end();
-            gracefullyProcessExitDoNotHang(0);
-          } else {
-            page?.bringToFront().catch(() => {});
-            dashboard.reveal(parsed);
-            socket.end();
-          }
-        });
-      });
-    });
-    await statePromise;
+    await startApp(acquired.server, options);
+    stopSelfDestruct();
     // eslint-disable-next-line no-console
-    console.log('### Success\nDashboard ready');
+    console.log(`### Success\nDashboard ready pid=${process.pid}`);
     // eslint-disable-next-line no-console
     console.log('<EOF>');
   } catch (error) {
-    const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message;
+    const message = (error as Error).stack || (error as Error).message;
     // eslint-disable-next-line no-console
     console.log(`### Error\n${message}`);
     // eslint-disable-next-line no-console
     console.log('<EOF>');
     gracefullyProcessExitDoNotHang(1);
-    return;
   }
-  stopSelfDestruct();
+}
+
+async function startApp(server: net.Server, options: DashboardOptions) {
+  const statePromise = innerOpenDashboardApp(options);
+  server.on('connection', socket => {
+    let buffer = '';
+    socket.on('data', data => {
+      buffer += data.toString();
+      const newlineIndex = buffer.indexOf('\n');
+      if (newlineIndex === -1)
+        return;
+      const line = buffer.slice(0, newlineIndex);
+      buffer = buffer.slice(newlineIndex + 1);
+      let parsed: DashboardOptions | undefined;
+      try {
+        parsed = JSON.parse(line);
+      } catch {
+        // no-op
+      }
+      if (!parsed) {
+        socket.end();
+        return;
+      }
+      void statePromise.then(({ page, server: dashboard }) => {
+        if (parsed.annotate) {
+          page?.bringToFront().catch(() => {});
+          dashboard.reveal(parsed);
+          dashboard.triggerAnnotate();
+          dashboard.registerAnnotateWaiter(socket);
+        } else if (parsed.kill) {
+          socket.end();
+          gracefullyProcessExitDoNotHang(0);
+        } else {
+          page?.bringToFront().catch(() => {});
+          dashboard.reveal(parsed);
+          socket.end();
+        }
+      });
+    });
+  });
+  await statePromise;
 }
 
 export async function openDashboardForContext(context: api.BrowserContext): Promise<void> {
diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts
index f9a52b515f2b4..20222e00c1f26 100644
--- a/tests/mcp/dashboard.spec.ts
+++ b/tests/mcp/dashboard.spec.ts
@@ -178,3 +178,14 @@ test('save recording streams WebM bytes to the chosen file', async ({ cli, serve
   // WebM files start with the EBML magic bytes.
   expect(bytes.subarray(0, 4)).toEqual(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
 });
+
+test('two concurrent cli show invocations both succeed', async ({ cli }) => {
+  const bindTitle = `--playwright-internal--${crypto.randomUUID()}`;
+  const [first, second] = await Promise.all([cli('show', { bindTitle }), cli('show', { bindTitle })]);
+  expect(first.exitCode, first.error).toBe(0);
+  expect(second.exitCode, second.error).toBe(0);
+  expect(first.dashboardPid).toBeTruthy();
+  expect(second.dashboardPid).toBeTruthy();
+  expect(first.dashboardPid).toBe(second.dashboardPid);
+  await cli('show', '--kill');
+});

From 1d68c512cf0c55c8688ca7c8db11d70307438af4 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 12:08:57 +0200
Subject: [PATCH 3/7] fix(cli show): handle EEXIST in singleton race
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

When two cli show invocations race, the loser's server.listen() can
fail with EEXIST (not EADDRINUSE) — the winner created the socket
file before the loser bound to it. Treat both codes as "peer exists,
try to connect".

Also simplify the regression test to just assert pid equality.
---
 packages/playwright-core/src/tools/dashboard/dashboardApp.ts | 2 +-
 tests/mcp/dashboard.spec.ts                                  | 4 ----
 2 files changed, 1 insertion(+), 5 deletions(-)

diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index a3d7437d4cc71..270f61aff5a67 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -280,7 +280,7 @@ async function acquireSingleton(options: DashboardOptions): Promise<{ server: ne
       resolve({ server });
     });
     server.on('error', (err: NodeJS.ErrnoException) => {
-      if (err.code !== 'EADDRINUSE')
+      if (err.code !== 'EADDRINUSE' && err.code !== 'EEXIST')
         return reject(err);
       const client = net.connect(socketPath, () => {
         let buffer = '';
diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts
index 20222e00c1f26..5addc88fb3704 100644
--- a/tests/mcp/dashboard.spec.ts
+++ b/tests/mcp/dashboard.spec.ts
@@ -182,10 +182,6 @@ test('save recording streams WebM bytes to the chosen file', async ({ cli, serve
 test('two concurrent cli show invocations both succeed', async ({ cli }) => {
   const bindTitle = `--playwright-internal--${crypto.randomUUID()}`;
   const [first, second] = await Promise.all([cli('show', { bindTitle }), cli('show', { bindTitle })]);
-  expect(first.exitCode, first.error).toBe(0);
-  expect(second.exitCode, second.error).toBe(0);
-  expect(first.dashboardPid).toBeTruthy();
-  expect(second.dashboardPid).toBeTruthy();
   expect(first.dashboardPid).toBe(second.dashboardPid);
   await cli('show', '--kill');
 });

From d3497c2d55989394ff5b45c45dbd66920d4800a0 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 12:17:33 +0200
Subject: [PATCH 4/7] fix(cli show): make handshake a single round-trip

Loser writes options first, holder responds with {pid} as the ack
(bundled with socket.end for non-annotate paths). The pid serves as
both the holder identifier and the request-accepted signal.

Loser side: register data (buffer) and end (parse) listeners first,
then write options on connect.
---
 .../src/tools/dashboard/dashboardApp.ts       | 41 +++++++++----------
 1 file changed, 20 insertions(+), 21 deletions(-)

diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index 270f61aff5a67..0b1e67da27147 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -272,9 +272,7 @@ async function acquireSingleton(options: DashboardOptions): Promise<{ server: ne
     await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
 
   return await new Promise((resolve, reject) => {
-    const server = net.createServer(socket => {
-      socket.write(JSON.stringify({ pid: process.pid }) + '\n');
-    });
+    const server = net.createServer();
     server.listen(socketPath, () => {
       process.on('exit', () => server.close());
       resolve({ server });
@@ -282,22 +280,21 @@ async function acquireSingleton(options: DashboardOptions): Promise<{ server: ne
     server.on('error', (err: NodeJS.ErrnoException) => {
       if (err.code !== 'EADDRINUSE' && err.code !== 'EEXIST')
         return reject(err);
-      const client = net.connect(socketPath, () => {
-        let buffer = '';
-        client.on('data', data => {
-          buffer += data.toString();
-          const newlineIndex = buffer.indexOf('\n');
-          if (newlineIndex === -1)
-            return;
-          client.removeAllListeners('data');
-          try {
-            const { pid } = JSON.parse(buffer.slice(0, newlineIndex));
-            client.write(JSON.stringify(options) + '\n');
-            resolve({ peerPid: pid });
-          } catch (e) {
-            reject(e);
-          }
-        });
+      const client = net.connect(socketPath);
+      let buffer = '';
+      client.on('data', data => {
+        buffer += data.toString();
+      });
+      client.on('end', () => {
+        try {
+          const { pid } = JSON.parse(buffer);
+          resolve({ peerPid: pid });
+        } catch (e) {
+          reject(e);
+        }
+      });
+      client.on('connect', () => {
+        client.write(JSON.stringify(options) + '\n');
       });
       client.on('error', () => {
         if (process.platform !== 'win32')
@@ -386,18 +383,20 @@ async function startApp(server: net.Server, options: DashboardOptions) {
         return;
       }
       void statePromise.then(({ page, server: dashboard }) => {
+        const ack = JSON.stringify({ pid: process.pid }) + '\n';
         if (parsed.annotate) {
+          socket.write(ack);
           page?.bringToFront().catch(() => {});
           dashboard.reveal(parsed);
           dashboard.triggerAnnotate();
           dashboard.registerAnnotateWaiter(socket);
         } else if (parsed.kill) {
-          socket.end();
+          socket.end(ack);
           gracefullyProcessExitDoNotHang(0);
         } else {
           page?.bringToFront().catch(() => {});
           dashboard.reveal(parsed);
-          socket.end();
+          socket.end(ack);
         }
       });
     });

From 1bfc9e2c47681318ac9ab7ee47756380a9c73275 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 13:11:08 +0200
Subject: [PATCH 5/7] fix(cli show): only ack with pid on the singleton
 handshake

The annotate client reads the entire socket payload as the
annotation result; writing a pid ack on that path corrupts it.
Restrict the pid ack to the normal (singleton-acquire) branch.
---
 .../playwright-core/src/tools/dashboard/dashboardApp.ts     | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index 0b1e67da27147..4ad6da98269fe 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -383,20 +383,18 @@ async function startApp(server: net.Server, options: DashboardOptions) {
         return;
       }
       void statePromise.then(({ page, server: dashboard }) => {
-        const ack = JSON.stringify({ pid: process.pid }) + '\n';
         if (parsed.annotate) {
-          socket.write(ack);
           page?.bringToFront().catch(() => {});
           dashboard.reveal(parsed);
           dashboard.triggerAnnotate();
           dashboard.registerAnnotateWaiter(socket);
         } else if (parsed.kill) {
-          socket.end(ack);
+          socket.end();
           gracefullyProcessExitDoNotHang(0);
         } else {
           page?.bringToFront().catch(() => {});
           dashboard.reveal(parsed);
-          socket.end(ack);
+          socket.end(JSON.stringify({ pid: process.pid }) + '\n');
         }
       });
     });

From db57bb0d4ebbd4fa340de27b9a2d83844a14d686 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 15:55:33 +0200
Subject: [PATCH 6/7] revert(cli show): drop pid comparison and EEXIST handling

Per review: the pid round-trip and EEXIST race-loser handling made
the singleton handshake harder to reason about. Revert both, plus
the concurrent test that relied on the pid comparison.
---
 .../src/tools/cli-client/program.ts           | 10 +---
 .../src/tools/dashboard/dashboardApp.ts       | 56 ++++++++-----------
 tests/mcp/dashboard.spec.ts                   |  7 ---
 3 files changed, 26 insertions(+), 47 deletions(-)

diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts
index 400d4c347fb8e..c01d0aaf70337 100644
--- a/packages/playwright-core/src/tools/cli-client/program.ts
+++ b/packages/playwright-core/src/tools/cli-client/program.ts
@@ -235,7 +235,6 @@ export async function program(options?: { embedderVersion?: string}) {
         await new Promise<void>(resolve => child.on('exit', () => resolve()));
         return;
       }
-      let dashboardPid: number | undefined;
       try {
         await new Promise<void>((resolve, reject) => {
           let outLog = '';
@@ -253,13 +252,10 @@ export async function program(options?: { embedderVersion?: string}) {
             outLog += data.toString();
             if (!outLog.includes('<EOF>'))
               return;
-            const successMatch = outLog.match(/### Success\n[^\n]*pid=(\d+)[^\n]*\n<EOF>/);
-            if (successMatch) {
-              dashboardPid = +successMatch[1];
+            if (outLog.match(/### Success\n[\s\S]*<EOF>/))
               settle();
-            } else {
+            else
               settle(new Error(outLog.trim()));
-            }
           });
           child.stdout!.once('error', err => settle(err));
           child.once('exit', (code, signal) => settle(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? '\n' + outLog : ''}`)));
@@ -274,7 +270,7 @@ export async function program(options?: { embedderVersion?: string}) {
       child.stdin!.destroy();
       child.stdout!.destroy();
       child.unref();
-      output.show(sessionName, dashboardPid);
+      output.show(sessionName, child.pid);
       return;
     }
     default: {
diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index 4ad6da98269fe..bee2589fd9550 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -266,7 +266,7 @@ function parseOpenArgs(): DashboardOptions {
   };
 }
 
-async function acquireSingleton(options: DashboardOptions): Promise<{ server: net.Server } | { peerPid: number }> {
+async function acquireSingleton(options: DashboardOptions): Promise<net.Server> {
   const socketPath = dashboardSocketPath();
   if (process.platform !== 'win32')
     await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
@@ -275,33 +275,21 @@ async function acquireSingleton(options: DashboardOptions): Promise<{ server: ne
     const server = net.createServer();
     server.listen(socketPath, () => {
       process.on('exit', () => server.close());
-      resolve({ server });
+      resolve(server);
     });
     server.on('error', (err: NodeJS.ErrnoException) => {
-      if (err.code !== 'EADDRINUSE' && err.code !== 'EEXIST')
+      if (err.code !== 'EADDRINUSE')
         return reject(err);
-      const client = net.connect(socketPath);
-      let buffer = '';
-      client.on('data', data => {
-        buffer += data.toString();
-      });
-      client.on('end', () => {
-        try {
-          const { pid } = JSON.parse(buffer);
-          resolve({ peerPid: pid });
-        } catch (e) {
-          reject(e);
-        }
-      });
-      client.on('connect', () => {
+      const client = net.connect(socketPath, () => {
         client.write(JSON.stringify(options) + '\n');
+        reject(new Error('already running'));
       });
       client.on('error', () => {
         if (process.platform !== 'win32')
           fs.unlinkSync(socketPath);
         server.listen(socketPath, () => {
           process.on('exit', () => server.close());
-          resolve({ server });
+          resolve(server);
         });
       });
     });
@@ -332,23 +320,25 @@ export async function openDashboardApp() {
   // Self-destruct if the parent CLI dies before we signal READY. Unregistered
   // before we signal so the daemon outlives the parent.
   const stopSelfDestruct = selfDestructOnParentGone();
+  let server: net.Server;
   try {
-    const acquired = await acquireSingleton(options);
-    if ('peerPid' in acquired) {
-      // Another daemon is already running; acquireSingleton forwarded our
-      // options to it. Signal success so the parent doesn't treat our clean
-      // exit as a startup failure.
-      stopSelfDestruct();
-      // eslint-disable-next-line no-console
-      console.log(`### Success\nDashboard already running pid=${acquired.peerPid}`);
-      // eslint-disable-next-line no-console
-      console.log('<EOF>');
-      return;
-    }
-    await startApp(acquired.server, options);
+    server = await acquireSingleton(options);
+  } catch {
+    // Another daemon is already running; acquireSingleton forwarded our
+    // options to it. Signal success so the parent doesn't treat our clean
+    // exit as a startup failure.
     stopSelfDestruct();
     // eslint-disable-next-line no-console
-    console.log(`### Success\nDashboard ready pid=${process.pid}`);
+    console.log('### Success\nDashboard already running');
+    // eslint-disable-next-line no-console
+    console.log('<EOF>');
+    return;
+  }
+  try {
+    await startApp(server, options);
+    stopSelfDestruct();
+    // eslint-disable-next-line no-console
+    console.log('### Success\nDashboard ready');
     // eslint-disable-next-line no-console
     console.log('<EOF>');
   } catch (error) {
@@ -394,7 +384,7 @@ async function startApp(server: net.Server, options: DashboardOptions) {
         } else {
           page?.bringToFront().catch(() => {});
           dashboard.reveal(parsed);
-          socket.end(JSON.stringify({ pid: process.pid }) + '\n');
+          socket.end();
         }
       });
     });
diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts
index 5addc88fb3704..f9a52b515f2b4 100644
--- a/tests/mcp/dashboard.spec.ts
+++ b/tests/mcp/dashboard.spec.ts
@@ -178,10 +178,3 @@ test('save recording streams WebM bytes to the chosen file', async ({ cli, serve
   // WebM files start with the EBML magic bytes.
   expect(bytes.subarray(0, 4)).toEqual(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
 });
-
-test('two concurrent cli show invocations both succeed', async ({ cli }) => {
-  const bindTitle = `--playwright-internal--${crypto.randomUUID()}`;
-  const [first, second] = await Promise.all([cli('show', { bindTitle }), cli('show', { bindTitle })]);
-  expect(first.dashboardPid).toBe(second.dashboardPid);
-  await cli('show', '--kill');
-});

From 339eb9ddb4190da733349aec3e9801a65542f799 Mon Sep 17 00:00:00 2001
From: Simon Knott <info@simonknott.de>
Date: Mon, 11 May 2026 15:58:27 +0200
Subject: [PATCH 7/7] revert(cli show): keep exit handler registration in
 openDashboardApp

---
 .../src/tools/dashboard/dashboardApp.ts               | 11 +++--------
 1 file changed, 3 insertions(+), 8 deletions(-)

diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
index bee2589fd9550..ffa1a183e92b6 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts
@@ -273,10 +273,7 @@ async function acquireSingleton(options: DashboardOptions): Promise<net.Server>
 
   return await new Promise((resolve, reject) => {
     const server = net.createServer();
-    server.listen(socketPath, () => {
-      process.on('exit', () => server.close());
-      resolve(server);
-    });
+    server.listen(socketPath, () => resolve(server));
     server.on('error', (err: NodeJS.ErrnoException) => {
       if (err.code !== 'EADDRINUSE')
         return reject(err);
@@ -287,10 +284,7 @@ async function acquireSingleton(options: DashboardOptions): Promise<net.Server>
       client.on('error', () => {
         if (process.platform !== 'win32')
           fs.unlinkSync(socketPath);
-        server.listen(socketPath, () => {
-          process.on('exit', () => server.close());
-          resolve(server);
-        });
+        server.listen(socketPath, () => resolve(server));
       });
     });
   });
@@ -334,6 +328,7 @@ export async function openDashboardApp() {
     console.log('<EOF>');
     return;
   }
+  process.on('exit', () => server.close());
   try {
     await startApp(server, options);
     stopSelfDestruct();
