microsoft / microsoft/playwright

[Bug]: MCP idle timeout closes the browser during a tool call that outlasts it, failing that call

Open Beginner friendly
#42,693 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
96.3k
Forks
6.5k
Avg merge
1d 6h
Merged PRs (30d)
180

Description

### Version

1.64.0-next, `d1ead3ecc`. Regressed in #42676 (`3b346c8df`).

### Steps to reproduce

A single tool call that runs longer than the idle timeout has its own browser closed underneath it, then fails.

```js
const { client } = await startClient({ args: ['--idle-timeout=1000'] });
await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } });
// One call, 3x the idle timeout.
const waited = await client.callTool({ name: 'browser_wait_for', arguments: { time: 3 } });
```

### Expected

The call completes. An idle timeout should measure time with nothing running, so a call in flight is by definition not idle.

At `dc0f85227`, the parent of the regressing commit, that is what happens:

```
wait isError: false | "### Result\nWaited for 3 ..."
log: {"create browser (persistent)":1,"create context":1}
```

### Actual

At `d1ead3ecc` the same probe closes the browser mid-call and the call errors:

```
wait isError: true | "### Error\nError: No open pages available."
log: {"create browser (persistent)":1,"create context":1,"close browser":1}
```

The navigated page is gone too, so the next `browser_snapshot` comes back as `about:blank` and the client silently loses its state.

### Cause

#42663 gave `IdleTimer` a refcount so the timer was cancelled for the duration of a call and only re-armed once the last one finished:

```ts
callStarted() { ++this._running; this.dispose(); }
callFinished() { if (!--this._running) this._timer = setTimeout(this._onIdle, this._timeout).unref(); }
```

#42676 replaced both with a single `poke()` that `BrowserBackend.callTool` calls when a call *starts*:

```ts
poke() {
this.dispose();
this._timer = setTimeout(this._onIdle, this._timeout);
}
```

so the clock now runs during the call rather than around it.

What made this easy to miss is that #42663 shipped a test for exactly this, `does not close the browser while a tool call is running`. #42666 removed it while consolidating to one test per scenario, and #42676 landed after that, so nothing was left to catch it. Re-adding that test would pin the behaviour.

One smaller thing in the same three lines: the re-armed timer used to be `.unref()`ed and no longer is. Now that the timer is armed by default with `defaultIdleTimeout` of an hour, a pending one will hold the event loop open. I did not chase that far enough to say whether anything else keeps the process alive anyway, so treat it as a note rather than a claim.

The default of an hour makes this hard to hit by accident, but any explicitly lowered `--idle-timeout` combined with a slow navigation or a long `browser_wait_for` runs into it.

I am a freshman in college trying to be genuinely useful on real projects, so if the new semantics are intentional and the timer is meant to bound total call time too, I am happy to be told so.

Contributor guide

Open the contributing guide

Research direction

Start with IdleTimer and BrowserBackend.callTool, then find the existing test named "does not close the browser while a tool call is running" or the consolidated scenario test. Reproduce the issue with --idle-timeout=1000 and a browser_wait_for call lasting longer than the timeout. Done means the in-flight call completes, the browser remains open, and the regression test passes.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
testing-qa
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.