From bb5e35771f2313c17a7cf6629dc795fba637f4a7 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 5 Mar 2026 17:29:21 -0800 Subject: [PATCH] chore(cli): conditional exit on close (#39536) --- .../playwright-core/src/cli/daemon/daemon.ts | 62 ++++++++++++------- .../playwright-core/src/cli/daemon/program.ts | 6 +- packages/playwright-core/src/mcp/exports.ts | 2 +- packages/playwright-core/src/tools/exports.ts | 2 +- packages/playwright-core/src/tools/tool.ts | 2 + .../playwright/src/mcp/test/browserBackend.ts | 2 +- .../playwright/src/mcp/test/testBackend.ts | 4 +- 7 files changed, 48 insertions(+), 32 deletions(-) diff --git a/packages/playwright-core/src/cli/daemon/daemon.ts b/packages/playwright-core/src/cli/daemon/daemon.ts index 4b4b273fe8f8e..8bc57e97b2ca8 100644 --- a/packages/playwright-core/src/cli/daemon/daemon.ts +++ b/packages/playwright-core/src/cli/daemon/daemon.ts @@ -21,19 +21,20 @@ import path from 'path'; import { calculateSha1 } from '../../utils'; import { debug } from '../../utilsBundle'; + import { decorateServer } from '../../server/utils/network'; import { gracefullyProcessExitDoNotHang } from '../../server/utils/processLauncher'; import { BrowserServerBackend } from '../../tools/browserServerBackend'; import { browserTools } from '../../tools/tools'; -import { SocketConnection } from '../client/socketConnection'; -import { commands } from './commands'; import { parseCommand } from './command'; +import { commands } from './commands'; + +import { SocketConnection } from '../client/socketConnection'; import { createClientInfo } from '../client/registry'; import type * as playwright from '../../..'; import type * as tools from '../../tools/exports'; -import type * as mcp from '../../mcp/exports'; import type { SessionConfig, ClientInfo } from '../client/registry'; import type { BrowserContext } from '../../client/browserContext'; @@ -49,14 +50,17 @@ async function socketExists(socketPath: string): Promise { return false; } -export async function startMcpDaemonServer( +export async function startCliDaemonServer( sessionName: string, browserContext: playwright.BrowserContext, - mcpConfig: tools.ContextConfig, + contextConfig: tools.ContextConfig = {}, clientInfo = createClientInfo(), - persistent?: boolean, + options?: { + persistent?: boolean, + exitOnClose?: boolean, + } ): Promise { - const sessionConfig = createSessionConfig(clientInfo, sessionName, browserContext, persistent); + const sessionConfig = createSessionConfig(clientInfo, sessionName, browserContext, options); const { socketPath } = sessionConfig; // Clean up existing socket file on Unix @@ -70,11 +74,14 @@ export async function startMcpDaemonServer( } } - const backend = new BrowserServerBackend(mcpConfig, browserContext, browserTools); + const backend = new BrowserServerBackend(contextConfig, browserContext, browserTools); await backend.initialize({ cwd: process.cwd() }); await fs.promises.mkdir(path.dirname(socketPath), { recursive: true }); + if ((browserContext as BrowserContext)._closingStatus !== 'none') + throw new Error('Browser context was closed before the daemon could start'); + const server = net.createServer(socket => { daemonDebug('new client connection'); const connection = new SocketConnection(socket); @@ -87,15 +94,12 @@ export async function startMcpDaemonServer( daemonDebug('received command', method); if (method === 'stop') { daemonDebug('stop command received, shutting down'); - if (process.platform !== 'win32') - await fs.promises.unlink(sessionConfig.socketPath).catch(() => {}); - if (!sessionConfig.cli.persistent) - await deleteSessionFile(clientInfo, sessionConfig); - - gracefullyProcessExitDoNotHang(0, async () => { - await connection.send({ id, result: 'ok' }).catch(() => {}); - server.close(); - }); + await deleteSessionFile(clientInfo, sessionConfig); + const sendAck = async () => connection.send({ id, result: 'ok' }).catch(() => {}); + if (options?.exitOnClose) + gracefullyProcessExitDoNotHang(0, () => sendAck()); + else + await sendAck(); } else if (method === 'run') { const { toolName, toolParams } = parseCliCommand(params.args); if (params.cwd) @@ -112,7 +116,13 @@ export async function startMcpDaemonServer( } }; }); + decorateServer(server); + browserContext.on('close', () => Promise.resolve().then(async () => { + await deleteSessionFile(clientInfo, sessionConfig); + if (options?.exitOnClose) + gracefullyProcessExitDoNotHang(0); + })); await new Promise((resolve, reject) => { server.on('error', (error: NodeJS.ErrnoException) => { @@ -133,17 +143,20 @@ async function saveSessionFile(clientInfo: ClientInfo, sessionConfig: SessionCon } async function deleteSessionFile(clientInfo: ClientInfo, sessionConfig: SessionConfig) { - const sessionFile = path.join(clientInfo.daemonProfilesDir, `${sessionConfig.name}.session`); - await fs.promises.rm(sessionFile).catch(() => {}); + await fs.promises.unlink(sessionConfig.socketPath).catch(() => {}); + if (!sessionConfig.cli.persistent) { + const sessionFile = path.join(clientInfo.daemonProfilesDir, `${sessionConfig.name}.session`); + await fs.promises.rm(sessionFile).catch(() => {}); + } } -function formatResult(result: mcp.CallToolResult) { +function formatResult(result: tools.CallToolResult) { const isError = result.isError; const text = result.content[0].type === 'text' ? result.content[0].text : undefined; return { isError, text }; } -function parseCliCommand(args: Record & { _: string[] }): { toolName: string, toolParams: NonNullable } { +function parseCliCommand(args: Record & { _: string[] }): { toolName: string, toolParams: NonNullable } { const command = commands[args._[0]]; if (!command) throw new Error('Command is required'); @@ -159,7 +172,10 @@ function daemonSocketPath(clientInfo: ClientInfo, sessionName: string): string { return path.join(socketsDir, clientInfo.workspaceDirHash, socketName); } -function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browserContext: playwright.BrowserContext, persistent?: boolean): SessionConfig { +function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browserContext: playwright.BrowserContext, options: { + persistent?: boolean, + exitOnStop?: boolean, +} = {}): SessionConfig { const bc = browserContext as BrowserContext; return { name: sessionName, @@ -167,7 +183,7 @@ function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browse timestamp: Date.now(), socketPath: daemonSocketPath(clientInfo, sessionName), workspaceDir: clientInfo.workspaceDir, - cli: { persistent }, + cli: { persistent: options.persistent }, browser: { browserName: bc.browser()!.browserType().name(), launchOptions: bc.browser()!._options, diff --git a/packages/playwright-core/src/cli/daemon/program.ts b/packages/playwright-core/src/cli/daemon/program.ts index af23cffcb9d29..796f5a7a3b55a 100644 --- a/packages/playwright-core/src/cli/daemon/program.ts +++ b/packages/playwright-core/src/cli/daemon/program.ts @@ -19,12 +19,11 @@ import fs from 'fs'; import path from 'path'; -import { startMcpDaemonServer } from './daemon'; +import { startCliDaemonServer } from './daemon'; import { setupExitWatchdog } from '../../mcp/watchdog'; import { contextFactory } from '../../mcp/browserContextFactory'; import { ExtensionContextFactory } from '../../mcp/extensionContextFactory'; import * as configUtils from '../../mcp/config'; -import { gracefullyProcessExitDoNotHang } from '../../utils'; import { ClientInfo, createClientInfo } from '../client/registry'; import type { Command } from '../../utilsBundle'; @@ -52,8 +51,7 @@ export function decorateCLICommand(command: Command, version: string) { const browserContextFactory = contextFactory(mcpConfig); const cf = mcpConfig.extension ? extensionContextFactory : browserContextFactory; const browserContext = mcpConfig.browser.isolated ? await cf.createContext(mcpClientInfo) : (await cf.contexts(mcpClientInfo))[0]; - browserContext.on('close', () => gracefullyProcessExitDoNotHang(0)); - const socketPath = await startMcpDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, options.persistent); + const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true }); console.log(`### Success\nDaemon listening on ${socketPath}`); console.log(''); } catch (error) { diff --git a/packages/playwright-core/src/mcp/exports.ts b/packages/playwright-core/src/mcp/exports.ts index a4352586315b3..dd5244b9a0a00 100644 --- a/packages/playwright-core/src/mcp/exports.ts +++ b/packages/playwright-core/src/mcp/exports.ts @@ -17,7 +17,7 @@ export { createClientInfo } from '../cli/client/registry'; export { logUnhandledError } from './log'; export { setupExitWatchdog } from './watchdog'; -export { startMcpDaemonServer } from '../cli/daemon/daemon'; +export { startCliDaemonServer } from '../cli/daemon/daemon'; export * from './sdk/server'; export * from './sdk/tool'; diff --git a/packages/playwright-core/src/tools/exports.ts b/packages/playwright-core/src/tools/exports.ts index f6926f010e3bd..40f38559e2266 100644 --- a/packages/playwright-core/src/tools/exports.ts +++ b/packages/playwright-core/src/tools/exports.ts @@ -21,4 +21,4 @@ export { parseResponse } from './response'; export { Tab } from './tab'; export type { ContextConfig } from './context'; -export type { Tool as BrowserTool } from './tool'; +export type { Tool, CallToolResult, CallToolRequest } from './tool'; diff --git a/packages/playwright-core/src/tools/tool.ts b/packages/playwright-core/src/tools/tool.ts index 9c88db38e22ac..b6a7e208cabe5 100644 --- a/packages/playwright-core/src/tools/tool.ts +++ b/packages/playwright-core/src/tools/tool.ts @@ -20,6 +20,8 @@ import type * as playwright from '../../types/types'; import type { Tab } from './tab'; import type { Response } from './response'; +export type { CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; + type ToolSchema = { name: string; title: string; diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index 2f24b78060e6e..6d9ef5267855d 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -125,7 +125,7 @@ export async function runDaemonForContext(testInfo: TestInfoImpl, context: playw const outputDir = path.join(testInfo.artifactsDir(), '.playwright-mcp'); const sessionName = `test-worker-${createGuid().slice(0, 6)}`; - await mcp.startMcpDaemonServer(sessionName, context, { + await mcp.startCliDaemonServer(sessionName, context, { outputMode: 'file', snapshot: { mode: 'full' }, outputDir, diff --git a/packages/playwright/src/mcp/test/testBackend.ts b/packages/playwright/src/mcp/test/testBackend.ts index 4ee3fd0800d9b..8339a0facbbbd 100644 --- a/packages/playwright/src/mcp/test/testBackend.ts +++ b/packages/playwright/src/mcp/test/testBackend.ts @@ -26,7 +26,7 @@ import * as generatorTools from './generatorTools.js'; import * as plannerTools from './plannerTools.js'; import type { TestTool } from './testTool'; -import type { BrowserTool } from 'playwright-core/lib/tools/exports'; +import type { Tool } from 'playwright-core/lib/tools/exports'; const typesWithIntent = ['action', 'assertion', 'input']; @@ -76,7 +76,7 @@ export class TestServerBackend extends EventEmitter implements mcp.ServerBackend } } -function wrapBrowserTool(tool: BrowserTool): TestTool { +function wrapBrowserTool(tool: Tool): TestTool { const inputSchema = typesWithIntent.includes(tool.schema.type) ? (tool.schema.inputSchema as any).extend({ intent: zod.string().describe('The intent of the call, for example the test step description plan idea') }) : tool.schema.inputSchema;