feat(cli): shut idle headless sessions down after an hour (#42676)

This commit is contained in:
Yury Semikhatsky
2026-09-11 15:51:39 -07:00
committed by GitHub
parent 6750deccd1
commit 3b346c8df5
17 changed files with 50 additions and 37 deletions
+2
View File
@@ -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=<ms>` 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:
+3 -3
View File
@@ -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
@@ -82,15 +82,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement
}
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record<string, any> } = {}, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
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<string, any> }, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
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}` }],
@@ -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() {
@@ -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)
@@ -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: '<auto>' }),
@@ -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<string> {
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;
}
@@ -43,6 +43,7 @@ export function decorateProgram(program: Command) {
.option('--config <path>', '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 <url>', 'connect to an existing browser via CDP endpoint URL')
.option('--endpoint <endpoint>', 'attach to a running Playwright browser endpoint')
.option('--idle-timeout <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 <value>', 'install skills for the given agent type ("claude" or "agents")')
.option('--init-skills-global <value>', '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);
@@ -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<BrowserWithInfo> {
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());
+3 -1
View File
@@ -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;
};
@@ -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);
@@ -54,6 +54,7 @@ export function decorateMCPCommand(command: Command) {
.option('--grant-permissions <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>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--idle-timeout <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...>', 'path to TypeScript file to evaluate on Playwright page object')
.option('--init-script <path...>', '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>', 'path to the storage state file for isolated sessions.')
.option('--test-id-attribute <attribute>', 'specify the attribute to use for test ids, defaults to "data-testid"')
.option('--timeout-action <timeout>', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser)
.option('--timeout-idle <timeout>', '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 <timeout>', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser)
.option('--timeout-settle <timeout>', 'how long to wait after each action for triggered work to settle, in milliseconds, defaults to 500ms', numberParser)
.option('--user-agent <ua string>', 'specify user agent string')
@@ -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=<ms>` to change the timeout, or `0` to disable it.
## Environment Variable
Set a default browser session name via environment variable:
+10
View File
@@ -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);
+3 -3
View File
@@ -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) => {
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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' },
});