Skip to content

[Bug]: --init-page hook races the first tool call -- Tab _initialize() is fire-and-forget and browser_take_screenshot does not await it #41650

Closed

Description

@K4uP

Description

A --init-page hook that patches/wraps methods on the page object (e.g. page.screenshot) races the first tool call of a fresh MCP process: the tool can execute before the hook has been applied. From the second tool call on, the hook works as expected.

Root cause (from the bundled source, playwright-core 1.62.0-alpha-2026-06-29 shipped with @playwright/mcp 0.0.77):

  • The Tab constructor starts initialization fire-and-forget:
    this._initializedPromise = this._initialize();
  • _initialize() performs two roundtrips first (collectConsoleMessages, page.requests()) and only then applies the init-page hooks:
    for (const initPage of this.context.config.browser?.initPage || []) {
      const { default: func } = require(initPage);
      await func({ page: this.page });
    }
  • Many Tab methods do await this._initializedPromise; (navigate, captureSnapshot, targetLocator, consoleMessages, ...), but the browser_take_screenshot handler does not -- for a plain viewport/fullPage screenshot it calls tab.page.screenshot(options) directly. (The element-screenshot path happens to be safe only because it goes through targetLocator, which awaits the promise.)

So on the very first tools/call after server start, handle() can reach tab.page.screenshot() while _initialize() is still in its first awaits -- the hook's patch is not installed yet.

Steps to reproduce

hook.js:

module.exports.default = async ({ page }) => {
  console.error('[hook] applied');
  const orig = page.screenshot.bind(page);
  page.screenshot = (o) => { console.error('[hook] wrapped call'); return orig(o); };
};
  1. Attach to a running browser that already has a page open:
    npx -y @playwright/mcp@latest --cdp-endpoint http://localhost:9222 --init-page hook.js
  2. Send initialize / notifications/initialized, then a single tools/call -> browser_take_screenshot (no prior tool call).

Expected behavior

stderr shows [hook] applied and then [hook] wrapped call for the first screenshot.

Actual behavior

stderr shows [hook] applied, but the first screenshot runs the original page.screenshot (no [hook] wrapped call). From the second browser_take_screenshot on, the wrapper is invoked.

Why this matters (how we hit it)

We use an init-page hook to work around a CDP-attach screenshot hang: when the attached browser window is minimized, Chromium's compositor produces no frames and Page.captureScreenshot never resolves -- Playwright issues no bringToFront before taking a page screenshot, so browser_take_screenshot reliably times out (page.screenshot: Timeout 5000ms exceeded, call log ends after taking page screenshot). Our hook un-minimizes/brings the tab to front around page.screenshot and restores the window state afterwards. Because of this race, the first screenshot of every fresh MCP process still bypasses the hook and times out; every later one works.

(That underlying "page screenshot hangs while the attached browser window is minimized" behavior is arguably its own bug -- happy to file it separately if useful.)

Suggested fix

Await tab._initializedPromise before tool handlers touch tab.page -- either centrally at dispatch time for the resolved target tab, or at least in the screenshot/PDF handlers that use tab.page directly. That would guarantee init-page hooks are applied before any tool uses the page.

Environment

  • OS: Windows Server 2025
  • @playwright/mcp: 0.0.77 (playwright-core 1.62.0-alpha-2026-06-29)
  • Browser: Microsoft Edge 150.0.4078.48, attached via --cdp-endpoint http://localhost:9222
  • Node: 24.x

Activity

  1. harshmathurx commented on Jul 6, 2026

    @harshmathurx
    Contributor

    I can take this. I reproduced the source path locally: browser_take_screenshot takes the no-target path straight to tab.page.screenshot(...), while the target path goes through tab.targetLocator(...) and waits for tab initialization.

    I'll open a focused PR in microsoft/playwright since the implementation lives in playwright-core, and link it back here.

  2. harshmathurx commented on Jul 6, 2026

    @harshmathurx
    Contributor

    PR is open in microsoft/playwright: #41646

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

Metadata

Metadata

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