From f4f920556df618f41c05d6b3af1bcb86971ac4a8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 11 Sep 2026 12:01:44 -0700 Subject: [PATCH 1/4] feat(cli): shut idle headless sessions down after an hour The daemon now honors timeouts.idle and defaults it to one hour for headless browsers it launched, exiting the same way `close` does. Headed and attached browsers keep running unless a timeout is set explicitly. `open --idle-timeout=` overrides the default and 0 disables it. The MCP server gets the same default, and its option is renamed to --idle-timeout to match. Fixes https://github.com/microsoft/playwright-cli/issues/460 --- docs/src/getting-started-cli.md | 2 ++ docs/src/getting-started-mcp.md | 6 +++--- .../playwright-core/src/tools/backend/idleTimer.ts | 10 +++++++++- .../playwright-core/src/tools/cli-client/session.ts | 2 ++ .../playwright-core/src/tools/cli-daemon/commands.ts | 2 ++ .../playwright-core/src/tools/cli-daemon/daemon.ts | 5 ++++- .../playwright-core/src/tools/cli-daemon/program.ts | 5 +++-- .../playwright-core/src/tools/mcp/browserFactory.ts | 4 ++-- packages/playwright-core/src/tools/mcp/config.d.ts | 4 +++- packages/playwright-core/src/tools/mcp/config.ts | 7 ++++--- packages/playwright-core/src/tools/mcp/program.ts | 2 +- .../playwright-cli/references/session-management.md | 2 ++ tests/mcp/cli-session.spec.ts | 10 ++++++++++ tests/mcp/config-resolve.spec.ts | 6 +++--- tests/mcp/http.spec.ts | 2 +- tests/mcp/idle-timeout.spec.ts | 4 ++-- 16 files changed, 53 insertions(+), 20 deletions(-) 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/idleTimer.ts b/packages/playwright-core/src/tools/backend/idleTimer.ts index 4bde60d601872..69ac50a19f90b 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +// Only headless browsers we launched get this by default, headed and attached ones may be in use by a person. +export const defaultIdleTimeout = 60 * 60 * 1000; + export class IdleTimer { private _timeout: number; private _onIdle: () => void; @@ -25,6 +28,11 @@ export class IdleTimer { this._onIdle = onIdle; } + // For a session that is ready before its first call and may never receive one. + arm() { + this._timer = setTimeout(this._onIdle, this._timeout).unref(); + } + callStarted() { ++this._running; this.dispose(); @@ -32,7 +40,7 @@ export class IdleTimer { callFinished() { if (!--this._running) - this._timer = setTimeout(this._onIdle, this._timeout).unref(); + this.arm(); } 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..5988e4062f105 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?.arm(); 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..c562c47427819 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=500', 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..ca7351f59a8db 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=500'] }); const client1 = await connectClient(url, 'test1'); await client1.client.callTool({ name: 'browser_navigate', 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' }, }); From 71acf89ea8753fc9d8758ba5f10d4abd95550769 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 11 Sep 2026 12:11:54 -0700 Subject: [PATCH 2/4] chore: drop comments from the idle timer --- packages/playwright-core/src/tools/backend/idleTimer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/idleTimer.ts b/packages/playwright-core/src/tools/backend/idleTimer.ts index 69ac50a19f90b..a69c5653a6f94 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -// Only headless browsers we launched get this by default, headed and attached ones may be in use by a person. export const defaultIdleTimeout = 60 * 60 * 1000; export class IdleTimer { @@ -28,7 +27,6 @@ export class IdleTimer { this._onIdle = onIdle; } - // For a session that is ready before its first call and may never receive one. arm() { this._timer = setTimeout(this._onIdle, this._timeout).unref(); } From 428f9ec0015d42112280b11d0ecd196d07aa097a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 11 Sep 2026 15:00:30 -0700 Subject: [PATCH 3/4] chore(mcp): reset the idle timer at the start of each call Drops the running-call bookkeeping: a tool call is expected to be short relative to the timeout, so restarting the countdown when the call starts is enough and removes the callTool wrapper. --- .../src/tools/backend/browserBackend.ts | 10 +--------- .../playwright-core/src/tools/backend/idleTimer.ts | 14 ++------------ .../playwright-core/src/tools/cli-daemon/daemon.ts | 2 +- tests/mcp/cli-session.spec.ts | 2 +- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 1c7b3dbeebfca..67523a5b5b11e 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?.reset(); 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 a69c5653a6f94..f8964381d7d72 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -19,7 +19,6 @@ 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) { @@ -27,18 +26,9 @@ export class IdleTimer { this._onIdle = onIdle; } - arm() { - this._timer = setTimeout(this._onIdle, this._timeout).unref(); - } - - callStarted() { - ++this._running; + reset() { this.dispose(); - } - - callFinished() { - if (!--this._running) - this.arm(); + this._timer = setTimeout(this._onIdle, this._timeout).unref(); } dispose() { diff --git a/packages/playwright-core/src/tools/cli-daemon/daemon.ts b/packages/playwright-core/src/tools/cli-daemon/daemon.ts index 5988e4062f105..8924b3c3ad23d 100644 --- a/packages/playwright-core/src/tools/cli-daemon/daemon.ts +++ b/packages/playwright-core/src/tools/cli-daemon/daemon.ts @@ -143,7 +143,7 @@ export async function startCliDaemonServer( }); await saveSessionFile(clientInfo, sessionConfig); - options.idleTimer?.arm(); + options.idleTimer?.reset(); await monitorSocketPath(socketPath); return socketPath; } diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index c562c47427819..58ce16b4998d0 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -52,7 +52,7 @@ test('close', async ({ cli, server }) => { }); test('idle timeout shuts the session down', async ({ cli, server }) => { - await cli('open', '--idle-timeout=500', server.HELLO_WORLD); + await cli('open', '--idle-timeout=3000', server.HELLO_WORLD); const { output } = await cli('list'); expect(output).toContain('- default:'); From d15e949131989d54b2c790fa98790288b73e1c5f Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 11 Sep 2026 15:15:13 -0700 Subject: [PATCH 4/4] chore(mcp): rename the idle timer reset to poke Also drops unref(): both the MCP server and the CLI daemon exit through process.exit(), so the timer never decides whether the process lives. --- packages/playwright-core/src/tools/backend/browserBackend.ts | 2 +- packages/playwright-core/src/tools/backend/idleTimer.ts | 4 ++-- packages/playwright-core/src/tools/cli-daemon/daemon.ts | 2 +- tests/mcp/http.spec.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 67523a5b5b11e..b5036df30cc6d 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -82,7 +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?.reset(); + 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 f8964381d7d72..641d296681db9 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -26,9 +26,9 @@ export class IdleTimer { this._onIdle = onIdle; } - reset() { + poke() { this.dispose(); - 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-daemon/daemon.ts b/packages/playwright-core/src/tools/cli-daemon/daemon.ts index 8924b3c3ad23d..b71dcc477ec51 100644 --- a/packages/playwright-core/src/tools/cli-daemon/daemon.ts +++ b/packages/playwright-core/src/tools/cli-daemon/daemon.ts @@ -143,7 +143,7 @@ export async function startCliDaemonServer( }); await saveSessionFile(clientInfo, sessionConfig); - options.idleTimer?.reset(); + options.idleTimer?.poke(); await monitorSocketPath(socketPath); return socketPath; } diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index ca7351f59a8db..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', '--idle-timeout=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,