diff --git a/docs/src/getting-started-cli.md b/docs/src/getting-started-cli.md index bfafa55ad7009..fac23d2c50653 100644 --- a/docs/src/getting-started-cli.md +++ b/docs/src/getting-started-cli.md @@ -261,6 +261,8 @@ playwright-cli kill-all # forcefully kill all browser processes playwright-cli -s=name delete-data # delete user data for a named session ``` +A headless session shuts itself down after an hour without commands, so a session an agent forgot to close does not keep a browser running. Headed and attached browsers are never closed automatically. Change the timeout with `open --idle-timeout=` or `timeouts.idle` in the config file, and pass `0` to disable it. + ## Monitoring Use `playwright-cli show` to open a visual dashboard for observing and controlling all running browser sessions: diff --git a/docs/src/getting-started-mcp.md b/docs/src/getting-started-mcp.md index 63c5ae04e6712..9d5c1d15b3a65 100644 --- a/docs/src/getting-started-mcp.md +++ b/docs/src/getting-started-mcp.md @@ -196,7 +196,7 @@ Playwright MCP supports three profile modes: ### Idle timeout -The browser is launched by the first tool call and stays open until the MCP server exits, so a page that keeps animating or rendering costs CPU for as long as the agent's session lasts. Pass `--timeout-idle` to close the browser after a period without tool calls, in milliseconds: +A page that keeps animating or rendering costs CPU for as long as the browser is open, and an agent session can last hours. A headless browser launched by the server is therefore closed after an hour without tool calls. Headed browsers, and browsers attached over `--cdp-endpoint` or `--extension`, are never closed automatically. Pass `--idle-timeout` to set the timeout in milliseconds for any mode, or `0` to disable it: ```json { @@ -205,7 +205,7 @@ The browser is launched by the first tool call and stays open until the MCP serv "command": "npx", "args": [ "@playwright/mcp@latest", - "--timeout-idle=300000" + "--idle-timeout=300000" ] } } @@ -215,7 +215,7 @@ The browser is launched by the first tool call and stays open until the MCP serv When no tool call has completed for that long, the browser is closed, headed or not, the same way as if you had closed it by hand. The next tool call launches a new browser, so the agent navigates again. The timer never fires while a tool call is running. - With `--isolated`, cookies and storage kept in memory are lost on an idle close. Use the persistent profile or `--storage-state` to keep them. -- With `--cdp-endpoint` or `--extension`, the browser is not owned by the server, so an idle close only disconnects from it and its pages stay open. With `--extension`, the next tool call goes through the connect flow again. +- With `--cdp-endpoint` or `--extension`, the browser is not owned by the server, so there is no default timeout, and an explicit one only disconnects from it, leaving its pages open. With `--extension`, the next tool call goes through the connect flow again. - With `--shared-browser-context`, the timer spans all clients: a client that is idle while others keep working keeps its tabs and state, and the shared browser is closed only once every client has been idle for the timeout. ### Configuration file diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 1c7b3dbeebfca..b5036df30cc6d 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -82,15 +82,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement } async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}, signal?: AbortSignal): Promise { - this._idleTimer?.callStarted(); - try { - return await this._callTool(name, rawArguments, signal); - } finally { - this._idleTimer?.callFinished(); - } - } - - private async _callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record }, signal?: AbortSignal): Promise { + this._idleTimer?.poke(); const json = !!rawArguments._meta?.json; const formatError = (message: string): mcpServer.CallToolResult => ({ content: [{ type: 'text' as const, text: json ? JSON.stringify({ isError: true, error: message }, null, 2) : `### Error\n${message}` }], diff --git a/packages/playwright-core/src/tools/backend/idleTimer.ts b/packages/playwright-core/src/tools/backend/idleTimer.ts index 4bde60d601872..641d296681db9 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -14,10 +14,11 @@ * limitations under the License. */ +export const defaultIdleTimeout = 60 * 60 * 1000; + export class IdleTimer { private _timeout: number; private _onIdle: () => void; - private _running = 0; private _timer: NodeJS.Timeout | undefined; constructor(timeout: number, onIdle: () => void) { @@ -25,14 +26,9 @@ export class IdleTimer { this._onIdle = onIdle; } - callStarted() { - ++this._running; + poke() { this.dispose(); - } - - callFinished() { - if (!--this._running) - this._timer = setTimeout(this._onIdle, this._timeout).unref(); + this._timer = setTimeout(this._onIdle, this._timeout); } dispose() { diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts index 290d8cbff8c14..c8d810d8ab054 100644 --- a/packages/playwright-core/src/tools/cli-client/session.ts +++ b/packages/playwright-core/src/tools/cli-client/session.ts @@ -137,6 +137,8 @@ export class Session { args.push(`--profile=${cliArgs.profile}`); if (cliArgs.config) args.push(`--config=${cliArgs.config}`); + if (cliArgs['idle-timeout'] !== undefined) + args.push(`--idle-timeout=${cliArgs['idle-timeout']}`); if (cliArgs.extension) args.push('--extension'); else if (cliArgs.cdp) diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 5eb00f8835f52..04e5834a8f7d1 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -50,6 +50,7 @@ const open = declareCommand({ config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'), device: z.string().optional().describe('Emulate a specific device, for example "iPhone 15".'), headed: z.boolean().optional().describe('Run browser in headed mode'), + ['idle-timeout']: numberArg.optional().describe('Shut the session down after this many milliseconds without a command. Defaults to one hour for headless browsers, never for headed ones. Pass 0 to disable.'), mobile: z.boolean().optional().describe('Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens.'), persistent: z.boolean().optional().describe('Use persistent browser profile'), profile: z.string().optional().describe('Path to a persistent user data directory.'), @@ -71,6 +72,7 @@ const attach = declareCommand({ extension: z.union([z.boolean(), z.string()]).optional().describe('Connect to browser extension, optionally specify browser name (e.g. --extension=chrome)'), config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'), session: z.string().optional().describe('Session name (defaults to bound browser name or "default")'), + ['idle-timeout']: numberArg.optional().describe('Detach after this many milliseconds without a command. Attached browsers are never detached by default.'), }), toolName: 'browser_snapshot', toolParams: () => ({ filename: '' }), diff --git a/packages/playwright-core/src/tools/cli-daemon/daemon.ts b/packages/playwright-core/src/tools/cli-daemon/daemon.ts index 85b5a73b978e6..b71dcc477ec51 100644 --- a/packages/playwright-core/src/tools/cli-daemon/daemon.ts +++ b/packages/playwright-core/src/tools/cli-daemon/daemon.ts @@ -29,6 +29,7 @@ import { commands } from './commands'; import { SocketConnection } from '../utils/socketConnection'; import type * as playwright from '../../..'; +import type { IdleTimer } from '../backend/idleTimer'; import type { SessionConfig, ClientInfo } from '../cli-client/registry'; import type { CallToolRequest, CallToolResult } from '../backend/tool'; import type { ContextConfig } from '../backend/context'; @@ -79,6 +80,7 @@ export async function startCliDaemonServer( ownership?: 'attached' | 'own', persistent?: boolean, exitOnClose?: boolean, + idleTimer?: IdleTimer, } ): Promise { const sessionConfig = createSessionConfig(clientInfo, sessionName, browserInfo, options); @@ -93,7 +95,7 @@ export async function startCliDaemonServer( } } - const backend = new BrowserBackend(contextConfig, browserContext, browserTools); + const backend = new BrowserBackend(contextConfig, browserContext, browserTools, { idleTimer: options.idleTimer }); await backend.initialize(mcpClientInfo); if (browserContext.isClosed()) @@ -141,6 +143,7 @@ export async function startCliDaemonServer( }); await saveSessionFile(clientInfo, sessionConfig); + options.idleTimer?.poke(); await monitorSocketPath(socketPath); return socketPath; } diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts index 6682efbee19ff..e96685479c525 100644 --- a/packages/playwright-core/src/tools/cli-daemon/program.ts +++ b/packages/playwright-core/src/tools/cli-daemon/program.ts @@ -43,6 +43,7 @@ export function decorateProgram(program: Command) { .option('--config ', 'path to the config file; by default uses .playwright/cli.config.json in the project directory and ~/.playwright/cli.config.json as global config') .option('--cdp ', 'connect to an existing browser via CDP endpoint URL') .option('--endpoint ', 'attach to a running Playwright browser endpoint') + .option('--idle-timeout ', 'shut the session down after this many milliseconds without a command, defaults to one hour for headless browsers', configUtils.numberParser) .option('--init-workspace', 'initialize workspace') .option('--init-skills ', 'install skills for the given agent type ("claude" or "agents")') .option('--init-skills-global ', 'install skills for the given agent type ("claude" or "agents") into the home directory') @@ -62,12 +63,12 @@ export function decorateProgram(program: Command) { }; try { - const { browser, browserInfo, ownership } = await createBrowserWithInfo(mcpConfig, mcpClientInfo, options, { title: sessionName, workspaceDir: clientInfo.workspaceDir }); + const { browser, browserInfo, ownership, idleTimer } = await createBrowserWithInfo(mcpConfig, mcpClientInfo, options, { title: sessionName, workspaceDir: clientInfo.workspaceDir }); const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0]; if (!browserContext) throw new Error('Error: unable to connect to a browser that does not have any contexts'); const persistent = options.persistent || options.profile || mcpConfig.browser.userDataDir ? true : undefined; - const socketPath = await startCliDaemonServer(sessionName, browserContext, browserInfo, mcpConfig, clientInfo, mcpClientInfo, { persistent, exitOnClose: true, ownership }); + const socketPath = await startCliDaemonServer(sessionName, browserContext, browserInfo, mcpConfig, clientInfo, mcpClientInfo, { persistent, exitOnClose: true, ownership, idleTimer }); console.log(`Daemon listening on ${socketPath}\n`); } catch (error) { console.log(error); diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 3783eea93a781..6efd9ba4efd64 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -22,7 +22,7 @@ import { playwright } from '../../inprocess'; import { defaultCacheDirectory } from '../../server/registry/index'; import { testDebug } from './log'; import { outputDir } from '../backend/context'; -import { IdleTimer } from '../backend/idleTimer'; +import { IdleTimer, defaultIdleTimeout } from '../backend/idleTimer'; import { createExtensionBrowser } from './extensionContextFactory'; import { connectToBrowserAcrossVersions, descriptorEndpoint } from '../utils/connect'; import { serverRegistry } from '../../serverRegistry'; @@ -52,7 +52,7 @@ export type BindOptions = { export async function createBrowserWithInfo(config: FullConfig, clientInfo: ClientInfo, cliOptions: CLIOptions, bindOptions: BindOptions): Promise { const info = await createBrowser(config, clientInfo, cliOptions, bindOptions); - const idleTimeout = config.timeouts?.idle; + const idleTimeout = config.timeouts?.idle ?? (info.ownership === 'own' && config.browser.launchOptions.headless ? defaultIdleTimeout : undefined); if (idleTimeout) { info.idleTimer = new IdleTimer(idleTimeout, () => info.browser.close().catch(() => {})); info.browser.once('disconnected', () => info.idleTimer?.dispose()); diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 4ffe96ecbfc3f..421a601c25bfd 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -219,7 +219,9 @@ export type Config = { settle?: number; /** - * Close the browser after this many milliseconds without a tool call, and relaunch it on the next one. Disabled by default. + * Close the browser after this many milliseconds without a tool call, and relaunch it on the next one. + * Defaults to one hour for headless browsers Playwright launched, and to no timeout for headed or attached ones. Pass 0 to disable. + * The CLI shuts the whole session down instead of relaunching. */ idle?: number; }; diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index b8e3577a85056..cb7f1d1ea11d8 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -57,6 +57,7 @@ export type CLIOptions = { initScript?: string[]; initPage?: string[]; isolated?: boolean; + idleTimeout?: number; imageResponses?: 'allow' | 'omit' | 'only'; mobile?: boolean; sandbox?: boolean; @@ -75,7 +76,6 @@ export type CLIOptions = { storageState?: string; testIdAttribute?: string; timeoutAction?: number; - timeoutIdle?: number; timeoutNavigation?: number; timeoutSettle?: number; userAgent?: string; @@ -172,6 +172,7 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN mobile: options.mobile, extension: options.extension, userDataDir: options.profile, + idleTimeout: options.idleTimeout, snapshotMode: 'full', }); @@ -392,7 +393,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s testIdAttribute: cliOptions.testIdAttribute, timeouts: { action: cliOptions.timeoutAction, - idle: cliOptions.timeoutIdle, + idle: cliOptions.idleTimeout, navigation: cliOptions.timeoutNavigation, settle: cliOptions.timeoutSettle, }, @@ -452,7 +453,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: options.storageState = envToString(e.PLAYWRIGHT_MCP_STORAGE_STATE); options.testIdAttribute = envToString(e.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE); options.timeoutAction = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_ACTION); - options.timeoutIdle = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_IDLE); + options.idleTimeout = numberParser(e.PLAYWRIGHT_MCP_IDLE_TIMEOUT); options.timeoutNavigation = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION); options.timeoutSettle = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_SETTLE); options.userAgent = envToString(e.PLAYWRIGHT_MCP_USER_AGENT); diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 811ecd6021d1d..848be4c699307 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -54,6 +54,7 @@ export function decorateMCPCommand(command: Command) { .option('--grant-permissions ', 'List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".', commaSeparatedList) .option('--headless', 'run browser in headless mode, headed by default') .option('--host ', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') + .option('--idle-timeout ', 'close the browser after this many milliseconds without a completed tool call, the next tool call relaunches it. Defaults to one hour for headless browsers, never for headed ones, 0 disables.', numberParser) .option('--ignore-https-errors', 'ignore https errors') .option('--init-page ', 'path to TypeScript file to evaluate on Playwright page object') .option('--init-script ', 'path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page\'s scripts. Can be specified multiple times.') @@ -76,7 +77,6 @@ export function decorateMCPCommand(command: Command) { .option('--storage-state ', 'path to the storage state file for isolated sessions.') .option('--test-id-attribute ', 'specify the attribute to use for test ids, defaults to "data-testid"') .option('--timeout-action ', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser) - .option('--timeout-idle ', 'close the browser after this many milliseconds without a completed tool call, the next tool call relaunches it. Disabled by default.', numberParser) .option('--timeout-navigation ', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser) .option('--timeout-settle ', 'how long to wait after each action for triggered work to settle, in milliseconds, defaults to 500ms', numberParser) .option('--user-agent ', 'specify user agent string') diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md b/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md index bf39acd5f4e38..eb1bde35d9ce6 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md @@ -49,6 +49,8 @@ playwright-cli delete-data # delete default browser data playwright-cli -s=mysession delete-data # delete named browser data ``` +A headless session shuts down on its own after an hour without commands; the next command then reports that the browser is not open, so run `open` again. Headed browsers stay open. Use `open --idle-timeout=` to change the timeout, or `0` to disable it. + ## Environment Variable Set a default browser session name via environment variable: diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index 683623b88c9a9..58ce16b4998d0 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -51,6 +51,16 @@ test('close', async ({ cli, server }) => { expect(listOutput).toContain('(no browsers)'); }); +test('idle timeout shuts the session down', async ({ cli, server }) => { + await cli('open', '--idle-timeout=3000', server.HELLO_WORLD); + const { output } = await cli('list'); + expect(output).toContain('- default:'); + + await expect.poll(async () => (await cli('list')).output).toContain('(no browsers)'); + const { output: afterOutput } = await cli('snapshot'); + expect(afterOutput).toContain(`The browser 'default' is not open, please run open first`); +}); + test('close named session', async ({ cli, server }) => { await cli('-s', 'mysession', 'open', server.HELLO_WORLD); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 9123930e51562..9420239f1b48a 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -455,21 +455,21 @@ test.describe('resolveCLIConfigForMCP', () => { }); test('cli timeout overrides defaults', async () => { - const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000, timeoutIdle: 60000 }, emptyEnv); + const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000, idleTimeout: 60000 }, emptyEnv); expect(config.timeouts.action).toBe(10000); expect(config.timeouts.navigation).toBe(30000); expect(config.timeouts.expect).toBe(5000); expect(config.timeouts.idle).toBe(60000); }); - test('idle timeout is off by default and comes from the config file or env', async ({}, testInfo) => { + test('idle timeout is unset by default and comes from the config file or env', async ({}, testInfo) => { expect((await resolveCLIConfigForMCP({}, emptyEnv)).timeouts.idle).toBeUndefined(); const configFile = testInfo.outputPath('config.json'); await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { idle: 1000 } })); expect((await resolveCLIConfigForMCP({ config: configFile }, emptyEnv)).timeouts.idle).toBe(1000); - expect((await resolveCLIConfigForMCP({ config: configFile }, { ...emptyEnv, PLAYWRIGHT_MCP_TIMEOUT_IDLE: '2000' })).timeouts.idle).toBe(2000); + expect((await resolveCLIConfigForMCP({ config: configFile }, { ...emptyEnv, PLAYWRIGHT_MCP_IDLE_TIMEOUT: '2000' })).timeouts.idle).toBe(2000); }); test('cli timeout overrides config file timeout', async ({}, testInfo) => { diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 9d6e39fe70ce6..da13258d6f881 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -526,7 +526,7 @@ async function keepBusy(client: Client, ms: number) { } test('http transport shared context: one idle timer across clients', async ({ serverEndpoint, server }) => { - const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context', '--timeout-idle=500'] }); + const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context', '--idle-timeout=1500'] }); const client1 = await connectClient(url, 'test1'); await client1.client.callTool({ name: 'browser_navigate', @@ -534,7 +534,7 @@ test('http transport shared context: one idle timer across clients', async ({ se }); const client2 = await connectClient(url, 'test2'); - await keepBusy(client2.client, 1200); + await keepBusy(client2.client, 3000); expect(formatLog(stderr())).toEqual({ 'create browser (persistent)': 1, 'connect to shared browser': 2, diff --git a/tests/mcp/idle-timeout.spec.ts b/tests/mcp/idle-timeout.spec.ts index 4d9eaae915ca8..a245d691e4638 100644 --- a/tests/mcp/idle-timeout.spec.ts +++ b/tests/mcp/idle-timeout.spec.ts @@ -20,7 +20,7 @@ test('closes the browser after the idle timeout and relaunches it on the next ca annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42548' }, }, async ({ startClient, server }) => { const { client, stderr } = await startClient({ - args: ['--timeout-idle=500'], + args: ['--idle-timeout=500'], env: { DEBUG: 'pw:mcp:test' }, }); @@ -61,7 +61,7 @@ test('closes the browser after the idle timeout and relaunches it on the next ca test('cdp endpoint only disconnects on idle and reconnects to the same pages', async ({ cdpServer, startClient, server }) => { const browserContext = await cdpServer.start(); const { client, stderr } = await startClient({ - args: [`--cdp-endpoint=${cdpServer.endpoint}`, '--timeout-idle=500'], + args: [`--cdp-endpoint=${cdpServer.endpoint}`, '--idle-timeout=500'], env: { DEBUG: 'pw:mcp:test' }, });