All posts

October 25, 20265 min readbrowsercdpengineering

A JavaScript Dialog Looks Exactly Like a Frozen Tab

Every CDP command on the tab times out, CPU is at zero and the screen shows a normal page. Before you call it a hung renderer, rule out a native dialog.

By Scrappy Team

A click on a button never returned. The CDP call — Input.dispatchMouseEvent — timed out, and after it every page-level command on that tab timed out too. Evaluate a script: timeout. Take a screenshot: timeout. A blank tab beside it in the same browser answered instantly.

We investigated from a fresh DevTools connection and found what looked like a renderer stuck in a wait: Runtime.evaluate and Page.enable both timed out, every Chrome process sat at zero CPU, and a grab of the display showed an ordinary form. We wrote it up as "renderer blocked, cause unknown".

It was a confirm() dialog. Two of them, in fact, one behind the other. Every observation above is exactly what a dialog looks like, and the method we used to investigate was structurally unable to see it.

Why a fresh connection cannot see a dialog

Three facts about Chrome's DevTools protocol, each of which we measured afterwards:

  • A dialog is announced only to clients that already had the Page domain enabled when it opened. They receive Page.javascriptDialogOpening, and only they can answer it.
  • A client that attaches later is told nothing. Ask it to handle the dialog and Chrome replies "No dialog is showing" — while the dialog is showing.
  • With a dialog open, a newly attached client times out on Page.enable itself, as well as on any evaluate. So "even Page.enable hangs" does not distinguish a dialog from a dead renderer.

Add the rest: a renderer waiting in a dialog's nested loop is idle, so zero CPU is expected. And when a DevTools client is holding the dialog, Chrome does not draw a native window, so a screenshot of the display shows the page and nothing else.

The test that works

Ask a connection that was attached before the dialog opened to dismiss it. Page.handleJavaScriptDialog errors when nothing is open, so a success is proof. That is what established it here — from the session's own long-lived connection, not from the investigation's fresh one.

The first dismiss succeeded and the tab stayed blocked. The second succeeded and the tab came back: the page had opened a second dialog behind the first, within milliseconds.

Suppressing dialogs does not catch them all

The client driving that page had already tried to prevent this. Six seconds before each click it replaced window.confirm in the page with a function that returns true. It did not help, because that override lives in the top-level document, and a dialog opened from an iframe goes straight past it. We confirmed that with a test page.

What we changed

  • Remember the dialog. Our sessions now keep a connection with Page enabled from the moment a tab exists, record each open dialog per tab, and clear it when Chrome reports it closed.
  • Say so in the error. A command timeout while a dialog is known to be open now carries the dialog's type and where it came from, and points at the endpoint that answers it — from the first timeout, not after someone has guessed.
  • Report chained dialogs. Answering a dialog returns the next one if another opened behind it, so a caller does not stop at "dismissed" while the tab is still blocked.
  • Keep the record somewhere that outlives a deploy. A process that attaches after a dialog opened can never answer it, so the browser host keeps its own record and can answer on our behalf.

And when nothing else works, there is a last resort. Closing a target is handled by Chrome's browser process, not the blocked renderer, so a new tab can be opened in the same context and the stuck one closed. It works — signed in, cookies intact — but it costs whatever the page held, such as a half-filled form. Answering the dialog would not have.

The checklist

When a tab stops answering every command:

  1. Rule out a dialog first, from a connection that was attached before the hang began.
  2. Do not trust a fresh connection's view: it cannot see the dialog and will time out the same way.
  3. After answering one, check for another.
  4. Remember that overriding window.confirm covers the top document only.
  5. Replace the tab only when you are prepared to lose its state.

Scrappy's cloud browser now does the first four for you, and its error responses tell you when a dialog is the reason a command timed out.

Keep reading