Skip to content

Commit bb5e357

Browse files
authored
chore(cli): conditional exit on close (microsoft#39536)
1 parent 7aac9b4 commit bb5e357

7 files changed

Lines changed: 48 additions & 32 deletions

File tree

packages/playwright-core/src/cli/daemon/daemon.ts

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,20 @@ import path from 'path';
2121

2222
import { calculateSha1 } from '../../utils';
2323
import { debug } from '../../utilsBundle';
24+
2425
import { decorateServer } from '../../server/utils/network';
2526
import { gracefullyProcessExitDoNotHang } from '../../server/utils/processLauncher';
2627

2728
import { BrowserServerBackend } from '../../tools/browserServerBackend';
2829
import { browserTools } from '../../tools/tools';
29-
import { SocketConnection } from '../client/socketConnection';
30-
import { commands } from './commands';
3130
import { parseCommand } from './command';
31+
import { commands } from './commands';
32+
33+
import { SocketConnection } from '../client/socketConnection';
3234
import { createClientInfo } from '../client/registry';
3335

3436
import type * as playwright from '../../..';
3537
import type * as tools from '../../tools/exports';
36-
import type * as mcp from '../../mcp/exports';
3738
import type { SessionConfig, ClientInfo } from '../client/registry';
3839
import type { BrowserContext } from '../../client/browserContext';
3940

@@ -49,14 +50,17 @@ async function socketExists(socketPath: string): Promise<boolean> {
4950
return false;
5051
}
5152

52-
export async function startMcpDaemonServer(
53+
export async function startCliDaemonServer(
5354
sessionName: string,
5455
browserContext: playwright.BrowserContext,
55-
mcpConfig: tools.ContextConfig,
56+
contextConfig: tools.ContextConfig = {},
5657
clientInfo = createClientInfo(),
57-
persistent?: boolean,
58+
options?: {
59+
persistent?: boolean,
60+
exitOnClose?: boolean,
61+
}
5862
): Promise<string> {
59-
const sessionConfig = createSessionConfig(clientInfo, sessionName, browserContext, persistent);
63+
const sessionConfig = createSessionConfig(clientInfo, sessionName, browserContext, options);
6064
const { socketPath } = sessionConfig;
6165

6266
// Clean up existing socket file on Unix
@@ -70,11 +74,14 @@ export async function startMcpDaemonServer(
7074
}
7175
}
7276

73-
const backend = new BrowserServerBackend(mcpConfig, browserContext, browserTools);
77+
const backend = new BrowserServerBackend(contextConfig, browserContext, browserTools);
7478
await backend.initialize({ cwd: process.cwd() });
7579

7680
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
7781

82+
if ((browserContext as BrowserContext)._closingStatus !== 'none')
83+
throw new Error('Browser context was closed before the daemon could start');
84+
7885
const server = net.createServer(socket => {
7986
daemonDebug('new client connection');
8087
const connection = new SocketConnection(socket);
@@ -87,15 +94,12 @@ export async function startMcpDaemonServer(
8794
daemonDebug('received command', method);
8895
if (method === 'stop') {
8996
daemonDebug('stop command received, shutting down');
90-
if (process.platform !== 'win32')
91-
await fs.promises.unlink(sessionConfig.socketPath).catch(() => {});
92-
if (!sessionConfig.cli.persistent)
93-
await deleteSessionFile(clientInfo, sessionConfig);
94-
95-
gracefullyProcessExitDoNotHang(0, async () => {
96-
await connection.send({ id, result: 'ok' }).catch(() => {});
97-
server.close();
98-
});
97+
await deleteSessionFile(clientInfo, sessionConfig);
98+
const sendAck = async () => connection.send({ id, result: 'ok' }).catch(() => {});
99+
if (options?.exitOnClose)
100+
gracefullyProcessExitDoNotHang(0, () => sendAck());
101+
else
102+
await sendAck();
99103
} else if (method === 'run') {
100104
const { toolName, toolParams } = parseCliCommand(params.args);
101105
if (params.cwd)
@@ -112,7 +116,13 @@ export async function startMcpDaemonServer(
112116
}
113117
};
114118
});
119+
115120
decorateServer(server);
121+
browserContext.on('close', () => Promise.resolve().then(async () => {
122+
await deleteSessionFile(clientInfo, sessionConfig);
123+
if (options?.exitOnClose)
124+
gracefullyProcessExitDoNotHang(0);
125+
}));
116126

117127
await new Promise<void>((resolve, reject) => {
118128
server.on('error', (error: NodeJS.ErrnoException) => {
@@ -133,17 +143,20 @@ async function saveSessionFile(clientInfo: ClientInfo, sessionConfig: SessionCon
133143
}
134144

135145
async function deleteSessionFile(clientInfo: ClientInfo, sessionConfig: SessionConfig) {
136-
const sessionFile = path.join(clientInfo.daemonProfilesDir, `${sessionConfig.name}.session`);
137-
await fs.promises.rm(sessionFile).catch(() => {});
146+
await fs.promises.unlink(sessionConfig.socketPath).catch(() => {});
147+
if (!sessionConfig.cli.persistent) {
148+
const sessionFile = path.join(clientInfo.daemonProfilesDir, `${sessionConfig.name}.session`);
149+
await fs.promises.rm(sessionFile).catch(() => {});
150+
}
138151
}
139152

140-
function formatResult(result: mcp.CallToolResult) {
153+
function formatResult(result: tools.CallToolResult) {
141154
const isError = result.isError;
142155
const text = result.content[0].type === 'text' ? result.content[0].text : undefined;
143156
return { isError, text };
144157
}
145158

146-
function parseCliCommand(args: Record<string, string> & { _: string[] }): { toolName: string, toolParams: NonNullable<mcp.CallToolRequest['params']['arguments']> } {
159+
function parseCliCommand(args: Record<string, string> & { _: string[] }): { toolName: string, toolParams: NonNullable<tools.CallToolRequest['params']['arguments']> } {
147160
const command = commands[args._[0]];
148161
if (!command)
149162
throw new Error('Command is required');
@@ -159,15 +172,18 @@ function daemonSocketPath(clientInfo: ClientInfo, sessionName: string): string {
159172
return path.join(socketsDir, clientInfo.workspaceDirHash, socketName);
160173
}
161174

162-
function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browserContext: playwright.BrowserContext, persistent?: boolean): SessionConfig {
175+
function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browserContext: playwright.BrowserContext, options: {
176+
persistent?: boolean,
177+
exitOnStop?: boolean,
178+
} = {}): SessionConfig {
163179
const bc = browserContext as BrowserContext;
164180
return {
165181
name: sessionName,
166182
version: clientInfo.version,
167183
timestamp: Date.now(),
168184
socketPath: daemonSocketPath(clientInfo, sessionName),
169185
workspaceDir: clientInfo.workspaceDir,
170-
cli: { persistent },
186+
cli: { persistent: options.persistent },
171187
browser: {
172188
browserName: bc.browser()!.browserType().name(),
173189
launchOptions: bc.browser()!._options,

packages/playwright-core/src/cli/daemon/program.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,11 @@
1919
import fs from 'fs';
2020
import path from 'path';
2121

22-
import { startMcpDaemonServer } from './daemon';
22+
import { startCliDaemonServer } from './daemon';
2323
import { setupExitWatchdog } from '../../mcp/watchdog';
2424
import { contextFactory } from '../../mcp/browserContextFactory';
2525
import { ExtensionContextFactory } from '../../mcp/extensionContextFactory';
2626
import * as configUtils from '../../mcp/config';
27-
import { gracefullyProcessExitDoNotHang } from '../../utils';
2827
import { ClientInfo, createClientInfo } from '../client/registry';
2928

3029
import type { Command } from '../../utilsBundle';
@@ -52,8 +51,7 @@ export function decorateCLICommand(command: Command, version: string) {
5251
const browserContextFactory = contextFactory(mcpConfig);
5352
const cf = mcpConfig.extension ? extensionContextFactory : browserContextFactory;
5453
const browserContext = mcpConfig.browser.isolated ? await cf.createContext(mcpClientInfo) : (await cf.contexts(mcpClientInfo))[0];
55-
browserContext.on('close', () => gracefullyProcessExitDoNotHang(0));
56-
const socketPath = await startMcpDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, options.persistent);
54+
const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true });
5755
console.log(`### Success\nDaemon listening on ${socketPath}`);
5856
console.log('<EOF>');
5957
} catch (error) {

packages/playwright-core/src/mcp/exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
export { createClientInfo } from '../cli/client/registry';
1818
export { logUnhandledError } from './log';
1919
export { setupExitWatchdog } from './watchdog';
20-
export { startMcpDaemonServer } from '../cli/daemon/daemon';
20+
export { startCliDaemonServer } from '../cli/daemon/daemon';
2121
export * from './sdk/server';
2222
export * from './sdk/tool';
2323

packages/playwright-core/src/tools/exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ export { parseResponse } from './response';
2121
export { Tab } from './tab';
2222

2323
export type { ContextConfig } from './context';
24-
export type { Tool as BrowserTool } from './tool';
24+
export type { Tool, CallToolResult, CallToolRequest } from './tool';

packages/playwright-core/src/tools/tool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type * as playwright from '../../types/types';
2020
import type { Tab } from './tab';
2121
import type { Response } from './response';
2222

23+
export type { CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
24+
2325
type ToolSchema<Input extends z.Schema> = {
2426
name: string;
2527
title: string;

packages/playwright/src/mcp/test/browserBackend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export async function runDaemonForContext(testInfo: TestInfoImpl, context: playw
125125

126126
const outputDir = path.join(testInfo.artifactsDir(), '.playwright-mcp');
127127
const sessionName = `test-worker-${createGuid().slice(0, 6)}`;
128-
await mcp.startMcpDaemonServer(sessionName, context, {
128+
await mcp.startCliDaemonServer(sessionName, context, {
129129
outputMode: 'file',
130130
snapshot: { mode: 'full' },
131131
outputDir,

packages/playwright/src/mcp/test/testBackend.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import * as generatorTools from './generatorTools.js';
2626
import * as plannerTools from './plannerTools.js';
2727

2828
import type { TestTool } from './testTool';
29-
import type { BrowserTool } from 'playwright-core/lib/tools/exports';
29+
import type { Tool } from 'playwright-core/lib/tools/exports';
3030

3131
const typesWithIntent = ['action', 'assertion', 'input'];
3232

@@ -76,7 +76,7 @@ export class TestServerBackend extends EventEmitter implements mcp.ServerBackend
7676
}
7777
}
7878

79-
function wrapBrowserTool(tool: BrowserTool): TestTool {
79+
function wrapBrowserTool(tool: Tool): TestTool {
8080
const inputSchema = typesWithIntent.includes(tool.schema.type) ? (tool.schema.inputSchema as any).extend({
8181
intent: zod.string().describe('The intent of the call, for example the test step description plan idea')
8282
}) : tool.schema.inputSchema;

0 commit comments

Comments
 (0)