web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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();