git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit623734452f8097eaa79e8f3c1d1da659218a6340
parentc96aec517f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 17:43
feat: recover from a crashed or hung page

A tab whose WebProcess died used to freeze on its last frame, and a hung
one gave no sign at all (a WebKit/Mesa deadlock left a tab dead with the
UI process idle). Now:

- web-process-terminated loads an error page as alternate HTML under the
  dead page's own URL, so the URL bar, the session and Reload still mean
  the real page.
- A hung active tab raises a modal: Stop page kills the process (landing
  in the error page), Wait leaves it until it recovers. Enter does not
  stop, so a keystroke meant for the page cannot destroy it.
- A ping in a private script world on every page press catches the hang
  WebKit's own timer misses: a pointer move queues the clicks behind it
  and never starts the timer.

examples/wpe_crash.rs proves all of it against the real engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md             |  28 ++++++++
 examples/wpe_crash.rs | 188 ++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs           |  54 ++++++++++++--
 src/wpe/host.rs       | 196 +++++++++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 459 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4cb70ee..b22b37a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -233,6 +233,34 @@ are choices, not accidents:
   WebProcess each on WPE). Fine at normal tab counts; lazy restore is the
   upgrade path if someone lives with dozens.
 
+### A dead or hung page
+
+A tab's WebProcess can die (crash, memory limit) or hang (a deadlock inside
+WebKit/Mesa froze one for good on 2026-10-05: main thread on a driver lock,
+zero CPU, the UI process perfectly idle — it looked like the browser was
+stuck). `src/wpe/host.rs` handles both; `examples/wpe_crash.rs` proves them
+against the real engine. Points that are choices:
+
+- **A dead process gets an error page under the dead page's URL**
+  (`web-process-terminated` → `terminated_page`, loaded as alternate HTML in
+  `pump`, not inside the signal). The URL bar, the saved session and Reload
+  all still mean the real page. The real URL is passed as the *base* URI too:
+  without it the error page is `about:blank` to itself and its Reload link
+  goes nowhere (refused outright for a `file:` page).
+- **A hang raises a modal** ("This page isn't responding": Stop page / Wait).
+  Stop kills the process with `webkit_web_view_terminate_web_process`, which
+  lands in the error page above. Wait (or Escape) is remembered per tab until
+  the page answers again. **Enter does not stop**: the question can appear
+  mid-typing, and an Enter aimed at the page must not destroy what was typed.
+- **WebKit's own responsiveness timer misses the commonest hang.** Pointer
+  events are queued behind an unacknowledged one, and a *move* — which always
+  precedes a click — does not start the timer, so the clicks behind it are
+  never even sent. Keys and the wheel are caught; move-then-click was not.
+  Every page press therefore also sends a no-op script in a private world
+  (`ping`), and a ping unanswered for `HANG_GRACE` (3s) is a hang. A one-shot
+  GLib timeout wakes the loop when the grace runs out, since a hung page
+  sends nothing that would.
+
 ## The chrome is hand-rolled
 
 There are **no `cce-ui` widgets in this app**. The whole utility bar is emitted as
diff --git a/examples/wpe_crash.rs b/examples/wpe_crash.rs
new file mode 100644
index 0000000..3f67d53
--- /dev/null
+++ b/examples/wpe_crash.rs
@@ -0,0 +1,188 @@
+//! Proves what a tab does when its WebProcess hangs or dies.
+//!
+//! Serves a page that spins its main thread forever on the first visit and
+//! loads normally on the next, and checks, against the real engine:
+//!
+//! * a hung page is reported unresponsive once a click goes unanswered —
+//!   including after a pointer move, which WebKit's own timer misses;
+//! * a page that answers is not;
+//! * "Wait" quiets the question, and stopping kills the process;
+//! * the dead tab shows the error page **under its own URL**, so the URL
+//!   bar and the saved session still mean the real page;
+//! * a reload from there fetches the real page again;
+//! * a WebProcess that dies outright (SIGKILL) gets the crash page.
+//!
+//! `cargo run --release -p cce-browser --example wpe_crash`
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+    eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand { Copy, Cut, Paste }
+
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+/// Spins once it has painted, so there is a frame to be stuck on.
+#[cfg(feature = "wpe")]
+const HANG: &str = "<!doctype html><title>hang</title><p>spinning\
+<script>setTimeout(() => { for (;;) {} }, 300)</script>";
+#[cfg(feature = "wpe")]
+const FINE: &str = "<!doctype html><title>recovered</title><p>fine";
+
+/// The first request gets the hanging page, every later one the fine one.
+#[cfg(feature = "wpe")]
+fn serve() -> u16 {
+    use std::io::{Read, Write};
+    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+    let port = listener.local_addr().unwrap().port();
+    std::thread::spawn(move || {
+        let mut served = 0;
+        for stream in listener.incoming() {
+            let Ok(mut s) = stream else { continue };
+            let mut buf = [0u8; 4096];
+            let n = s.read(&mut buf).unwrap_or(0);
+            if !String::from_utf8_lossy(&buf[..n]).starts_with("GET /page") {
+                let _ = write!(s, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+                continue;
+            }
+            let body = if served == 0 { HANG } else { FINE };
+            served += 1;
+            let _ = write!(
+                s,
+                "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                body.len(),
+                body
+            );
+        }
+    });
+    port
+}
+
+/// Every `WPEWebProcess` under this process (they sit below bwrap).
+#[cfg(feature = "wpe")]
+fn web_processes() -> Vec<i32> {
+    let me = std::process::id() as i32;
+    let parent_of = |pid: i32| -> Option<i32> {
+        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
+        stat.rsplit_once(')')?.1.split_whitespace().nth(1)?.parse().ok()
+    };
+    let mut out = Vec::new();
+    for e in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
+        let Ok(pid) = e.file_name().to_string_lossy().parse::<i32>() else { continue };
+        let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default();
+        if comm.trim() != "WPEWebProcess" {
+            continue;
+        }
+        let mut p = pid;
+        while let Some(pp) = parent_of(p) {
+            if pp == me {
+                out.push(pid);
+                break;
+            }
+            if pp <= 1 {
+                break;
+            }
+            p = pp;
+        }
+    }
+    out
+}
+
+#[cfg(feature = "wpe")]
+fn main() {
+    let port = serve();
+    let page = url::Url::parse(&format!("http://127.0.0.1:{port}/page")).unwrap();
+    let mut host = wpe::WebKitHost::new(page.clone(), (800, 600));
+    host.focus(true);
+    let settle = |h: &mut wpe::WebKitHost, ms: u64| {
+        let end = std::time::Instant::now() + std::time::Duration::from_millis(ms);
+        while std::time::Instant::now() < end {
+            h.pump();
+            // Nothing draws here; say so, or the readback waits forever.
+            h.frame_drawn();
+            std::thread::sleep(std::time::Duration::from_millis(20));
+        }
+    };
+
+    use cce_ui::widget::MouseButton;
+    let mut ok = true;
+    let mut check = |what: &str, pass: bool, detail: String| {
+        ok &= pass;
+        println!("{what:<44} {} {detail}", if pass { "OK   " } else { "WRONG" });
+    };
+
+    settle(&mut host, 1500);
+    check("the hanging page loaded", host.title().as_deref() == Some("hang"), format!("{:?}", host.title()));
+    check("not unresponsive before any input", !host.active_unresponsive(), String::new());
+
+    // Move, then click: the order a person does it in, and the one WebKit's
+    // own timer misses — the click queues behind the unanswered move and is
+    // never sent. The host's ping is what catches it.
+    host.mouse_move(100.0, 100.0);
+    host.mouse_button_ui(MouseButton::Left, true, 100.0, 100.0);
+    host.mouse_button_ui(MouseButton::Left, false, 100.0, 100.0);
+    settle(&mut host, 4000);
+    check("unanswered input reports a hang", host.active_unresponsive(), String::new());
+
+    host.wait_unresponsive();
+    check("waiting quiets the question", !host.active_unresponsive(), String::new());
+
+    host.stop_unresponsive();
+    settle(&mut host, 1500);
+    let title = host.title().unwrap_or_default();
+    check("a stopped page shows the error page", title == "This page was stopped", format!("{title:?}"));
+    check("under its own URL", host.url().as_ref() == Some(&page), format!("{:?}", host.url().map(|u| u.to_string())));
+    check("and is no longer unresponsive", !host.active_unresponsive(), String::new());
+
+    host.reload();
+    settle(&mut host, 1500);
+    let title = host.title().unwrap_or_default();
+    check("reload fetches the real page", title == "recovered", format!("{title:?}"));
+
+    // A page that answers is never asked about.
+    host.mouse_move(120.0, 120.0);
+    host.mouse_button_ui(MouseButton::Left, true, 120.0, 120.0);
+    host.mouse_button_ui(MouseButton::Left, false, 120.0, 120.0);
+    settle(&mut host, 4000);
+    check("a live page is not reported", !host.active_unresponsive(), String::new());
+
+    let victims = web_processes();
+    // SIGKILL rather than SIGSEGV: JavaScriptCore installs its own SEGV
+    // handler, and a sent one is not a fault it will die of.
+    for &pid in &victims {
+        unsafe { libc_kill(pid, 9) };
+    }
+    settle(&mut host, 1500);
+    let title = host.title().unwrap_or_default();
+    check(
+        "a crashed process shows the crash page",
+        title == "This page crashed",
+        format!("{title:?} after SIGKILL to {victims:?}"),
+    );
+    check("still under its own URL", host.url().as_ref() == Some(&page), String::new());
+
+    host.reload();
+    settle(&mut host, 1500);
+    let title = host.title().unwrap_or_default();
+    check("and reloads from there", title == "recovered", format!("{title:?}"));
+
+    println!("\ncrash: {}", if ok { "OK" } else { "BROKEN" });
+    std::process::exit(if ok { 0 } else { 1 });
+}
+
+#[cfg(feature = "wpe")]
+extern "C" {
+    #[link_name = "kill"]
+    fn libc_kill(pid: i32, sig: i32) -> i32;
+}
diff --git a/src/main.rs b/src/main.rs
index 1ea86e2..463fb68 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -200,6 +200,10 @@ enum ModalKind {
     Script,
     /// An HTTP auth challenge.
     Auth,
+    /// The active tab's WebProcess stopped answering. Unlike the other two
+    /// the page is not waiting on this — it is stuck — so nothing in WebKit
+    /// is held open; the answer is to kill the process or leave it be.
+    Unresponsive,
 }
 
 #[cfg(feature = "wpe")]
@@ -241,6 +245,14 @@ impl Modal {
         }
     }
 
+    /// The (ok, cancel) button labels.
+    fn labels(&self) -> (&'static str, &'static str) {
+        match self.kind {
+            ModalKind::Unresponsive => ("Stop page", "Wait"),
+            _ => ("OK", "Cancel"),
+        }
+    }
+
     /// (ok, cancel) — cancel is `None` for a bare alert.
     fn button_rects(&self, r: &Rect) -> (Rect, Option<Rect>) {
         let y = r.y + r.height - plate_pad() - BTN_H;
@@ -1779,7 +1791,12 @@ impl BrowserApp {
     /// needs redrawing.
     #[cfg(feature = "wpe")]
     fn sync_modal(&mut self) -> bool {
-        if self.modal.is_some() {
+        if let Some(m) = &self.modal {
+            // A hang that cleared on its own takes its question with it.
+            if matches!(m.kind, ModalKind::Unresponsive) && !self.host.active_unresponsive() {
+                self.modal = None;
+                return true;
+            }
             return false;
         }
         if let Some(d) = self.host.pending_dialog() {
@@ -1822,6 +1839,22 @@ impl BrowserApp {
             });
             return true;
         }
+        if self.host.active_unresponsive() {
+            let site = self
+                .host
+                .url()
+                .map(|u| u.host_str().map_or_else(|| u.to_string(), str::to_string))
+                .unwrap_or_default();
+            self.modal = Some(Modal {
+                title: "This page isn't responding".to_string(),
+                message: site,
+                fields: Vec::new(),
+                focused: 0,
+                has_cancel: true,
+                kind: ModalKind::Unresponsive,
+            });
+            return true;
+        }
         false
     }
 
@@ -1834,6 +1867,13 @@ impl BrowserApp {
                 let text = m.fields.first().map(|(_, e)| e.text.clone());
                 self.host.respond_dialog(ok, text.as_deref());
             }
+            ModalKind::Unresponsive => {
+                if ok {
+                    self.host.stop_unresponsive();
+                } else {
+                    self.host.wait_unresponsive();
+                }
+            }
             ModalKind::Auth => {
                 if ok {
                     let user = m.fields[0].1.text.clone();
@@ -2218,7 +2258,8 @@ impl BrowserApp {
         }
 
         let (ok, cancel) = m.button_rects(&r);
-        for (rect, label, accent) in [(Some(ok), "OK", true), (cancel, "Cancel", false)]
+        let (ok_label, cancel_label) = m.labels();
+        for (rect, label, accent) in [(Some(ok), ok_label, true), (cancel, cancel_label, false)]
             .into_iter()
             .filter_map(|(rc, l, a)| rc.map(|rc| (rc, l, a)))
         {
@@ -3548,8 +3589,13 @@ impl Application for BrowserApp {
                     m.fields[i].1.handle_key(event)
                 }
                 // No field: Enter accepts, Escape cancels, nothing else acts.
-                Some(_) => match (&event.logical_key, event.state) {
-                    (Key::Named(NamedKey::Enter), ElementState::Pressed) => {
+                // Except over a hang, where accepting kills the page: that
+                // question can pop up mid-typing, and an Enter meant for the
+                // page must not throw away what was typed into it.
+                Some(m) => match (&event.logical_key, event.state) {
+                    (Key::Named(NamedKey::Enter), ElementState::Pressed)
+                        if !matches!(m.kind, ModalKind::Unresponsive) =>
+                    {
                         cce_ui::widget::EditOutcome::Submit
                     }
                     (Key::Named(NamedKey::Escape), ElementState::Pressed) => {
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index a2f59e3..576d336 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -41,8 +41,29 @@ struct TabState {
     /// tab report a title change — the old polling only ever looked at the
     /// active webview.
     dirty: Cell<bool>,
+    /// The tab's WebProcess died, and why (`WebKitWebProcessTerminationReason`).
+    /// Set by the signal, taken by `pump`, which puts the error page up —
+    /// outside the signal, so the load is not started from inside WebKit's
+    /// own teardown of the process.
+    terminated: Cell<Option<WebKitWebProcessTerminationReason::Type>>,
+    /// WebKit's responsiveness timer gave up on the WebProcess: a message
+    /// has gone ~3s without an answer. Cleared when it answers again.
+    unresponsive: Cell<bool>,
+    /// The person chose to wait on this hang, so it is not asked about again
+    /// until the page recovers and hangs anew.
+    hang_waived: Cell<bool>,
+    /// When the outstanding [`WebKitHost::ping`] went out, if one has not
+    /// been answered yet.
+    ping_since: Cell<Option<std::time::Instant>>,
 }
 
+/// How long a page may leave a ping unanswered before it counts as hung —
+/// WebKit's own responsiveness timeout.
+const HANG_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
+
+/// The world the ping runs in, so the page never sees it.
+const PING_WORLD: &str = "cce-ping";
+
 /// One tab: its webview plus the app-visible page state and the last frame
 /// uploaded to the image registry (id, w px, h px). Same shape as
 /// `webview::Tab` so the chrome reads it identically.
@@ -92,21 +113,100 @@ unsafe extern "C" fn drop_state_ref(data: gpointer, _closure: *mut GClosure) {
 }
 
 unsafe fn connect_notify(wv: *mut WebKitWebView, signal: &str, state: &Rc<TabState>) {
+    connect_state(wv, signal, on_notify as *const () as usize, state);
+}
+
+/// Connect `cb` with a `TabState` as its data.
+unsafe fn connect_state(wv: *mut WebKitWebView, signal: &str, cb: usize, state: &Rc<TabState>) {
     let name = cstr(signal);
     // Each connection owns its own ref, handed back by `drop_state_ref`.
     let raw = Rc::into_raw(state.clone()) as gpointer;
     g_signal_connect_data(
         wv as *mut _,
         name.as_ptr(),
-        Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
-            on_notify as unsafe extern "C" fn(*mut GObject, *mut GParamSpec, gpointer),
-        )),
+        Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
         raw,
         Some(drop_state_ref),
         0,
     );
 }
 
+/// The tab's WebProcess is gone — crashed, killed for memory, or stopped by
+/// [`WebKitHost::stop_unresponsive`]. Without this the tab just froze on its
+/// last frame, with nothing saying the page behind it no longer existed.
+unsafe extern "C" fn on_terminated(
+    _wv: *mut WebKitWebView,
+    reason: WebKitWebProcessTerminationReason::Type,
+    data: gpointer,
+) {
+    let st = &*(data as *const TabState);
+    st.terminated.set(Some(reason));
+    st.unresponsive.set(false);
+    st.hang_waived.set(false);
+    st.ping_since.set(None);
+    st.dirty.set(true);
+}
+
+/// The ping came back — or failed because the process is gone, which the
+/// termination signal reports on its own.
+unsafe extern "C" fn on_ping(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
+    let st = Rc::from_raw(data as *const TabState);
+    let mut err: *mut GError = std::ptr::null_mut();
+    let v = webkit_web_view_evaluate_javascript_finish(source as *mut WebKitWebView, res, &mut err);
+    if !v.is_null() {
+        g_object_unref(v as *mut _);
+    }
+    if !err.is_null() {
+        g_error_free(err);
+    }
+    st.ping_since.set(None);
+    if !st.unresponsive.get() {
+        st.hang_waived.set(false);
+    }
+}
+
+/// Fires once the grace has run out. It does nothing itself: being a GLib
+/// source is what wakes the loop, and the `pump` that follows is where an
+/// unanswered ping is noticed. Without it, a hung page — which sends nothing
+/// — would leave the loop asleep and the question unasked.
+unsafe extern "C" fn on_ping_due(_data: gpointer) {}
+
+unsafe extern "C" fn on_responsive(obj: *mut GObject, _pspec: *mut GParamSpec, data: gpointer) {
+    let st = &*(data as *const TabState);
+    let responsive = webkit_web_view_get_is_web_process_responsive(obj as *mut WebKitWebView) != 0;
+    st.unresponsive.set(!responsive);
+    if responsive {
+        st.hang_waived.set(false);
+    }
+}
+
+/// The page shown in place of one whose WebProcess died. Loaded as
+/// *alternate* HTML for the dead page's own URL, so the URL bar, the saved
+/// session and Reload all still mean the real page.
+fn terminated_page(url: Option<&Url>, reason: WebKitWebProcessTerminationReason::Type) -> String {
+    use crate::pages::{html_escape, page};
+    use WebKitWebProcessTerminationReason::*;
+    let (title, why) = match reason {
+        WEBKIT_WEB_PROCESS_EXCEEDED_MEMORY_LIMIT => (
+            "This page ran out of memory",
+            "Its renderer used more memory than it is allowed and was stopped.",
+        ),
+        WEBKIT_WEB_PROCESS_TERMINATED_BY_API => (
+            "This page was stopped",
+            "Its renderer had stopped responding, and was shut down.",
+        ),
+        _ => ("This page crashed", "Its renderer exited unexpectedly."),
+    };
+    let meta = match url {
+        Some(u) => {
+            let u = html_escape(u.as_str());
+            format!("{u}<a href=\"{u}\">Reload</a>")
+        }
+        None => String::new(),
+    };
+    page(title, &meta, &format!("<p class=empty>{why}</p>"), "")
+}
+
 /// The frame handed over by `render_buffer`, drained by `pump`. A slot, not a
 /// queue: only the newest frame is ever shown, and the engine will not run far
 /// ahead of a browser that has not released the one it is holding.
@@ -529,6 +629,15 @@ impl WebKitHost {
             for sig in ["notify::title", "notify::uri", "notify::is-loading"] {
                 connect_notify(wv, sig, state);
             }
+            // A dead or hung WebProcess. The first gets an error page in
+            // `pump`; the second is offered to the chrome to ask about.
+            connect_state(wv, "web-process-terminated", on_terminated as *const () as usize, state);
+            connect_state(
+                wv,
+                "notify::is-web-process-responsive",
+                on_responsive as *const () as usize,
+                state,
+            );
             // A page's alert/confirm/prompt, and HTTP auth challenges. Both
             // are held open and answered later, so the chrome can draw a real
             // dialog rather than the handler having to decide inline.
@@ -872,6 +981,29 @@ impl WebKitHost {
             if !tab.state.dirty.replace(false) {
                 continue;
             }
+            if let Some(reason) = tab.state.terminated.take() {
+                // Loading anything respawns a WebProcess; this loads the
+                // error page under the dead page's URL. Reload — the chrome's
+                // or the page's link — then fetches the real one.
+                log::warn!(
+                    "web process for {} terminated (reason {reason})",
+                    tab.url.as_ref().map_or("<no url>", |u| u.as_str())
+                );
+                unsafe {
+                    let html = cstr(&terminated_page(tab.url.as_ref(), reason));
+                    let uri = tab.url.as_ref().map(|u| cstr(u.as_str()));
+                    webkit_web_view_load_alternate_html(
+                        tab.webview,
+                        html.as_ptr(),
+                        uri.as_ref().map_or(std::ptr::null(), |u| u.as_ptr()),
+                        // The page's own URL as the base too: without one the
+                        // error page is `about:blank` to itself, so its
+                        // Reload link — refused outright when the dead page
+                        // was a `file:` — had nowhere real to go.
+                        uri.as_ref().map_or(std::ptr::null(), |u| u.as_ptr()),
+                    );
+                }
+            }
             tab.title = tab.state.title.borrow().clone();
             if let Some(u) = tab.state.url.borrow().clone() {
                 tab.url = Some(u);
@@ -901,6 +1033,61 @@ impl WebKitHost {
         self.active_tab().loading
     }
 
+    /// The active tab's WebProcess has stopped answering, and the person has
+    /// not already chosen to wait on it.
+    pub fn active_unresponsive(&self) -> bool {
+        self.tabs.get(self.active).is_some_and(|t| {
+            let ping_overdue = t.state.ping_since.get().is_some_and(|at| at.elapsed() >= HANG_GRACE);
+            (t.state.unresponsive.get() || ping_overdue) && !t.state.hang_waived.get()
+        })
+    }
+
+    /// Ask the active page's main thread for an answer, to learn whether it
+    /// is still there.
+    ///
+    /// WebKit's own responsiveness timer misses the commonest hang: pointer
+    /// events are queued behind an unacknowledged one, and a *move* — which
+    /// always comes before a click — does not start the timer. So the
+    /// clicks behind it are never even sent, and the page that ignores them
+    /// is never reported. A no-op script, in a world the page cannot see,
+    /// is answered by the same main thread, so its silence is the hang.
+    fn ping(&self) {
+        let Some(t) = self.tabs.get(self.active) else { return };
+        if t.state.ping_since.get().is_some() {
+            return;
+        }
+        t.state.ping_since.set(Some(std::time::Instant::now()));
+        unsafe {
+            let (script, world) = (cstr("0"), cstr(PING_WORLD));
+            webkit_web_view_evaluate_javascript(
+                t.webview,
+                script.as_ptr(),
+                -1,
+                world.as_ptr(),
+                std::ptr::null(),
+                std::ptr::null_mut(),
+                Some(on_ping),
+                Rc::into_raw(t.state.clone()) as gpointer,
+            );
+            g_timeout_add_once(HANG_GRACE.as_millis() as u32 + 50, Some(on_ping_due), std::ptr::null_mut());
+        }
+    }
+
+    /// Leave the active tab's hang alone until it recovers.
+    pub fn wait_unresponsive(&self) {
+        if let Some(t) = self.tabs.get(self.active) {
+            t.state.hang_waived.set(true);
+        }
+    }
+
+    /// Kill the active tab's hung WebProcess. The termination signal follows,
+    /// and with it the error page offering a reload.
+    pub fn stop_unresponsive(&self) {
+        if let Some(t) = self.tabs.get(self.active) {
+            unsafe { webkit_web_view_terminate_web_process(t.webview) }
+        }
+    }
+
     pub fn load(&self, url: Url) {
         unsafe {
             let c = cstr(url.as_str());
@@ -1162,6 +1349,9 @@ impl WebKitHost {
         let Some(n) = input::button_number(button) else {
             return;
         };
+        if pressed {
+            self.ping();
+        }
         unsafe {
             let view = self.active_tab().view;
             let time = input::now_ms();