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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions crates/app/src/preview_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ pub async fn run(
shell.update(cx, |shell, cx| {
shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| {
lifecycle.set_visible(Some(KEYS[1]), cx);
lifecycle.drop_view(KEYS[0]);
lifecycle.drop_view(KEYS[0], cx);
});
});
yield_for(cx, STEP_DELAY).await;
Expand Down Expand Up @@ -271,7 +271,7 @@ pub async fn run(
shell.update(cx, |shell, cx| {
shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| {
lifecycle.set_visible(Some(KEYS[1]), cx);
lifecycle.drop_view(DROP_DURING_CREATE_KEY);
lifecycle.drop_view(DROP_DURING_CREATE_KEY, cx);
});
});
yield_for(cx, STEP_DELAY).await;
Expand All @@ -290,8 +290,8 @@ pub async fn run(
)
.await;
shell.update(cx, |shell, cx| {
shell.preview_lifecycle(cx).update(cx, |lifecycle, _| {
lifecycle.drop_view(DROP_DURING_CREATE_KEY)
shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| {
lifecycle.drop_view(DROP_DURING_CREATE_KEY, cx)
});
});
watchdog.finish_phase("recreate-after-inflight-drop");
Expand Down
3 changes: 2 additions & 1 deletion crates/preview-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ impl PreviewTools {
#[tool(
description = "Report the preview browser's current URL, title, and loading state; call this first for browser work. \
If no automation-capable preview is attached, call preview_open before concluding the browser is unavailable. \
Do not fall back to Chrome, Playwright, or another browser merely because the preview is initially closed or a first call fails; fall back only when preview_open explicitly reports unsupported or unavailable."
Do not fall back to Chrome, Playwright, or another browser merely because the preview is initially closed or a first call fails; fall back only when preview_open explicitly reports unsupported or unavailable. \
Includes load_error with the platform error when the last navigation failed, e.g. an untrusted certificate."
)]
async fn preview_status(&self) -> CallToolResult {
self.run(PreviewOp::Status).await
Expand Down
11 changes: 9 additions & 2 deletions crates/ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ desktop = [
"dep:objc2-foundation",
"dep:objc2-image-io",
"dep:objc2-web-kit",
"dep:webview2-com",
"dep:windows-core",
]

[dependencies]
Expand Down Expand Up @@ -77,9 +79,14 @@ block2 = { version = "0.6.2", optional = true }
objc2 = { version = "0.6.4", optional = true }
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "objc2-core-graphics"], optional = true }
objc2-core-foundation = { version = "0.3.2", features = ["CFData", "CFString"], optional = true }
objc2-foundation = { version = "0.3.2", features = ["NSError", "NSNotification", "NSOperation", "NSString", "block2"], optional = true }
objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSError", "NSNotification", "NSOperation", "NSString", "NSURLError", "block2"], optional = true }
objc2-image-io = { version = "0.3.2", features = ["CGImageDestination"], optional = true }
objc2-web-kit = { version = "0.3.2", features = ["WKSnapshotConfiguration", "WKWebView"], optional = true }
objc2-web-kit = { version = "0.3.2", features = ["WKNavigationDelegate", "WKSnapshotConfiguration", "WKWebView"], optional = true }

[target.'cfg(target_os = "windows")'.dependencies]
# Pinned to the version lb-wry resolves, so both drive the same WebView2 objects.
webview2-com = { version = "0.38.2", optional = true }
windows-core = { version = "0.61", optional = true }

[dev-dependencies]
criterion = "0.8"
Expand Down
44 changes: 35 additions & 9 deletions crates/ui/src/preview_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
//! or allowing a stale completion to replace a newer preview. macOS keeps the
//! proven synchronous child-view path.
//!
//! ## Load errors
//!
//! A navigation that fails — an untrusted certificate, a dead port — leaves the
//! previous document on screen, so the JavaScript status probe cannot see it.
//! [`load_error`] observes the platform's own navigation callbacks (WKWebView's
//! delegate, WebView2's `NavigationCompleted`) and keeps the last failure per
//! webview; `preview_status` reports it as `load_error` and `preview_wait_for`
//! fails with it instead of waiting out its timeout.
//!
//! ## Known caveat — native overlay
//!
//! A `gpui-wry` WebView is a **native child view drawn over** the gpui window,
Expand Down Expand Up @@ -73,6 +82,8 @@ type ReplyTx = async_channel::Sender<Result<PreviewReply, String>>;

#[cfg(not(target_os = "linux"))]
pub(crate) mod lifecycle;
#[cfg(not(target_os = "linux"))]
mod load_error;

#[cfg(not(target_os = "linux"))]
pub use native::PreviewPanel;
Expand Down Expand Up @@ -249,7 +260,7 @@ mod native {

fn drop_webview(&mut self, key: &str, cx: &mut Context<Self>) {
self.lifecycle
.update(cx, |lifecycle, _| lifecycle.drop_view(key));
.update(cx, |lifecycle, cx| lifecycle.drop_view(key, cx));
if self.mirrored.as_deref() == Some(key) {
self.mirrored = None;
}
Expand All @@ -265,7 +276,7 @@ mod native {
self.mirrored = None;
}
self.lifecycle
.update(cx, |lifecycle, _| lifecycle.prune(&live));
.update(cx, |lifecycle, cx| lifecycle.prune(&live, cx));
}

/// Mirror a URL into the store, then navigate through the lifecycle.
Expand Down Expand Up @@ -439,7 +450,8 @@ mod native {
let payload = serde_json::json!({
"ok": true,
"url": self.store.read(cx).preview_url(&key),
"note": "call preview_status for live page state once loaded",
"note": "call preview_status for live page state once loaded; \
it reports load_error when the page failed to load",
});
let _ = reply.try_send(Ok(PreviewReply::Json(payload)));
}
Expand All @@ -460,7 +472,8 @@ mod native {
let payload = serde_json::json!({
"ok": true,
"url": self.store.read(cx).preview_url(&key),
"note": "page is loading; call preview_status for live state",
"note": "page is loading; call preview_status for live state, \
which reports load_error when the page failed to load",
});
let _ = reply.try_send(Ok(PreviewReply::Json(payload)));
}
Expand Down Expand Up @@ -564,12 +577,18 @@ mod native {
})
})
.unwrap_or_else(|| serde_json::json!({ "mode": "fill" }));
// The page cannot see a failed navigation, so the JS probe would
// keep describing whatever was on screen before it.
let load_error = self.lifecycle.read(cx).load_error(key, cx);
let (status_reply, status_result) = async_channel::bounded(1);
cx.spawn(async move |_, _| {
let result = match status_result.recv().await {
Ok(Ok(PreviewReply::Json(mut value))) => {
if let Some(object) = value.as_object_mut() {
object.insert("canvas".into(), canvas);
if let Some(load_error) = load_error {
object.insert("load_error".into(), load_error.to_json());
}
Ok(PreviewReply::Json(value))
} else {
Err("preview status returned a non-object value".into())
Expand Down Expand Up @@ -660,18 +679,25 @@ mod native {
return;
}
let (probe_reply, probe_result) = async_channel::bounded(1);
if this
.update(cx, |panel, cx| {
// A failed navigation never changes the page, so waiting on
// it can only time out; report the platform error instead.
let Ok(failure) = this.update(cx, |panel, cx| {
let failure = panel.lifecycle.read(cx).load_error(&key, cx);
if failure.is_none() {
panel.lifecycle.update(cx, |lifecycle, cx| {
lifecycle.evaluate_ready(&key, &probe, probe_reply.clone(), cx);
});
})
.is_err()
{
}
failure
}) else {
let _ = reply
.send(Err("preview panel was dropped while waiting".into()))
.await;
return;
};
if let Some(failure) = failure {
let _ = reply.send(Err(failure.describe())).await;
return;
}

let watchdog_delay = remaining.min(Duration::from_secs(5));
Expand Down
45 changes: 35 additions & 10 deletions crates/ui/src/preview_panel/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ use std::collections::{HashMap, HashSet};
use std::rc::Weak;
use std::time::Duration;

use gpui::{AppContext as _, Context, Entity, Window};
use gpui::{App, AppContext as _, Context, Entity, Window};
use gpui_wry::WebView;
use preview_mcp::{PreviewReply, js};

use super::load_error::{self, LoadError};
use super::{ReplyTx, unavailable_message};

const STARTING_MESSAGE: &str = "preview is starting; retry the operation shortly";
Expand Down Expand Up @@ -177,14 +178,21 @@ impl BrowserLifecycle {
) -> Availability {
let availability = self.ensure(key, Some(url), window, cx);
match &availability {
Availability::Ready(webview) => match webview.read(cx).raw().load_url(url) {
Ok(()) => {
self.warm.insert(key.to_string());
}
Err(error) => {
log::warn!("preview: failed to navigate {key}: {error}");
Availability::Ready(webview) => {
let raw = webview.read(cx).raw();
// Clear the previous failure now rather than on WebKit's
// asynchronous didStart, so a wait_for issued right after
// this navigation cannot fail on the old record.
load_error::forget(raw);
match raw.load_url(url) {
Ok(()) => {
self.warm.insert(key.to_string());
}
Err(error) => {
log::warn!("preview: failed to navigate {key}: {error}");
}
}
},
}
#[cfg(target_os = "windows")]
Availability::Starting(_) => {
if let Some(WebViewSlot::Creating { pending_url, .. }) = self.slots.get_mut(key) {
Expand Down Expand Up @@ -264,25 +272,39 @@ impl BrowserLifecycle {
}

/// Tear down one ready or in-progress browser generation.
pub fn drop_view(&mut self, key: &str) {
pub fn drop_view(&mut self, key: &str, cx: &App) {
self.forget_load_error(key, cx);
self.slots.remove(key);
self.warm.remove(key);
}

/// Tear down every browser whose session key is no longer live.
pub fn prune(&mut self, live_keys: &HashSet<String>) {
pub fn prune(&mut self, live_keys: &HashSet<String>, cx: &App) {
let deleted = self
.slots
.keys()
.filter(|key| !live_keys.contains(*key))
.cloned()
.collect::<Vec<_>>();
for key in deleted {
self.forget_load_error(&key, cx);
self.slots.remove(&key);
self.warm.remove(&key);
}
}

/// The last navigation failure the platform reported for this browser, if
/// the current page is still the one it left behind.
pub(super) fn load_error(&self, key: &str, cx: &App) -> Option<LoadError> {
load_error::get(self.ready_view(key)?.read(cx).raw())
}

fn forget_load_error(&self, key: &str, cx: &App) {
if let Some(view) = self.ready_view(key) {
load_error::forget(view.read(cx).raw());
}
}

pub fn unavailable_error(&self) -> Option<&str> {
match &self.creator {
Creator::Available(_) => None,
Expand Down Expand Up @@ -441,6 +463,7 @@ impl BrowserLifecycle {
window: &mut Window,
cx: &mut Context<Self>,
) {
load_error::install(&raw);
let warm = if let Some(url) = &pending_url {
match raw.load_url(url) {
Ok(()) => true,
Expand Down Expand Up @@ -532,6 +555,7 @@ mod platform {
return Availability::Unavailable;
}
};
load_error::install(&raw);
let webview = cx.new(|cx| {
let mut view = WebView::new(raw, window, cx);
set_webview_visible(&mut view, false);
Expand Down Expand Up @@ -747,6 +771,7 @@ mod platform {
}

fn drop_raw_webview(raw: wry::WebView, key: &str, reason: &str) {
load_error::forget(&raw);
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(raw))).is_err() {
log::error!("preview: raw webview drop panicked for {key} after {reason}");
}
Expand Down
Loading
Loading