Skip to content

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

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.

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:

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:

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions