From 89e617a3cbbf3d35714b2d92c11d0d812c531405 Mon Sep 17 00:00:00 2001 From: Anurag Kumar Singh Date: Sat, 12 Sep 2026 22:09:40 +0530 Subject: [PATCH 1/3] fix(mcp): do not close browser while tool call is running --- .../src/tools/backend/browserBackend.ts | 10 +++++++- .../src/tools/backend/idleTimer.ts | 16 +++++++++++- tests/mcp/idle-timeout.spec.ts | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index b5036df30cc6d..1c7b3dbeebfca 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -82,7 +82,15 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement } async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}, signal?: AbortSignal): Promise { - this._idleTimer?.poke(); + 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 { 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 641d296681db9..fd30ab0ef8616 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -19,6 +19,7 @@ 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) { @@ -26,9 +27,22 @@ export class IdleTimer { this._onIdle = onIdle; } + callStarted() { + ++this._running; + this.dispose(); + } + + callFinished() { + if (this._running > 0) + --this._running; + if (this._running === 0) + this._timer = setTimeout(this._onIdle, this._timeout).unref(); + } + poke() { this.dispose(); - this._timer = setTimeout(this._onIdle, this._timeout); + if (!this._running) + this._timer = setTimeout(this._onIdle, this._timeout).unref(); } dispose() { diff --git a/tests/mcp/idle-timeout.spec.ts b/tests/mcp/idle-timeout.spec.ts index a245d691e4638..d68a66f725208 100644 --- a/tests/mcp/idle-timeout.spec.ts +++ b/tests/mcp/idle-timeout.spec.ts @@ -92,3 +92,28 @@ test('cdp endpoint only disconnects on idle and reconnects to the same pages', a 'close browser': 1, }); }); + +test('does not close the browser while a tool call is running', async ({ startClient, server }) => { + const { client, stderr } = await startClient({ + args: ['--idle-timeout=500'], + env: { DEBUG: 'pw:mcp:test' }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + // The wait outlasts the idle timeout, which only starts once the call completes. + expect(await client.callTool({ + name: 'browser_wait_for', + arguments: { time: 1 }, + })).toHaveResponse({ + code: `await new Promise(f => setTimeout(f, 1 * 1000));`, + }); + + expect(formatLog(stderr())).toEqual({ + 'create browser (persistent)': 1, + 'create context': 1, + }); +}); From 1c947d07fb4590d29e3980ea238fbc67014dc9c7 Mon Sep 17 00:00:00 2001 From: Anurag Kumar Singh Date: Tue, 15 Sep 2026 22:45:58 +0530 Subject: [PATCH 2/3] fix(mcp): do not restart idle timer after browser_close --- .../src/tools/backend/browserBackend.ts | 1 + .../src/tools/backend/idleTimer.ts | 18 ++++++++-- tests/mcp/idle-timeout.spec.ts | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 1c7b3dbeebfca..52de214ebefef 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -127,6 +127,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement } if (this._disconnected || responseObject.isClose) { delete responseObject.isClose; + this._idleTimer?.dispose(); await this.dispose(); } return responseObject; diff --git a/packages/playwright-core/src/tools/backend/idleTimer.ts b/packages/playwright-core/src/tools/backend/idleTimer.ts index fd30ab0ef8616..8f3fc44b629fe 100644 --- a/packages/playwright-core/src/tools/backend/idleTimer.ts +++ b/packages/playwright-core/src/tools/backend/idleTimer.ts @@ -21,6 +21,7 @@ export class IdleTimer { private _onIdle: () => void; private _running = 0; private _timer: NodeJS.Timeout | undefined; + private _disposed = false; constructor(timeout: number, onIdle: () => void) { this._timeout = timeout; @@ -28,25 +29,36 @@ export class IdleTimer { } callStarted() { + if (this._disposed) + return; ++this._running; - this.dispose(); + this._clearTimer(); } callFinished() { if (this._running > 0) --this._running; + if (this._disposed) + return; if (this._running === 0) this._timer = setTimeout(this._onIdle, this._timeout).unref(); } poke() { - this.dispose(); + if (this._disposed) + return; + this._clearTimer(); if (!this._running) this._timer = setTimeout(this._onIdle, this._timeout).unref(); } - dispose() { + private _clearTimer() { clearTimeout(this._timer); this._timer = undefined; } + + dispose() { + this._disposed = true; + this._clearTimer(); + } } diff --git a/tests/mcp/idle-timeout.spec.ts b/tests/mcp/idle-timeout.spec.ts index d68a66f725208..45daa284eaf98 100644 --- a/tests/mcp/idle-timeout.spec.ts +++ b/tests/mcp/idle-timeout.spec.ts @@ -117,3 +117,36 @@ test('does not close the browser while a tool call is running', async ({ startCl 'create context': 1, }); }); + +test('does not restart the idle timer after browser_close', async ({ startClient, server }) => { + const { client, stderr } = await startClient({ + args: ['--idle-timeout=500'], + env: { DEBUG: 'pw:mcp:test' }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + expect(await client.callTool({ + name: 'browser_close', + })).toHaveResponse({ + code: `await page.close()`, + }); + + expect(formatLog(stderr())).toEqual({ + 'create browser (persistent)': 1, + 'create context': 1, + 'close browser': 1, + }); + + // Outlast the idle timeout to ensure the timer is not re-armed after browser_close. + await new Promise(f => setTimeout(f, 1000)); + + expect(formatLog(stderr())).toEqual({ + 'create browser (persistent)': 1, + 'create context': 1, + 'close browser': 1, + }); +}); From 0b0158327b10c884cb9bbda38fe84e2a5901c757 Mon Sep 17 00:00:00 2001 From: Anurag Kumar Singh Date: Wed, 16 Sep 2026 13:18:29 +0530 Subject: [PATCH 3/3] fix(mcp): do not dispose idle timer on browser_close in backend --- .../src/tools/backend/browserBackend.ts | 1 - tests/mcp/idle-timeout.spec.ts | 33 ------------------- 2 files changed, 34 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 52de214ebefef..1c7b3dbeebfca 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -127,7 +127,6 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement } if (this._disconnected || responseObject.isClose) { delete responseObject.isClose; - this._idleTimer?.dispose(); await this.dispose(); } return responseObject; diff --git a/tests/mcp/idle-timeout.spec.ts b/tests/mcp/idle-timeout.spec.ts index 45daa284eaf98..d68a66f725208 100644 --- a/tests/mcp/idle-timeout.spec.ts +++ b/tests/mcp/idle-timeout.spec.ts @@ -117,36 +117,3 @@ test('does not close the browser while a tool call is running', async ({ startCl 'create context': 1, }); }); - -test('does not restart the idle timer after browser_close', async ({ startClient, server }) => { - const { client, stderr } = await startClient({ - args: ['--idle-timeout=500'], - env: { DEBUG: 'pw:mcp:test' }, - }); - - await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }); - - expect(await client.callTool({ - name: 'browser_close', - })).toHaveResponse({ - code: `await page.close()`, - }); - - expect(formatLog(stderr())).toEqual({ - 'create browser (persistent)': 1, - 'create context': 1, - 'close browser': 1, - }); - - // Outlast the idle timeout to ensure the timer is not re-armed after browser_close. - await new Promise(f => setTimeout(f, 1000)); - - expect(formatLog(stderr())).toEqual({ - 'create browser (persistent)': 1, - 'create context': 1, - 'close browser': 1, - }); -});