Skip to content

Commit a512630

Browse files
authored
fix(dashboard): block show -s until the controller acks the reveal (#40902)
1 parent 474d3c1 commit a512630

3 files changed

Lines changed: 95 additions & 69 deletions

File tree

packages/playwright-core/src/tools/cli-client/program.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,16 @@ export async function program(options?: { embedderVersion?: string}) {
204204
const daemonScript = libPath('entry', 'dashboardApp.js');
205205
const daemonArgs = [
206206
daemonScript,
207-
`--sessionName=${sessionName}`,
208207
`--workspaceDir=${clientInfo.workspaceDir ?? ''}`,
209208
];
209+
// Only pass --sessionName when the user explicitly requested a session
210+
// (via -s/--session or PLAYWRIGHT_CLI_SESSION). Bare `playwright cli show`
211+
// opens the dashboard generically, with no specific session to reveal,
212+
// so the daemon should ack as soon as it's ready rather than waiting for
213+
// a reveal that was never asked for.
214+
const explicit = explicitSessionName(args.session as string);
215+
if (explicit)
216+
daemonArgs.push(`--sessionName=${explicit}`);
210217
if (args.port !== undefined)
211218
daemonArgs.push(`--port=${args.port}`);
212219
if (args.host !== undefined)

packages/playwright-core/src/tools/dashboard/dashboardApp.ts

Lines changed: 55 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import http from 'http';
2222
import { HttpServer } from '@utils/httpServer';
2323
import { makeSocketPath } from '@utils/fileUtils';
2424
import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher';
25+
import { ManualPromise } from '@isomorphic/manualPromise';
2526
import { libPath } from '../../package';
2627
import { playwright } from '../../inprocess';
2728
import { findChromiumChannelBestEffort, registryDirectory } from '../../server/registry/index';
@@ -41,9 +42,8 @@ declare const __PW_HMR__: boolean;
4142

4243
type DashboardServer = {
4344
url: string;
44-
reveal: (options: DashboardOptions) => void;
45-
triggerAnnotate: () => void;
46-
registerAnnotateWaiter: (socket: net.Socket) => void;
45+
reveal: (options: DashboardOptions) => Promise<void>;
46+
triggerAnnotate: (socket: net.Socket) => Promise<void>;
4747
close: () => Promise<void>;
4848
};
4949

@@ -52,8 +52,7 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
5252
const httpServer = new HttpServer(dashboardDir);
5353

5454
const connections = new Set<DashboardConnection>();
55-
let currentReveal: DashboardOptions = options;
56-
let pendingAnnotate = false;
55+
let connectionLanded = new ManualPromise<void>();
5756
const waitingSockets = new Set<net.Socket>();
5857

5958
const submitAnnotation = (frames: SubmittedAnnotationFrame[], feedback: string) => {
@@ -70,15 +69,12 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
7069
httpServer.createWebSocket(() => {
7170
let connection: DashboardConnection;
7271
// eslint-disable-next-line prefer-const
73-
connection = new DashboardConnection(provider, () => connections.delete(connection), () => {
74-
if (currentReveal.pageId)
75-
connection.revealPage(currentReveal.pageId);
76-
else if (currentReveal.sessionName)
77-
connection.revealSession(currentReveal.sessionName, currentReveal.workspaceDir);
78-
if (pendingAnnotate) {
79-
pendingAnnotate = false;
80-
connection.emitAnnotate();
81-
}
72+
connection = new DashboardConnection(provider, () => {
73+
connections.delete(connection);
74+
if (connections.size === 0)
75+
connectionLanded = new ManualPromise<void>();
76+
}, () => {
77+
connectionLanded.resolve();
8278
}, submitAnnotation);
8379
connections.add(connection);
8480
return connection;
@@ -102,48 +98,38 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar
10298
attachDashboardStaticServer(httpServer, dashboardDir);
10399
await httpServer.start({ port: options.port, host: options.host });
104100

105-
const reveal = (next: DashboardOptions) => {
106-
currentReveal = next;
107-
if (next.pageId) {
108-
for (const connection of connections)
109-
connection.revealPage(next.pageId);
110-
return;
111-
}
112-
if (!next.sessionName)
113-
return;
114-
for (const connection of connections)
115-
connection.revealSession(next.sessionName, next.workspaceDir);
101+
const reveal = async (next: DashboardOptions): Promise<void> => {
102+
await connectionLanded;
103+
await Promise.all([...connections].map(async c => {
104+
if (next.pageId)
105+
await c.revealPage(next.pageId);
106+
else if (next.sessionName)
107+
await c.revealSession(next.sessionName, next.workspaceDir);
108+
}));
116109
};
117110

118-
const triggerAnnotate = () => {
119-
if (connections.size === 0) {
120-
pendingAnnotate = true;
121-
return;
122-
}
123-
for (const connection of connections)
124-
connection.emitAnnotate();
125-
};
126-
127-
const notifyAnnotateEnded = () => {
128-
pendingAnnotate = false;
129-
for (const connection of connections)
130-
connection.emitCancelAnnotate();
131-
};
132-
133-
const registerAnnotateWaiter = (socket: net.Socket) => {
111+
const triggerAnnotate = async (socket: net.Socket) => {
134112
waitingSockets.add(socket);
135113
const cleanup = () => {
136114
if (!waitingSockets.delete(socket))
137115
return;
138-
if (waitingSockets.size === 0)
139-
notifyAnnotateEnded();
116+
if (waitingSockets.size === 0) {
117+
for (const connection of connections)
118+
connection.emitCancelAnnotate();
119+
}
140120
};
141121
socket.on('close', cleanup);
142122
socket.on('error', cleanup);
123+
124+
await connectionLanded;
125+
if (waitingSockets.size === 0)
126+
return;
127+
for (const connection of connections)
128+
connection.emitAnnotate();
143129
};
144130

145131
const close = () => httpServer.stop();
146-
return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, registerAnnotateWaiter, close };
132+
return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, close };
147133
}
148134

149135
function attachDashboardStaticServer(httpServer: HttpServer, dashboardDir: string) {
@@ -168,6 +154,7 @@ async function attachDashboardDevServer(httpServer: HttpServer) {
168154

169155
async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> {
170156
const server = await startDashboardServer(new RegistrySessionProvider(), options);
157+
void server.reveal(options).catch(() => {});
171158
const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) });
172159
await page.goto(server.url);
173160
return { page, server };
@@ -279,8 +266,8 @@ async function acquireSingleton(options: DashboardOptions): Promise<net.Server>
279266
return reject(err);
280267
const client = net.connect(socketPath, () => {
281268
client.write(JSON.stringify(options) + '\n');
282-
reject(new Error('already running'));
283269
});
270+
client.on('end', () => reject(new Error('already running')));
284271
client.on('error', () => {
285272
if (process.platform !== 'win32')
286273
fs.unlinkSync(socketPath);
@@ -305,9 +292,10 @@ export async function openDashboardApp() {
305292
console.error('Unhandled promise rejection:', error);
306293
});
307294
if (options.port !== undefined) {
308-
const { url } = await startDashboardServer(new RegistrySessionProvider(), options);
295+
const server = await startDashboardServer(new RegistrySessionProvider(), options);
296+
void server.reveal(options).catch(() => {});
309297
// eslint-disable-next-line no-console
310-
console.log(`Listening on ${url}`);
298+
console.log(`Listening on ${server.url}`);
311299
// eslint-disable-next-line no-restricted-properties
312300
await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed.
313301
selfDestructOnParentGone();
@@ -345,7 +333,7 @@ async function startApp(server: net.Server, options: DashboardOptions) {
345333
const statePromise = innerOpenDashboardApp(options);
346334
server.on('connection', socket => {
347335
let buffer = '';
348-
socket.on('data', data => {
336+
socket.on('data', async data => {
349337
buffer += data.toString();
350338
const newlineIndex = buffer.indexOf('\n');
351339
if (newlineIndex === -1)
@@ -362,21 +350,27 @@ async function startApp(server: net.Server, options: DashboardOptions) {
362350
socket.end();
363351
return;
364352
}
365-
void statePromise.then(({ page, server: dashboard }) => {
366-
if (parsed.annotate) {
367-
page?.bringToFront().catch(() => {});
368-
dashboard.reveal(parsed);
369-
dashboard.triggerAnnotate();
370-
dashboard.registerAnnotateWaiter(socket);
371-
} else if (parsed.kill) {
372-
socket.end();
373-
gracefullyProcessExitDoNotHang(0);
374-
} else {
375-
page?.bringToFront().catch(() => {});
376-
dashboard.reveal(parsed);
353+
const { page, server: dashboard } = await statePromise;
354+
if (parsed.annotate) {
355+
try {
356+
await page?.bringToFront();
357+
await dashboard.reveal(parsed);
358+
await dashboard.triggerAnnotate(socket);
359+
} catch (e) {
360+
socket.end(e);
361+
}
362+
} else if (parsed.kill) {
363+
socket.end();
364+
gracefullyProcessExitDoNotHang(0);
365+
} else {
366+
try {
367+
await page?.bringToFront();
368+
await dashboard.reveal(parsed);
377369
socket.end();
370+
} catch (e) {
371+
socket.end(e);
378372
}
379-
});
373+
}
380374
});
381375
});
382376
await statePromise;

packages/playwright-core/src/tools/dashboard/dashboardController.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import fs from 'fs';
2020
import crypto from 'crypto';
2121
import { execFile } from 'child_process';
2222
import { Disposable } from '@isomorphic/disposable';
23+
import { ManualPromise } from '@isomorphic/manualPromise';
2324
import { eventsHelper } from '@utils/eventsHelper';
2425
import { createClientInfo } from '../cli-client/registry';
2526

@@ -42,7 +43,7 @@ export class DashboardConnection implements Transport {
4243
private _onAnnotationSubmit?: (frames: SubmittedAnnotationFrame[], feedback: string) => void;
4344
private _pushTabsScheduled = false;
4445
private _visible = true;
45-
private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string } | undefined;
46+
private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string; done: ManualPromise<void> } | undefined;
4647
private _annotateWaitingForAttach = false;
4748

4849
_recordingDir: string;
@@ -80,6 +81,9 @@ export class DashboardConnection implements Transport {
8081
this._provider.dispose();
8182
this._attachedPage?.dispose();
8283
this._attachedPage = undefined;
84+
// Reject any in-flight reveal so callers awaiting it don't hang.
85+
this._pendingReveal?.done.reject(new Error('Dashboard connection closed'));
86+
this._pendingReveal = undefined;
8387
for (const stream of this._streams.values()) {
8488
void stream.handle.close()
8589
.catch(() => {})
@@ -137,14 +141,29 @@ export class DashboardConnection implements Transport {
137141
await this._attachedPage?.setScreencastActive(params.visible);
138142
}
139143

140-
revealSession(sessionName: string, workspaceDir?: string) {
141-
this._pendingReveal = { sessionName, workspaceDir };
144+
revealSession(sessionName: string, workspaceDir?: string): Promise<void> {
145+
const existing = this._pendingReveal;
146+
if (existing
147+
&& existing.pageId === undefined
148+
&& existing.sessionName === sessionName
149+
&& existing.workspaceDir === workspaceDir)
150+
return existing.done;
151+
existing?.done.reject(new Error('Reveal superseded'));
152+
const done = new ManualPromise<void>();
153+
this._pendingReveal = { sessionName, workspaceDir, done };
142154
void this._tryRevealPending();
155+
return done;
143156
}
144157

145-
revealPage(pageId: string) {
146-
this._pendingReveal = { pageId };
158+
revealPage(pageId: string): Promise<void> {
159+
const existing = this._pendingReveal;
160+
if (existing && existing.pageId === pageId)
161+
return existing.done;
162+
existing?.done.reject(new Error('Reveal superseded'));
163+
const done = new ManualPromise<void>();
164+
this._pendingReveal = { pageId, done };
147165
void this._tryRevealPending();
166+
return done;
148167
}
149168

150169
private async _tryRevealPending() {
@@ -163,8 +182,14 @@ export class DashboardConnection implements Transport {
163182
if (!page)
164183
return;
165184
this._pendingReveal = undefined;
166-
await this._switchAttachedTo(page);
167-
this._pushTabs();
185+
try {
186+
await this._switchAttachedTo(page);
187+
this._pushTabs();
188+
pending.done.resolve();
189+
} catch (e) {
190+
pending.done.reject(e instanceof Error ? e : new Error(String(e)));
191+
throw e;
192+
}
168193
}
169194

170195
async submitAnnotation(params: { frames: SubmittedAnnotationFrame[]; feedback: string }) {

0 commit comments

Comments
 (0)