Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 172 additions & 22 deletions crates/hwatud/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::launcher;
use crate::prompts::{self, Prompt, Prompts};
use crate::Daemon;
use gtk::prelude::*;
use hwatu_ipc::{OpenMode, WindowInfo};
use hwatu_ipc::{OpenMode, WebProcessTerminationInfo, WindowInfo};
use std::cell::RefCell;
use std::rc::Rc;
use webkit6::prelude::*;
Expand Down Expand Up @@ -228,6 +228,31 @@ struct SavedState {
title: String,
}

fn termination_info(
reason: webkit6::WebProcessTerminationReason,
url: String,
) -> WebProcessTerminationInfo {
let (reason, message) = match reason {
webkit6::WebProcessTerminationReason::Crashed => ("crashed", "crashed"),
webkit6::WebProcessTerminationReason::ExceededMemoryLimit => {
("oom", "was killed (out of memory)")
}
_ => ("terminated", "terminated unexpectedly"),
};
WebProcessTerminationInfo {
reason: reason.to_string(),
message: message.to_string(),
url,
}
}

fn recovery_url(live_url: Option<&str>, last_url: &str) -> Option<String> {
live_url
.filter(|url| !url.is_empty())
.or((!last_url.is_empty()).then_some(last_url))
.map(str::to_string)
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum RecoveryOverlay {
Loading,
Expand Down Expand Up @@ -320,6 +345,16 @@ pub struct BrowserWindow {
/// stale prewarm `about:blank` commit from a genuine navigation
/// to about:blank.
nav_target: RefCell<Option<String>>,
/// Last non-empty URL observed or requested for this window. WebKit can
/// return an empty URI after web-process termination; keep enough state
/// for diagnostics and recovery instead of reporting a blank page.
last_url: RefCell<String>,
/// Last non-empty title, for the same post-termination fallback as URL.
last_title: RefCell<String>,
/// Last web-process termination diagnostic. Kept in the window model so
/// `hwatu list --json` exposes the reason even when hwatud was auto-
/// spawned with stdout/stderr discarded.
web_process_terminated: RefCell<Option<WebProcessTerminationInfo>>,
/// True once the current load has Committed: the new document has
/// replaced the old one. Together with `nav_pending` this lets
/// stage-aware waits (`wait-load --until committed|dom`) know
Expand Down Expand Up @@ -549,12 +584,14 @@ impl BrowserWindow {
let target = match url.or_else(home_page) {
Some(url) => {
this.mark_nav_pending(&url);
this.remember_url(&url);
webview.load_uri(&url);
url
}
None => {
let uri = launcher::deal_uri(daemon.take_deal());
this.mark_nav_pending(&uri);
this.remember_url(&uri);
webview.load_uri(&uri);
if mode == OpenMode::Normal {
this.bar.open_url("");
Expand All @@ -571,6 +608,7 @@ impl BrowserWindow {
suspended: false,
app_id,
mode,
web_process_terminated: None,
}
}

Expand Down Expand Up @@ -745,6 +783,9 @@ impl BrowserWindow {
viewport: std::cell::Cell::new(None),
nav_pending: RefCell::new(None),
nav_target: RefCell::new(None),
last_url: RefCell::new(String::new()),
last_title: RefCell::new(String::new()),
web_process_terminated: RefCell::new(None),
load_committed: std::cell::Cell::new(true),
snapshot_baseline: RefCell::new(None),
console: crate::console::Buffer::default(),
Expand Down Expand Up @@ -897,8 +938,12 @@ impl BrowserWindow {
crate::console::attach(&self.console, &webview);
crate::net::attach(&self.net, &webview);
let win = self.window.clone();
let this = self.clone();
webview.connect_title_notify(move |wv| {
let title = wv.title().unwrap_or_default();
if !title.is_empty() {
this.last_title.replace(title.to_string());
}
win.set_title(Some(if title.is_empty() {
"hwatu"
} else {
Expand All @@ -909,7 +954,11 @@ impl BrowserWindow {
// (debounced in the daemon).
{
let daemon = self.daemon.clone();
webview.connect_uri_notify(move |_| daemon.schedule_session_save());
let this = self.clone();
webview.connect_uri_notify(move |wv| {
this.remember_webview_url(wv);
daemon.schedule_session_save();
});
}
// A gray WebKit surface during a slow or wedged load used to look
// like hwatu itself had broken. Surface a low-priority overlay if
Expand All @@ -921,6 +970,7 @@ impl BrowserWindow {
webview.connect_load_changed(move |wv, event| match event {
webkit6::LoadEvent::Started => {
this.note_load_engaged(wv);
this.web_process_terminated.replace(None);
this.turnstile_handoff_offered.set(false);
this.load_committed.set(false);
this.clear_recovery_overlay();
Expand Down Expand Up @@ -1051,17 +1101,32 @@ impl BrowserWindow {
if this.webview.borrow().as_ref() != Some(wv) {
return;
}
let reason = match reason {
webkit6::WebProcessTerminationReason::Crashed => "crashed",
webkit6::WebProcessTerminationReason::ExceededMemoryLimit => {
"was killed (out of memory)"
}
_ => "terminated unexpectedly",
};
eprintln!("hwatud: web process for window {} {reason}", this.id);
let url = wv
.uri()
.map(|u| u.to_string())
.filter(|u| !u.is_empty())
.unwrap_or_else(|| this.last_url.borrow().clone());
let info = termination_info(reason, url.clone());
this.web_process_terminated.replace(Some(info.clone()));
this.daemon.events.emit(
"web_process",
Some(this.id),
serde_json::json!({
"state": "terminated",
"reason": info.reason,
"message": info.message,
"url": info.url,
}),
);
eprintln!(
"hwatud: web process for window {} {} at {}",
this.id,
info.message,
if url.is_empty() { "(unknown URL)" } else { &url }
);
this.show_recovery_overlay(
"Page crashed",
&format!("The web process {reason}. Press Ctrl+r or F5 to reload, or Ctrl+l to open a URL."),
&format!("The web process {}. Press Ctrl+r or F5 to reload, or Ctrl+l to open a URL.", info.message),
RecoveryOverlay::Failure,
);
});
Expand Down Expand Up @@ -1426,6 +1491,11 @@ impl BrowserWindow {
/// need for a human (not focused, no bar prompt, no CAPTCHA)
/// demotes itself instead of squatting in the WM forever.
pub fn present(self: &Rc<Self>) {
// Do not depend on the compositor granting activation (and emitting
// is-active-notify) to preserve the promoted page. Cancel a pending
// discard and restore before mapping the window.
self.cancel_discard_timer();
self.restore();
let prev = self.mode.get();
if prev != OpenMode::Normal && self.promoted_from.get().is_none() {
self.promoted_from.set(Some(prev));
Expand Down Expand Up @@ -1534,15 +1604,31 @@ impl BrowserWindow {

pub fn info(&self) -> WindowInfo {
match &*self.webview.borrow() {
Some(wv) => WindowInfo {
id: self.id,
url: wv.uri().map(|u| u.to_string()).unwrap_or_default(),
title: wv.title().map(|t| t.to_string()).unwrap_or_default(),
focused: self.window.is_active(),
suspended: false,
app_id: self.app_id.clone(),
mode: self.mode.get(),
},
Some(wv) => {
let url = wv.uri().map(|u| u.to_string()).unwrap_or_default();
WindowInfo {
id: self.id,
url: if url.is_empty() {
self.last_url.borrow().clone()
} else {
url
},
title: wv
.title()
.map(|t| t.to_string())
.filter(|title| !title.is_empty())
.unwrap_or_else(|| self.last_title.borrow().clone()),
focused: self.window.is_active(),
suspended: false,
app_id: self.app_id.clone(),
mode: self.mode.get(),
web_process_terminated: self
.web_process_terminated
.borrow()
.clone()
.map(Box::new),
}
}
None => {
let saved = self.saved.borrow();
let (url, title) = saved
Expand All @@ -1551,12 +1637,25 @@ impl BrowserWindow {
.unwrap_or_default();
WindowInfo {
id: self.id,
url,
title,
url: if url.is_empty() {
self.last_url.borrow().clone()
} else {
url
},
title: if title.is_empty() {
self.last_title.borrow().clone()
} else {
title
},
focused: false,
suspended: true,
app_id: self.app_id.clone(),
mode: self.mode.get(),
web_process_terminated: self
.web_process_terminated
.borrow()
.clone()
.map(Box::new),
}
}
}
Expand Down Expand Up @@ -1599,6 +1698,18 @@ impl BrowserWindow {
self.webview.borrow().clone()
}

fn remember_webview_url(&self, webview: &webkit6::WebView) {
if let Some(uri) = webview.uri().filter(|uri| !uri.is_empty()) {
self.remember_url(uri.as_str());
}
}

fn remember_url(&self, url: &str) {
if !url.is_empty() {
self.last_url.replace(url.to_string());
}
}

/// Re-assert the offscreen viewport of a headless window. The
/// manual allocation from `show()` is not sticky: any relayout GTK
/// runs later (a navigation changing the page's size requests is
Expand Down Expand Up @@ -1833,6 +1944,14 @@ impl BrowserWindow {
let Some(webview) = self.live_webview() else {
return;
};
if webview.uri().is_none_or(|uri| uri.is_empty()) {
let last_url = self.last_url.borrow().clone();
if let Some(url) = recovery_url(None, &last_url) {
self.mark_nav_pending(&url);
webview.load_uri(&url);
}
return;
}
if bypass_cache {
webview.reload_bypass_cache();
} else {
Expand Down Expand Up @@ -2089,6 +2208,7 @@ impl BrowserWindow {
if let Some(webview) = self.live_webview() {
let url = crate::ipc_server::normalize_url(input.to_string());
self.mark_nav_pending(&url);
self.remember_url(&url);
webview.load_uri(&url);
}
}
Expand Down Expand Up @@ -2200,6 +2320,7 @@ impl BrowserWindow {
mod tests {
use super::{
external_uri_scheme, is_ctrl_click_link, load_was_cancelled, parse_feature_overrides,
recovery_url, termination_info,
};

#[test]
Expand Down Expand Up @@ -2398,4 +2519,33 @@ mod tests {
assert_eq!(external_uri_scheme("not a uri"), None);
assert_eq!(external_uri_scheme("1invalid:value"), None);
}

#[test]
fn recovery_url_prefers_live_and_falls_back_to_last_non_empty_url() {
assert_eq!(
recovery_url(Some("https://live.test/"), "https://last.test/"),
Some("https://live.test/".into())
);
assert_eq!(
recovery_url(Some(""), "https://last.test/"),
Some("https://last.test/".into())
);
assert_eq!(recovery_url(None, ""), None);
}

#[test]
fn web_process_termination_reasons_are_stable_and_keep_url() {
let url = "https://example.test/sign-up".to_string();
let crashed = termination_info(webkit6::WebProcessTerminationReason::Crashed, url.clone());
let oom = termination_info(
webkit6::WebProcessTerminationReason::ExceededMemoryLimit,
url.clone(),
);

assert_eq!(crashed.reason, "crashed");
assert_eq!(crashed.message, "crashed");
assert_eq!(crashed.url, url);
assert_eq!(oom.reason, "oom");
assert_eq!(oom.message, "was killed (out of memory)");
}
}
48 changes: 48 additions & 0 deletions crates/ipc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,20 @@ pub struct WindowInfo {
/// promotes a window to normal.
#[serde(default, skip_serializing_if = "is_normal")]
pub mode: OpenMode,
/// Last WebKit web-process termination observed for this window, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_process_terminated: Option<Box<WebProcessTerminationInfo>>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebProcessTerminationInfo {
/// Stable, machine-readable reason: crashed, oom, or terminated.
pub reason: String,
/// Human-readable description suitable for diagnostics.
pub message: String,
/// Best-known URL at the time the web process died.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
}

fn is_normal(mode: &OpenMode) -> bool {
Expand Down Expand Up @@ -1066,6 +1080,40 @@ mod tests {
assert_eq!(base.as_deref(), Some("http://localhost:3000/"));
}

#[test]
fn old_window_info_defaults_recovery_fields() {
let old = r#"{"id":7,"url":"","title":"","focused":false,"suspended":false}"#;
let info: WindowInfo = serde_json::from_str(old).expect("old WindowInfo parses");

assert_eq!(info.web_process_terminated, None);
}

#[test]
fn window_info_serializes_recoverable_crash_state() {
let info = WindowInfo {
id: 7,
url: "https://example.test/sign-up".into(),
title: String::new(),
focused: true,
suspended: false,
app_id: None,
mode: OpenMode::Normal,
web_process_terminated: Some(Box::new(WebProcessTerminationInfo {
reason: "oom".into(),
message: "was killed (out of memory)".into(),
url: "https://example.test/sign-up".into(),
})),
};

let json = serde_json::to_value(&info).expect("WindowInfo serializes");

assert_eq!(json["web_process_terminated"]["reason"], "oom");
assert_eq!(
json["web_process_terminated"]["url"],
"https://example.test/sign-up"
);
}

/// A viewport-sweep check roundtrips through the wire format; an
/// empty sweep is omitted from the JSON entirely so old daemons
/// keep parsing new clients' plain checks unchanged.
Expand Down
Loading