Skip to content

fix(mcp): guard tab title() with a timeout to prevent CLI-wide hangs on unresponsive tabs#41748

Open
Git-Raini wants to merge 1 commit into
microsoft:mainfrom
Git-Raini:fix-41714-unresponsive-tab-hang
Open

fix(mcp): guard tab title() with a timeout to prevent CLI-wide hangs on unresponsive tabs#41748
Git-Raini wants to merge 1 commit into
microsoft:mainfrom
Git-Raini:fix-41714-unresponsive-tab-hang

Conversation

@Git-Raini

Copy link
Copy Markdown
Contributor

Fixes #41714

Root cause

Tab.headerSnapshot() (packages/playwright-core/src/tools/backend/tab.ts) calls await this.page.title() inside _raceAgainstModalStates(...), which only races the action against a modal-state event, not a timeout - if the action never resolves and no modal ever appears, it hangs forever. page.title() ultimately does context.evaluate(() => document.title) in the page's utility execution context server-side (Frame._title() in server/frames.ts), which requires the renderer to actually respond to a CDP Runtime.evaluate call. A tab discarded by Chrome's Memory Saver never answers such calls.

Response rendering for every MCP/CLI command calls headerSnapshot() for all open tabs, not just the current one - so one wedged tab blocks every command, even ones that don't touch it. That's the reported symptom.

Fix

  • Added raceAgainstTimeout() to tools/backend/utils.ts - wraps a promise with a timeout, returns { timedOut: true } or { timedOut: false, result } instead of throwing.
  • headerSnapshot() now wraps page.title() with a 3000ms timeout. On timeout: falls back to the last known title (instead of blank) and marks the tab unresponsive: true.
  • Added unresponsive to the TabHeader type and to tabHeaderEquals.
  • renderTabsMarkdown/renderTabMarkdown render [unresponsive] / - Page status: unresponsive, mirroring the existing [crashed] pattern exactly.

Deliberately out of scope: a CDP Target.getTargetInfo fallback for title/url (suggested as an alternative in the issue). The timeout guard + last-known-title fallback fully resolves the hang, which is the critical bug; adding a second CDP session type for a cosmetic title-accuracy improvement felt like unnecessary surface area for this fix. Also did not touch consoleMessageCount() - traced page.consoleMessages()/page.pageErrors() and confirmed they read a server-buffered list, not a live renderer round-trip, so they aren't part of this hang.

Testing

The hard part here was proving this against something more than a synthetic timeout - Chrome's real Memory Saver discard isn't reliably triggerable in CI. Simulated it faithfully instead: a page that busy-loops its main JS thread for a bounded 3.5s, which genuinely blocks Runtime.evaluate from completing, the same mechanism as the real bug (verified this is a faithful simulation, not just "a promise that never resolves" - it exercises the actual CDP layer). New test opens that page, confirms browser_tabs list and browser_snapshot both complete in ~3s (not 30s), that the busy tab is marked [unresponsive], and that the other, untouched tab still reports its real title in the same response - directly proving one wedged tab doesn't poison the rest, which is the core of the original report.

Ran the new integration test 8x back-to-back (--repeat-each=8 --workers=1) to check for timing flakiness given the chained timing assumptions (3000ms guard vs 3500ms loop vs staging delays) - all 8 passed with consistent ~5.1-5.3s timing, no flakes observed.

Lint / typecheck output
$ npx eslint packages/playwright-core/src/tools/backend/{utils,tab,response}.ts tests/mcp/{unresponsive-tab,utils}.spec.ts
(clean, exit 0)

$ npx tsc -p tsconfig.json --noEmit
(clean, exit 0)
Test output - new tests (with 8x repeat), plus existing crash.spec.ts and tabs.spec.ts (no regressions)
$ npm run ctest-mcp utils.spec


Running 2 tests using 2 workers

  ok 1 [chrome] › tests\mcp\utils.spec.ts:26:5 › raceAgainstTimeout times out when the promise settles after the timeout (31ms)
  ok 2 [chrome] › tests\mcp\utils.spec.ts:20:5 › raceAgainstTimeout resolves when the promise settles before the timeout (25ms)

  2 passed (1.1s)

$ npm run ctest-mcp unresponsive-tab --repeat-each=8 --workers=1
Running 8 tests using 1 worker

  ok 1 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.3s)
  ok 2 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.1s)
  ok 3 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.3s)
  ok 4 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.1s)
  ok 5 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.1s)
  ok 6 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.1s)
  ok 7 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.2s)
  ok 8 [chrome] › tests\mcp\unresponsive-tab.spec.ts:60:7 › unresponsive tab › does not block other commands and marks the tab as unresponsive (5.2s)

  8 passed (51.8s)

$ npm run ctest-mcp crash

Running 3 tests using 3 workers

  ok 2 [chrome] › tests\mcp\crash.spec.ts:31:7 › crash recovery › resets to about:blank and logs the crash (6.2s)
  ok 1 [chrome] › tests\mcp\crash.spec.ts:53:7 › crash recovery › lists only one tab (6.5s)
  ok 3 [chrome] › tests\mcp\crash.spec.ts:67:7 › crash recovery › marks non-current crashed tab in the tab list (6.5s)

  3 passed (9.3s)

$ npm run ctest-mcp tabs.spec

Running 7 tests using 4 workers

  ok 2 [chrome] › tests\mcp\tabs.spec.ts:36:5 › list initial tabs (5.9s)
  ok 3 [chrome] › tests\mcp\tabs.spec.ts:81:5 › create new tab with url (7.6s)
  ok 4 [chrome] › tests\mcp\tabs.spec.ts:47:5 › list first tab (7.6s)
  ok 1 [chrome] › tests\mcp\tabs.spec.ts:60:5 › create new tab (7.7s)
  ok 5 [chrome] › tests\mcp\tabs.spec.ts:94:5 › select tab (5.5s)
  ok 7 [chrome] › tests\mcp\tabs.spec.ts:139:5 › reuse first tab when navigating (4.0s)
  ok 6 [chrome] › tests\mcp\tabs.spec.ts:123:5 › close tab (4.3s)

  7 passed (15.1s)

@Git-Raini

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] playwright-cli: one discarded (Memory Saver) tab makes every command time out — tab header rendering awaits page.title() without timeout

1 participant