From 9861987f4a6a94ccd30ff678c7cdea248f6f284f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:52:45 +0900 Subject: [PATCH 01/24] feat(desktop): one exit path, one startup surface and a real tray probe Closing the window, the platform quit gesture and the tray's Quit all used to mean the same thing. There was no ExitRequested handler, so the quit gesture reached RunEvent::Exit and called CommandChild::kill() on the runtime this app had started - a SIGKILL on Unix, cutting off the in-flight requests, the client-configuration restore and the state-file clearing that the CLI's stop performs, on a keystroke the user reads as "hide". exit.rs now holds the exit, drains what the app owns and only then lets the process end. An installed update asks for a coordinated restart down the same drain rather than restarting straight into the kill, and a runtime counts as stopped only when the child reports its own exit or the endpoint refuses a connection. macOS needed one thing more than the handler: Tauri's default menu carries a predefined Quit wired to Cocoa's terminate:, and the pinned tao implements no cancellable applicationShouldTerminate, so that Cmd+Q never raised the event at all. menu.rs rebuilds the default menu with an ordinary item on the same accelerator, keeping the clipboard items the failure diagnostic needs. Startup ran inside setup() before any window existed, and the spawn event stream was destructured into _events and dropped, so a sidecar that exited immediately looked exactly like a slow one. The window is created and shown first now, and startup.rs runs the whole sequence inside it - registering, resolving, probing, attaching or starting, waiting - under one 30-second deadline, with every probe bounded by the time left rather than by the HTTP client's own timeout. Registering comes first so a failed start still leaves a tray to reopen from. The failure state carries a retry, the child's exit code and a copyable diagnostic; a retry waits on a child that has not exited rather than racing it, and a spawn cannot interleave with a quit because both take the same lock. Tray availability is asked of the session bus: not whether the watcher exists, which proves nothing, but whether it reports a host registered. The pinned Linux backend creates an AppIndicator and reports success either way. Where there is no host, no icon is claimed, the window is shown whatever the launch origin, and closing it quits through the same drain. --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 6 + desktop/src-tauri/src/exit.rs | 392 ++++++++++++ desktop/src-tauri/src/first_run.rs | 86 ++- desktop/src-tauri/src/lib.rs | 181 ++++-- desktop/src-tauri/src/menu.rs | 132 ++++ desktop/src-tauri/src/proxy.rs | 28 + desktop/src-tauri/src/sidecar.rs | 322 +++++++++- desktop/src-tauri/src/startup.rs | 685 +++++++++++++++++++++ desktop/src-tauri/src/tray.rs | 120 ++-- desktop/src-tauri/src/tray_availability.rs | 131 ++++ desktop/src-tauri/src/updater.rs | 7 +- desktop/src-tauri/src/window.rs | 44 +- desktop/src-tauri/tauri.conf.json | 1 + desktop/ui/index.html | 39 +- desktop/ui/main.js | 154 ++++- 16 files changed, 2162 insertions(+), 167 deletions(-) create mode 100644 desktop/src-tauri/src/exit.rs create mode 100644 desktop/src-tauri/src/menu.rs create mode 100644 desktop/src-tauri/src/startup.rs create mode 100644 desktop/src-tauri/src/tray_availability.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index f5bb3fca134..db1cf16767c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2543,6 +2543,7 @@ dependencies = [ name = "opencodex-desktop" version = "2.61.0" dependencies = [ + "dbus", "reqwest 0.12.24", "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1ec7348b749..2b323a3cb3e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -29,6 +29,12 @@ tauri-plugin-single-instance = "=2.4.0" tauri-plugin-updater = "=2.9.0" tokio = { version = "=1.45.1", features = ["sync", "time"] } +# Linux only, and already in this graph: `tao` enables its own `dbus` feature by default, so +# `libdbus-sys` is compiled for every Linux build of this shell today. Naming it here adds a +# session-bus probe for the StatusNotifier watcher without adding a package or a system library. +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "=0.9.12" + [profile.release] codegen-units = 1 lto = "thin" diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs new file mode 100644 index 00000000000..c7dbe51a50e --- /dev/null +++ b/desktop/src-tauri/src/exit.rs @@ -0,0 +1,392 @@ +//! Who is allowed to end the process, and what has to happen first. +//! +//! Three gestures arrive looking like an exit: closing the window, the platform's own quit gesture +//! (Cmd+Q on macOS, Alt+F4 on Windows, the window manager's close on Linux), and the tray's Quit +//! item. Only the last one means "end the runtime". Until this module existed the shell had no +//! `ExitRequested` handler at all, so the quit gesture went straight to `RunEvent::Exit`, which +//! called `CommandChild::kill()` — a SIGKILL on Unix — on a keystroke the user reads as "hide". +//! +//! Tauri separates a user gesture from a programmatic exit: `RunEvent::ExitRequested` carries +//! `code: None` for the gesture and `Some(_)` for `AppHandle::exit` or `AppHandle::restart`. What +//! it cannot tell apart is the tray's Quit from an update's restart, and those end differently: a +//! quit stops the runtime and stays stopped, an update restart runs the same drain and comes back. +//! [`ExitReason`] records which one asked. macOS needs one more thing on top, because its menu +//! Quit never raises the event at all; see [`crate::menu`]. +//! +//! The drain cannot run inside the event handler — it is asynchronous and can take seconds — so +//! every path funnels through one three-step phase: hold the exit, drain, ask again. The phase, the +//! reason and the permission to start a runtime all live under one lock, because they are one +//! decision: a quit that lands while the startup sequence is spawning must not leave the spawned +//! process behind. + +use crate::{sidecar, tray_availability::TrayAvailability, window, AppState}; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use tauri::{AppHandle, ExitRequestApi, Manager}; + +/// Why the process has been asked to end. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitReason { + /// The tray's Quit item, or a window close on a session with no tray to hide into. + UserQuit, + /// An installed update restarting the app. It drains exactly as a quit does and then comes + /// back, which is why it is a coordinated restart rather than an exception to the quit rule. + CoordinatedRestart, +} + +/// How far the one drain-and-exit sequence has got. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitPhase { + /// Nothing is in flight. + Idle, + /// The drain is running. Further exit requests wait for it rather than starting a second one. + Draining, + /// The drain has reported. The next exit request is the real one. + Drained, +} + +/// What the event loop should do with an exit request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitDecision { + /// Nothing asked the app to end and there is a tray to come back from: hide instead. + Hide, + /// Hold the exit and drain for this reason; the exit is requested again when the drain reports. + Drain(ExitReason), + /// A drain is already running. Hold the exit and let that one finish. + Wait, + /// The drain has reported. Let the process end. + Proceed, +} + +/// Decide what an exit request means. +/// +/// `reason` is what the app itself asked for and is `None` for a bare user gesture. +/// `hides_to_tray` is D6: on a session with no usable tray there is nowhere to hide, so a close is +/// a quit and takes the same graceful drain rather than leaving a running process unreachable. +pub fn decide(phase: ExitPhase, reason: Option, hides_to_tray: bool) -> ExitDecision { + match phase { + ExitPhase::Draining => ExitDecision::Wait, + ExitPhase::Drained => ExitDecision::Proceed, + ExitPhase::Idle => match reason { + Some(reason) => ExitDecision::Drain(reason), + None if hides_to_tray => ExitDecision::Hide, + None => ExitDecision::Drain(ExitReason::UserQuit), + }, + } +} + +struct Inner { + phase: ExitPhase, + reason: Option, + hides_to_tray: bool, +} + +/// The exit sequence's state, managed by the app. +pub struct ExitCoordinator { + inner: Mutex, +} + +impl ExitCoordinator { + pub fn new() -> Self { + Self { + inner: Mutex::new(Inner { + phase: ExitPhase::Idle, + reason: None, + // Until the probe answers, assume only what the platform guarantees. Assuming a + // tray that turns out not to exist is the exact failure D6 is about. + hides_to_tray: TrayAvailability::assumed().hides_to_tray(), + }), + } + } + + fn inner(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Record the session's tray verdict once the probe has answered. + pub fn set_tray(&self, tray: TrayAvailability) { + self.inner().hides_to_tray = tray.hides_to_tray(); + } + + pub fn hides_to_tray(&self) -> bool { + self.inner().hides_to_tray + } + + #[cfg(test)] + fn phase(&self) -> ExitPhase { + self.inner().phase + } + + pub fn decision(&self) -> ExitDecision { + let inner = self.inner(); + decide(inner.phase, inner.reason, inner.hides_to_tray) + } + + /// Record why the app is ending, without starting anything. The first reason wins. + pub fn claim(&self, reason: ExitReason) { + let mut inner = self.inner(); + inner.reason.get_or_insert(reason); + } + + /// Take ownership of the drain, and with it the reason the app is ending. + /// + /// Claiming the reason and moving out of `Idle` is one step on purpose. Split apart, a Quit + /// that claimed first could still be overtaken by an update that started the drain, and the app + /// would restart under a user who asked it to stop. `fallback` is only used when nothing has + /// claimed a reason yet. + pub fn claim_drain(&self, fallback: ExitReason) -> Option { + let mut inner = self.inner(); + if inner.phase != ExitPhase::Idle { + return None; + } + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + + pub fn finish_drain(&self) { + self.inner().phase = ExitPhase::Drained; + } + + /// Start a runtime, but only while no exit is in flight. + /// + /// The lock is held across the whole closure so that spawning a child and recording that we own + /// it cannot be split by a quit. Without that, a Quit arriving mid-startup reads "we own + /// nothing", drains nothing, and the process exits moments after the sequence spawned a proxy + /// that nothing will ever stop. The closure must not call back into this coordinator. + pub fn spawn_unless_ending(&self, spawn: impl FnOnce() -> T) -> Option { + let inner = self.inner(); + if inner.phase != ExitPhase::Idle { + return None; + } + Some(spawn()) + } +} + +impl Default for ExitCoordinator { + fn default() -> Self { + Self::new() + } +} + +/// Whether the current session hides to a tray rather than ending on a close. +pub fn hides_to_tray(app: &AppHandle) -> bool { + app.try_state::() + .map(|coordinator| coordinator.hides_to_tray()) + .unwrap_or_else(|| TrayAvailability::assumed().hides_to_tray()) +} + +/// The platform's quit gesture, and what closing the window means. +/// +/// It is not a request to end. D2 makes it mean the same thing on all three platforms: hide where +/// there is a tray to come back from, and a graceful quit where there is not. +pub fn gesture(app: &AppHandle) { + let Some(coordinator) = app.try_state::() else { + return; + }; + match coordinator.decision() { + ExitDecision::Hide => hide_windows(app), + ExitDecision::Drain(reason) => start_drain(app, reason), + ExitDecision::Wait | ExitDecision::Proceed => {} + } +} + +/// Ask the app to end for a stated reason. This is the only way the shell ends itself. +pub fn request(app: &AppHandle, reason: ExitReason) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(reason); + } + app.exit(0); +} + +/// Ask the app to drain and come back, which is what an installed update needs. +/// +/// This does not go through [`request`]. `AppHandle::restart` ignores `prevent_exit`, so the event +/// loop cannot hold a restart long enough to drain inside it; the drain has to happen first and +/// issue the restart itself, which is what [`start_drain`] does for this reason. +pub fn request_restart(app: &AppHandle) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(ExitReason::CoordinatedRestart); + } + start_drain(app, ExitReason::CoordinatedRestart); +} + +/// Handle `RunEvent::ExitRequested`. +pub fn on_exit_requested(app: &AppHandle, code: Option, api: &ExitRequestApi) { + // `AppHandle::restart` documents that `prevent_exit` is ignored for its own exit code, so a + // restart cannot be held here even to drain. The updater therefore drains before it restarts, + // and this branch only records the reason so nothing downstream reads the restart as a quit. + if code == Some(tauri::RESTART_EXIT_CODE) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(ExitReason::CoordinatedRestart); + } + return; + } + let Some(coordinator) = app.try_state::() else { + return; + }; + match coordinator.decision() { + ExitDecision::Hide => { + api.prevent_exit(); + hide_windows(app); + } + ExitDecision::Wait => api.prevent_exit(), + ExitDecision::Drain(reason) => { + api.prevent_exit(); + start_drain(app, reason); + } + ExitDecision::Proceed => {} + } +} + +/// Drain an app-owned runtime and then ask to end again. +/// +/// Nothing kills the child. The old path did, with `CommandChild::kill()`, and that is a SIGKILL on +/// Unix: it cut off the in-flight requests, the client-configuration restore and the state-file +/// clearing that the CLI's own stop performs. +/// +/// A drain that does not complete is reported and the exit still proceeds. That is a deliberate +/// trade and it has a cost: the runtime this app started can outlive it. The alternative is +/// refusing to quit when the user asked to, on a window that is showing the dashboard and has +/// nowhere to explain itself, and a runtime left standing is recoverable with `ocx stop` while a +/// half-restored client configuration is not. +pub fn start_drain(app: &AppHandle, reason: ExitReason) { + let Some(coordinator) = app.try_state::() else { + return; + }; + let Some(reason) = coordinator.claim_drain(reason) else { + return; + }; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let owned = app + .try_state::() + .map(|state| (state.proxy(), state.owns_runtime(), state.watch.clone())); + if let Some((Some(proxy), owned, watch)) = owned { + let outcome = sidecar::drain(&proxy, owned, &watch).await; + match outcome.failure() { + None => { + if let Some(state) = app.try_state::() { + state.release(); + } + } + Some(error) => crate::logging::log_once("graceful stop did not complete", &error), + } + } + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(); + } + match reason { + ExitReason::UserQuit => app.exit(0), + ExitReason::CoordinatedRestart => app.restart(), + } + }); +} + +fn hide_windows(app: &AppHandle) { + for window in app.webview_windows().values() { + window::hide(window); + } +} + +#[cfg(test)] +mod tests { + use super::{decide, ExitCoordinator, ExitDecision, ExitPhase, ExitReason}; + use crate::tray_availability::TrayAvailability; + + #[test] + fn a_bare_gesture_hides_when_there_is_a_tray_to_come_back_from() { + assert_eq!(decide(ExitPhase::Idle, None, true), ExitDecision::Hide); + } + + #[test] + fn a_bare_gesture_quits_through_the_drain_when_there_is_no_tray() { + assert_eq!( + decide(ExitPhase::Idle, None, false), + ExitDecision::Drain(ExitReason::UserQuit) + ); + } + + #[test] + fn an_explicit_quit_drains_even_though_a_tray_exists() { + assert_eq!( + decide(ExitPhase::Idle, Some(ExitReason::UserQuit), true), + ExitDecision::Drain(ExitReason::UserQuit) + ); + } + + #[test] + fn an_update_restart_keeps_its_own_reason_through_the_drain() { + assert_eq!( + decide(ExitPhase::Idle, Some(ExitReason::CoordinatedRestart), true), + ExitDecision::Drain(ExitReason::CoordinatedRestart) + ); + } + + #[test] + fn a_second_request_waits_instead_of_starting_a_second_drain() { + for hides in [true, false] { + assert_eq!( + decide(ExitPhase::Draining, Some(ExitReason::UserQuit), hides), + ExitDecision::Wait + ); + assert_eq!(decide(ExitPhase::Draining, None, hides), ExitDecision::Wait); + } + } + + #[test] + fn only_a_reported_drain_lets_the_process_end() { + assert_eq!( + decide(ExitPhase::Drained, Some(ExitReason::UserQuit), true), + ExitDecision::Proceed + ); + } + + #[test] + fn the_first_claimed_reason_wins_the_drain() { + let coordinator = ExitCoordinator::new(); + coordinator.claim(ExitReason::UserQuit); + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + Some(ExitReason::UserQuit) + ); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + } + + #[test] + fn only_one_caller_owns_the_drain() { + let coordinator = ExitCoordinator::new(); + assert_eq!( + coordinator.claim_drain(ExitReason::UserQuit), + Some(ExitReason::UserQuit) + ); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + coordinator.finish_drain(); + assert_eq!(coordinator.phase(), ExitPhase::Drained); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + } + + #[test] + fn a_runtime_is_not_started_once_an_exit_is_in_flight() { + let coordinator = ExitCoordinator::new(); + assert_eq!(coordinator.spawn_unless_ending(|| 7), Some(7)); + coordinator.claim_drain(ExitReason::UserQuit); + assert_eq!(coordinator.spawn_unless_ending(|| 7), None); + } + + #[test] + fn the_tray_verdict_replaces_the_platform_assumption() { + let coordinator = ExitCoordinator::new(); + assert_eq!( + coordinator.hides_to_tray(), + TrayAvailability::assumed().hides_to_tray() + ); + coordinator.set_tray(TrayAvailability::Unavailable); + assert!(!coordinator.hides_to_tray()); + assert_eq!( + coordinator.decision(), + ExitDecision::Drain(ExitReason::UserQuit) + ); + coordinator.set_tray(TrayAvailability::Available); + assert_eq!(coordinator.decision(), ExitDecision::Hide); + } +} diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs index e6a0417ed97..3fc7616b93f 100644 --- a/desktop/src-tauri/src/first_run.rs +++ b/desktop/src-tauri/src/first_run.rs @@ -5,6 +5,30 @@ use tauri_plugin_autostart::ManagerExt; /// Marker file recording that the one-time Start at Login default has already been applied. const MARKER: &str = "start-at-login-claimed"; +/// Marker file recording that the login item names the launch-origin argument. +const ORIGIN_MARKER: &str = "start-at-login-origin-flag"; + +/// What the one-time Start at Login decision did on this launch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StartAtLogin { + /// This installation had already decided, so whatever the user set is left alone. + AlreadyDecided, + /// Turned on for the first time on this installation. + Enabled, + /// The default could not be applied. The app still starts and the tray item still toggles it. + Unavailable, +} + +impl StartAtLogin { + pub fn describe(self) -> &'static str { + match self { + Self::AlreadyDecided => "already decided on this installation, left as the user set it", + Self::Enabled => "turned on for this installation", + Self::Unavailable => "could not be registered; the tray item still toggles it", + } + } +} + /// Turn Start at Login on once, the first time this installation runs. /// /// A menu bar app that is not running has no menu bar item. Leaving autostart off by default @@ -18,24 +42,70 @@ const MARKER: &str = "start-at-login-claimed"; /// failed or partial enable retries on every launch, and would eventually flip the setting back on /// under a user who had deliberately turned it off in between. /// -/// Every failure is silent on purpose. Not being able to write a marker or register a login item -/// is not a reason to stop the app from starting, and the user can still toggle the menu item. -pub fn apply_start_at_login_default(app: &AppHandle) { +/// No failure stops the app. Not being able to write a marker or register a login item is not a +/// reason to refuse to start, and the user can still toggle the menu item. What has changed is that +/// the outcome is returned rather than swallowed: D7's startup sequence reports this decision as +/// one of its named states, so a registration that did not happen is visible instead of silent. +pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { let Ok(dir) = app.path().app_config_dir() else { - return; + return StartAtLogin::Unavailable; }; let marker = dir.join(MARKER); if marker.exists() { - return; + return StartAtLogin::AlreadyDecided; } if fs::create_dir_all(&dir).is_err() { - return; + return StartAtLogin::Unavailable; } if fs::write(&marker, b"").is_err() { - return; + return StartAtLogin::Unavailable; } if app.autolaunch().is_enabled().unwrap_or(false) { + return StartAtLogin::AlreadyDecided; + } + match app.autolaunch().enable() { + Ok(()) => StartAtLogin::Enabled, + Err(_) => StartAtLogin::Unavailable, + } +} + +/// Rewrite an existing login item so a launch from it can be recognised as one. +/// +/// The autostart entry is written once, carrying whatever arguments the plugin was configured with +/// at the time. An installation that registered before the launch-origin argument existed has an +/// entry without it, and a bare launch carries nothing else that distinguishes login from manual — +/// so D7's hidden login start would quietly never happen for exactly the users who already had +/// autostart on. Re-registering rewrites the entry with the current arguments. +/// +/// It runs once, behind its own marker, and only where autostart is already on. It never turns the +/// setting on and never turns it off; the worst case is an entry that keeps its old arguments and a +/// login launch that shows its window, which is the visible failure rather than the silent one. +pub fn adopt_launch_origin_argument(app: &AppHandle) { + let Ok(dir) = app.path().app_config_dir() else { return; + }; + let claimed = dir.join(ORIGIN_MARKER); + if claimed.exists() { + return; + } + if fs::create_dir_all(&dir).is_err() { + return; + } + // Unlike the default above, the marker is written *after* the work, and that difference is + // deliberate. Writing first exists there to stop a failed enable from flipping a setting the + // user turned off. Here there is no setting to flip: the rewrite only ever runs on an entry + // that is already enabled, so retrying after a transient registry, LaunchAgent or desktop-file + // error is free — and claiming the marker first would suppress the migration permanently and + // leave a login launch showing its window forever. + match app.autolaunch().is_enabled() { + Ok(true) => { + if app.autolaunch().enable().is_err() { + return; + } + } + // Nothing registered to migrate. A later enable writes the current arguments anyway. + Ok(false) => {} + Err(_) => return, } - let _ = app.autolaunch().enable(); + let _ = fs::write(&claimed, b""); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5ee02473c7e..bbb85ba6c40 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,40 +1,82 @@ mod auth; mod discovery; +mod exit; mod first_run; mod formatting; mod logging; +mod menu; mod proxy; mod sidecar; +mod startup; mod tray; +mod tray_availability; mod updater; mod widget; mod window; use std::sync::{ atomic::{AtomicBool, Ordering}, - Mutex, + Mutex, MutexGuard, PoisonError, }; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_shell::process::CommandChild; pub struct AppState { - pub proxy: proxy::ProxyClient, - pub spawned_by_us: AtomicBool, - pub child: Mutex>, + /// Absent until the startup sequence has resolved a home and a port. Nothing guesses an + /// endpoint any more, so there is no client to hand out before that. + proxy: Mutex>, + spawned_by_us: AtomicBool, + child: Mutex>, + /// The consumed spawn event stream of the child, if this app started one. + pub watch: sidecar::SidecarWatch, } impl AppState { - pub fn shutdown_child(&self) { - if !self.spawned_by_us.swap(false, Ordering::AcqRel) { - return; - } - if let Ok(mut child) = self.child.lock() { - if let Some(child) = child.take() { - let _ = child.kill(); - } + pub fn new() -> Self { + Self { + proxy: Mutex::new(None), + spawned_by_us: AtomicBool::new(false), + child: Mutex::new(None), + watch: sidecar::SidecarWatch::default(), } } + + fn slot(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn proxy(&self) -> Option { + Self::slot(&self.proxy).clone() + } + + pub fn attach(&self, proxy: proxy::ProxyClient) { + *Self::slot(&self.proxy) = Some(proxy); + } + + pub fn owns_runtime(&self) -> bool { + self.spawned_by_us.load(Ordering::Acquire) + } + + pub fn adopt(&self, child: CommandChild) { + *Self::slot(&self.child) = Some(child); + self.spawned_by_us.store(true, Ordering::Release); + } + + /// Let go of a runtime that has already been drained. + /// + /// Dropping the handle does not signal the process — the shell plugin installs no `Drop` — so + /// this releases ownership without reintroducing the `kill()` that D2 removed. + pub fn release(&self) { + self.spawned_by_us.store(false, Ordering::Release); + let _ = Self::slot(&self.child).take(); + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } } #[tauri::command] @@ -51,8 +93,33 @@ fn hide_dashboard(app: tauri::AppHandle) { } } +/// Everything the startup sequence has said so far, including the states it has already finished. +/// +/// The page asks for this when it loads rather than relying only on the event stream: the first +/// states finish in milliseconds and an event emitted before the listener exists is simply gone. +#[tauri::command] +fn startup_snapshot(app: tauri::AppHandle) -> Option { + app.try_state::() + .map(|startup| startup.latest()) +} + +/// The named states the startup sequence moves through, in order. +/// +/// The page asks for them instead of restating them, so a state added in the shell appears in the +/// UI and one removed cannot leave a row behind. +#[tauri::command] +fn startup_phases() -> Vec { + startup::phase_list() +} + +/// Run the startup sequence again. A run already in flight is left alone. +#[tauri::command] +fn retry_startup(app: tauri::AppHandle) { + startup::begin(&app); +} + pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { window::show(&window); @@ -60,52 +127,59 @@ pub fn run() { })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) + // The argument is what makes a login launch recognisable. Nothing else in a bare launch + // distinguishes it from a person opening the app, and D7 needs the difference. .plugin(tauri_plugin_autostart::init( MacosLauncher::LaunchAgent, - None, + Some(vec![startup::AUTOSTART_FLAG]), )) .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .invoke_handler(tauri::generate_handler![show_dashboard, hide_dashboard]) + .plugin(tauri_plugin_updater::Builder::new().build()); + + // macOS is the one platform where the event loop cannot enforce D2 on its own: Tauri's default + // menu carries a predefined Quit wired to Cocoa's terminate:, and the pinned tao raises no + // cancellable event for it. Replacing that one item is what lets Cmd+Q mean hide. + #[cfg(target_os = "macos")] + let builder = builder + .menu(menu::build) + .on_menu_event(|app, event| menu::on_event(app, event.id().as_ref())); + + builder + .invoke_handler(tauri::generate_handler![ + show_dashboard, + hide_dashboard, + startup_snapshot, + startup_phases, + retry_startup + ]) .setup(|app| { - let (endpoint, home) = discovery::current(); - let proxy = proxy::ProxyClient::new(endpoint, auth::Auth::new(home)) - .map_err(|error| error.to_string())?; - let child = tauri::async_runtime::block_on(sidecar::ensure_proxy( - app.handle(), - &proxy, - endpoint, - )) - .map_err(std::io::Error::other)?; - app.manage(AppState { - proxy: proxy.clone(), - spawned_by_us: AtomicBool::new(child.is_some()), - child: Mutex::new(child), - }); + app.manage(AppState::new()); app.manage(updater::PendingUpdate(Mutex::new(None))); app.manage(tray::TrayState::default()); + app.manage(exit::ExitCoordinator::new()); + app.manage(startup::Startup::new()); - let window = WebviewWindowBuilder::new( - app, - "main", - WebviewUrl::App(format!("index.html?port={}", endpoint.port).into()), - ) - .title("OpenCodex") - .inner_size(1100.0, 720.0) - .visible(false) - .user_agent(&window::webview_user_agent()) - .on_navigation(window::navigation_allowed(endpoint)) - .build()?; + // D7: the window is created and shown before anything is registered, resolved, probed + // or started, so every state below has somewhere to be reported. A login launch stays + // hidden until the tray verdict, because R1 shows it after all when there turns out to + // be nowhere to hide. + let window = + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("OpenCodex") + .inner_size(1100.0, 720.0) + .visible(false) + .user_agent(&window::webview_user_agent()) + .on_navigation(window::navigation_allowed(app.handle().clone())) + .build()?; window::configure(&window); - window::set_tray_policy(app.handle(), false); - let dashboard = endpoint.url("/#/usage"); - if tauri::async_runtime::block_on(proxy.is_alive()).is_ok() { - let _ = window.eval(format!("window.location.replace({dashboard:?})")); + if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { + window::show(&window); + } else { + window::set_tray_policy(app.handle(), false); } - // Before the tray, so its Start at Login checkbox reads the state this leaves behind - // rather than the state from before first run. - first_run::apply_start_at_login_default(app.handle()); - tray::install(app.handle(), proxy)?; + + startup::begin(app.handle()); + if !cfg!(debug_assertions) { updater::start_background_checks(app.handle().clone()); } @@ -114,10 +188,11 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building OpenCodex desktop shell") .run(|app, event| { - if matches!(event, tauri::RunEvent::Exit) { - if let Some(state) = app.try_state::() { - state.shutdown_child(); - } + // Window close and the platform quit gesture arrive here as an exit request, and until + // this handler existed they went straight through to a SIGKILL of the runtime. D2 makes + // them hide; only the tray's Quit, and an update's coordinated restart, get past. + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + exit::on_exit_requested(app, code, &api); } }); } diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs new file mode 100644 index 00000000000..5a50b071a44 --- /dev/null +++ b/desktop/src-tauri/src/menu.rs @@ -0,0 +1,132 @@ +//! The macOS application menu. +//! +//! Tauri installs a default menu when the app sets none, and that menu's Quit is a predefined item +//! wired straight to Cocoa's `terminate:`. The pinned tao implements only +//! `applicationWillTerminate`, never the cancellable `applicationShouldTerminate`, so a Cmd+Q +//! through that item reaches `RunEvent::Exit` without ever raising `RunEvent::ExitRequested`. +//! Nothing can hold it, which means D2's rule — the quit gesture hides, only the tray's Quit ends +//! the app — cannot be enforced from the event loop alone on macOS. The one item is replaced here +//! with an ordinary item on the same accelerator, routed through the same gesture path as closing +//! the window. +//! +//! The rest is reproduced rather than mutated: `Menu::default` is not decomposable, and dropping it +//! would take Cut, Copy, Paste and Select All with it — which the startup diagnostic needs the user +//! to be able to use. This mirrors `tauri::menu::Menu::default` for the pinned version, minus that +//! item. + +/// The id of the replacement Quit item. Nothing else in the app uses it, so a menu event carrying +/// it is unambiguously this one. +pub const QUIT_ID: &str = "app-menu-quit"; + +#[cfg(target_os = "macos")] +pub fn build(app: &tauri::AppHandle) -> tauri::Result> { + use tauri::menu::{ + AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, + WINDOW_SUBMENU_ID, + }; + + let package = app.package_info(); + let config = app.config(); + let about = AboutMetadata { + name: Some(package.name.clone()), + version: Some(package.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config + .bundle + .publisher + .clone() + .map(|publisher| vec![publisher]), + ..Default::default() + }; + + // Labelled as a quit because that is the gesture the user is making. What it means here is + // D2's answer to that gesture: the window goes away and the runtime keeps serving. + let quit = MenuItem::with_id( + app, + QUIT_ID, + format!("Quit {}", package.name), + true, + Some("CmdOrCtrl+Q"), + )?; + + Menu::with_items( + app, + &[ + &Submenu::with_items( + app, + package.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?, + &Submenu::with_items( + app, + "File", + true, + &[&PredefinedMenuItem::close_window(app, None)?], + )?, + &Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?, + &Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?, + &Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?, + &Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?, + ], + ) +} + +/// Route an application-menu event. +/// +/// Only the replacement Quit is ours. Tray menu events are handled by the tray's own handler and +/// carry different ids, so an id that is not [`QUIT_ID`] is left alone. +pub fn on_event(app: &tauri::AppHandle, id: &str) { + if id == QUIT_ID { + crate::exit::gesture(app); + } +} + +#[cfg(test)] +mod tests { + use super::QUIT_ID; + + #[test] + fn the_replacement_quit_has_an_id_of_its_own() { + assert!(!QUIT_ID.is_empty()); + assert_ne!(QUIT_ID, "quit"); + } +} diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index caea7288563..e0dbdb2ebf4 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -2,6 +2,7 @@ use crate::{auth::Auth, discovery::ProxyEndpoint}; use reqwest::{Client, Method, StatusCode}; use serde_json::Value; use std::time::Duration; +use tokio::time::{timeout_at, Instant}; #[derive(Clone)] pub struct ProxyClient { @@ -18,6 +19,18 @@ pub enum ProxyError { Decode(reqwest::Error), } +impl ProxyError { + /// Whether nothing is listening on the endpoint at all. + /// + /// This is the only error that says anything about the process behind the port. A timeout, an + /// unauthorized reply or a body that will not parse all mean the listener answered or might + /// still be there, and a stop that reads any of them as "gone" reports a drain that did not + /// happen. + pub fn is_unreachable(&self) -> bool { + matches!(self, Self::Unreachable) + } +} + impl ProxyClient { pub fn new(endpoint: ProxyEndpoint, auth: Auth) -> Result { Ok(Self { @@ -42,6 +55,21 @@ impl ProxyClient { self.get("/healthz").await } + /// A health probe that cannot outlive the caller's deadline. + /// + /// The client's own timeout is per request and knows nothing about the budget the caller is + /// working to. A probe started a moment before a deadline would otherwise overrun it by that + /// whole timeout, which is how a stated 30-second startup ceiling quietly becomes 34. + /// `None` means the deadline arrived first. + pub async fn alive_within(&self, deadline: Instant) -> Option> { + timeout_at(deadline, self.is_alive()).await.ok() + } + + /// A stop request bounded the same way. + pub async fn stop_within(&self, deadline: Instant) -> Option> { + timeout_at(deadline, self.stop()).await.ok() + } + pub async fn companion_settings(&self) -> Result { self.get("/api/companion/settings").await } diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 434c1d62883..96f5e963fea 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -1,27 +1,149 @@ +//! Starting, watching and draining the runtime this app owns. +//! +//! The spawn event stream used to be discarded into `_events`, which is why a sidecar that exited +//! immediately — a binary built for a CPU instruction set this machine does not have, a port +//! already taken, a corrupt install — presented as the same generic health failure as a slow start. +//! The child's exit code and its last output were both available and both thrown away. They are +//! consumed here instead, and they are what the startup diagnostic is made of. + use crate::{discovery::ProxyEndpoint, proxy::ProxyClient}; -use tauri::{AppHandle, Manager}; -use tauri_plugin_shell::{process::CommandChild, ShellExt}; -use tokio::time::{sleep, timeout, Duration, Instant}; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; +use tauri::{async_runtime::Receiver, AppHandle, Manager}; +use tauri_plugin_shell::{ + process::{CommandChild, CommandEvent}, + ShellExt, +}; +use tokio::time::{sleep, Duration, Instant}; -pub async fn ensure_proxy( - app: &AppHandle, - proxy: &ProxyClient, - endpoint: ProxyEndpoint, -) -> Result, String> { - let deadline = Instant::now() + Duration::from_secs(2); - loop { - if matches!( - timeout(Duration::from_millis(250), proxy.is_alive()).await, - Ok(Ok(_)) - ) { - return Ok(None); +/// How much sidecar output the diagnostic keeps. Enough to carry a stack trace or a startup +/// refusal, bounded so a chatty runtime cannot grow the buffer for the life of the process. +const MAX_LINES: usize = 40; + +/// How long a graceful stop may take before it is reported as incomplete. The runtime's own stop +/// restores client configuration and lets in-flight requests finish, so this is generous on +/// purpose; it bounds a hang, it does not pace a healthy stop. +pub const DRAIN_DEADLINE: Duration = Duration::from_secs(15); + +/// How the sidecar process ended. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SidecarExit { + pub code: Option, + pub signal: Option, +} + +impl SidecarExit { + pub fn describe(&self) -> String { + match (self.code, self.signal) { + (Some(code), _) => format!("exit code {code}"), + (None, Some(signal)) => format!("terminated by signal {signal}"), + (None, None) => "exited without reporting a code".to_owned(), } - if Instant::now() >= deadline { - break; + } +} + +/// One thing the spawned child told us. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SidecarEvent { + Line(String), + Exited(SidecarExit), +} + +#[derive(Default)] +struct WatchInner { + lines: VecDeque, + exit: Option, +} + +impl WatchInner { + fn record(&mut self, event: SidecarEvent) { + match event { + SidecarEvent::Line(line) => { + let line = line.trim_end().to_owned(); + if line.is_empty() { + return; + } + if self.lines.len() == MAX_LINES { + self.lines.pop_front(); + } + self.lines.push_back(line); + } + SidecarEvent::Exited(exit) => self.exit = Some(exit), } - sleep(Duration::from_millis(150)).await; } +} +/// The consumed spawn event stream of the child this app started. +#[derive(Clone, Default)] +pub struct SidecarWatch { + inner: Arc>, +} + +impl SidecarWatch { + pub fn record(&self, event: SidecarEvent) { + if let Ok(mut inner) = self.inner.lock() { + inner.record(event); + } + } + + pub fn exit(&self) -> Option { + self.inner.lock().ok().and_then(|inner| inner.exit) + } + + pub fn lines(&self) -> Vec { + self.inner + .lock() + .map(|inner| inner.lines.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Forget the previous attempt so a retry's diagnostic describes the retry. + pub fn reset(&self) { + if let Ok(mut inner) = self.inner.lock() { + inner.lines.clear(); + inner.exit = None; + } + } + + /// Drain the spawn event stream into this record for as long as the child lives. + pub fn follow(&self, mut events: Receiver) { + let watch = self.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = events.recv().await { + if let Some(event) = translate(event) { + watch.record(event); + } + } + }); + } +} + +fn translate(event: CommandEvent) -> Option { + match event { + CommandEvent::Stdout(bytes) | CommandEvent::Stderr(bytes) => Some(SidecarEvent::Line( + String::from_utf8_lossy(&bytes).into_owned(), + )), + CommandEvent::Error(message) => Some(SidecarEvent::Line(format!("error: {message}"))), + CommandEvent::Terminated(payload) => Some(SidecarEvent::Exited(SidecarExit { + code: payload.code, + signal: payload.signal, + })), + _ => None, + } +} + +/// Start the bundled runtime and begin consuming what it says. +/// +/// The port is still passed explicitly. D5 hands that resolution to the bundled CLI so a user on a +/// custom `config.port` is not started on a different one; this is the call site that changes when +/// lane A's resolve verb lands, and nothing else here depends on where the number came from. +pub fn start( + app: &AppHandle, + endpoint: ProxyEndpoint, + watch: &SidecarWatch, +) -> Result { let gui_dist = app .path() .resource_dir() @@ -34,14 +156,164 @@ pub async fn ensure_proxy( .map_err(|error| error.to_string())? .args(["start", "--port", &endpoint.port.to_string()]) .env("OPENCODEX_GUI_DIST", gui_dist); - let (_events, child) = command.spawn().map_err(|error| error.to_string())?; + let (events, child) = command.spawn().map_err(|error| error.to_string())?; + watch.follow(events); + Ok(child) +} + +/// What a graceful stop did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DrainOutcome { + /// This app did not start the runtime, so it does not stop it. Someone else's proxy outlives + /// this app's quit, which is the whole point of only ever draining what we own. + NotOwned, + /// The endpoint stopped answering within the deadline. + Stopped, + /// The stop was accepted and the endpoint still answers. + StillRunning, + /// The stop request itself failed. + Refused(String), +} + +impl DrainOutcome { + pub fn failure(&self) -> Option { + match self { + Self::NotOwned | Self::Stopped => None, + Self::StillRunning => { + Some("the runtime still answers after the graceful stop deadline".to_owned()) + } + Self::Refused(error) => Some(error.clone()), + } + } +} + +/// Stop the runtime this app owns and wait until it is actually gone. +/// +/// Only an app-owned runtime reaches here, which is why the management stop is the right +/// instrument: its documented refusals are about launchd, systemd and the Windows respawn window, +/// none of which apply to a child this process spawned. Taking over somebody else's *managed* +/// runtime is a different act and needs the bundled `ocx stop` — that is D4, and it is lane A's +/// contract, not this path. +pub async fn drain(proxy: &ProxyClient, owned: bool, watch: &SidecarWatch) -> DrainOutcome { + if !owned { + return DrainOutcome::NotOwned; + } + let deadline = Instant::now() + DRAIN_DEADLINE; + let refusal = match proxy.stop_within(deadline).await { + Some(Ok(_)) => None, + Some(Err(error)) => { + // A runtime that has already gone is a drained runtime, not a failed stop. + if gone(proxy, watch, deadline).await { + return DrainOutcome::Stopped; + } + Some(format!("{error:?}")) + } + None => Some("the stop request did not answer before the deadline".to_owned()), + }; + while Instant::now() < deadline { + if gone(proxy, watch, deadline).await { + return DrainOutcome::Stopped; + } + sleep(Duration::from_millis(200)).await; + } + match refusal { + Some(error) => DrainOutcome::Refused(error), + None => DrainOutcome::StillRunning, + } +} + +/// Whether the runtime is actually gone, rather than merely not answering the way we hoped. +/// +/// Two facts count, and no others. The child reporting its own termination through the spawn event +/// stream is conclusive. Failing that, the endpoint *refusing a connection* says the listener has +/// released the port. A timeout, an unauthorized reply or a body that will not parse are none of +/// those: they mean something answered, or might still be there. Reading any error as proof is how +/// a stop that never happened gets reported as a completed drain. +async fn gone(proxy: &ProxyClient, watch: &SidecarWatch, deadline: Instant) -> bool { + if watch.exit().is_some() { + return true; + } + matches!( + proxy.alive_within(deadline).await, + Some(Err(error)) if error.is_unreachable() + ) +} - for _ in 0..20 { - tokio::time::sleep(std::time::Duration::from_millis(150)).await; - if proxy.is_alive().await.is_ok() { - return Ok(Some(child)); +#[cfg(test)] +mod tests { + use super::{DrainOutcome, SidecarEvent, SidecarExit, SidecarWatch, MAX_LINES}; + + #[test] + fn the_exit_code_survives_the_event_stream() { + let watch = SidecarWatch::default(); + watch.record(SidecarEvent::Line("listening on 10100".into())); + watch.record(SidecarEvent::Exited(SidecarExit { + code: Some(1), + signal: None, + })); + assert_eq!(watch.exit().and_then(|exit| exit.code), Some(1)); + assert_eq!(watch.lines(), vec!["listening on 10100".to_owned()]); + } + + #[test] + fn output_is_bounded_and_keeps_the_end() { + let watch = SidecarWatch::default(); + for index in 0..(MAX_LINES + 5) { + watch.record(SidecarEvent::Line(format!("line {index}"))); } + let lines = watch.lines(); + assert_eq!(lines.len(), MAX_LINES); + assert_eq!(lines.first().unwrap(), "line 5"); + assert_eq!(lines.last().unwrap(), &format!("line {}", MAX_LINES + 4)); + } + + #[test] + fn blank_output_is_not_recorded_and_a_reset_forgets_the_attempt() { + let watch = SidecarWatch::default(); + watch.record(SidecarEvent::Line(" \n".into())); + assert!(watch.lines().is_empty()); + watch.record(SidecarEvent::Line("boom".into())); + watch.record(SidecarEvent::Exited(SidecarExit { + code: None, + signal: Some(9), + })); + watch.reset(); + assert!(watch.lines().is_empty()); + assert!(watch.exit().is_none()); + } + + #[test] + fn an_exit_reads_as_a_code_a_signal_or_neither() { + assert_eq!( + SidecarExit { + code: Some(2), + signal: None + } + .describe(), + "exit code 2" + ); + assert_eq!( + SidecarExit { + code: None, + signal: Some(9) + } + .describe(), + "terminated by signal 9" + ); + assert_eq!( + SidecarExit::default().describe(), + "exited without reporting a code" + ); + } + + #[test] + fn only_an_incomplete_drain_reports_a_failure() { + assert!(DrainOutcome::NotOwned.failure().is_none()); + assert!(DrainOutcome::Stopped.failure().is_none()); + assert!(DrainOutcome::StillRunning.failure().is_some()); + assert_eq!( + DrainOutcome::Refused("Unreachable".into()).failure(), + Some("Unreachable".to_owned()) + ); } - let _ = child.kill(); - Err("the OpenCodex sidecar did not become healthy".into()) } diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs new file mode 100644 index 00000000000..7e1e7829624 --- /dev/null +++ b/desktop/src-tauri/src/startup.rs @@ -0,0 +1,685 @@ +//! The startup sequence, as named states inside a window the user can already see. +//! +//! Everything below used to run inside `setup()` before any window existed, and the window was +//! then created hidden. That ordering is why a failed start had no surface: the spawn event stream +//! was discarded, so the child's exit code was gone, and a run of probes that time out rather than +//! refuse takes over a minute with nothing on screen to explain it. D7 inverts it. The window is +//! created and shown first, and the sequence runs inside it as named states under one overall +//! deadline, with a retry, the child's exit code and a diagnostic the user can copy. +//! +//! Registration comes first, before the runtime is touched at all. The order looks backwards until +//! you follow the failing case: a login launch starts hidden, and if the tray were installed only +//! after a successful start then a start that failed would leave a running process with no window +//! and no icon — invisible. The app establishes its own surface, then deals with the runtime. +//! +//! A launch that came from login autostart starts hidden, and that is the only difference — except +//! where there is no usable tray to hide into, which is R1 and lives in [`shows_window`]. + +use crate::{ + auth::Auth, + discovery::{self, ProxyEndpoint}, + first_run::{self, StartAtLogin}, + proxy::ProxyClient, + sidecar::{self, SidecarWatch}, + tray_availability::{self, TrayAvailability}, + AppState, +}; +use serde::Serialize; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, MutexGuard, PoisonError, + }, +}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::{sleep, Duration, Instant}; + +/// The event the bootstrap page listens on. +pub const PHASE_EVENT: &str = "startup-phase"; + +/// One deadline for the whole sequence. +/// +/// Per-step budgets were what produced the unbounded case: a two-second attach loop whose probes +/// each cost a four-second client timeout, followed by twenty more waits, adds up to something no +/// single number in the code admitted to. One ceiling over the whole run is a promise that can be +/// read — and every probe under it is bounded by the remaining time rather than by its own +/// timeout, because otherwise the last probe overruns the ceiling by the whole client timeout. +pub const DEADLINE: Duration = Duration::from_secs(30); + +/// How long an already-running runtime gets to answer before this app starts its own. +/// +/// The core liveness path carries a comment explaining why this number is not smaller: a single +/// unanswered 750ms probe was once enough to start a duplicate proxy on Windows. +const ATTACH_BUDGET: Duration = Duration::from_secs(2); + +const POLL: Duration = Duration::from_millis(250); + +/// Where the launch came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LaunchOrigin { + /// A person opened the app. + User, + /// The login item started it. + Autostart, +} + +/// The argument the autostart registration passes back to us. Nothing else supplies it, so its +/// presence is the launch origin. +pub const AUTOSTART_FLAG: &str = "--autostart"; + +impl LaunchOrigin { + pub fn from_args(mut args: impl Iterator) -> Self { + if args.any(|argument| argument == AUTOSTART_FLAG) { + Self::Autostart + } else { + Self::User + } + } + + pub fn detect() -> Self { + Self::from_args(std::env::args()) + } +} + +/// Whether this launch shows its window. +/// +/// D7 shows it always and exempts a login launch, which starts hidden. D6 shows it wherever there +/// is no usable tray. A no-tray login launch satisfies both rules and they disagree, so R1 settles +/// it: tray availability wins. Starting hidden is a property of having somewhere to be hidden in, +/// not of how the process was started. +pub fn shows_window(origin: LaunchOrigin, tray: TrayAvailability) -> bool { + !tray.is_available() || origin == LaunchOrigin::User +} + +/// A named state of the startup sequence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + Registering, + Resolving, + Probing, + Attaching, + Starting, + Waiting, + Ready, + Failed, +} + +/// Every phase, in the order they run. The bootstrap page derives its checklist from this rather +/// than restating it, so a phase cannot exist in one place and be missing from the other. +pub const PHASES: [Phase; 8] = [ + Phase::Registering, + Phase::Resolving, + Phase::Probing, + Phase::Attaching, + Phase::Starting, + Phase::Waiting, + Phase::Ready, + Phase::Failed, +]; + +impl Phase { + /// The stable identifier the bootstrap page keys on. + pub fn id(self) -> &'static str { + match self { + Self::Registering => "registering", + Self::Resolving => "resolving", + Self::Probing => "probing", + Self::Attaching => "attaching", + Self::Starting => "starting", + Self::Waiting => "waiting", + Self::Ready => "ready", + Self::Failed => "failed", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Registering => "Registering the tray and the login item", + Self::Resolving => "Resolving the configuration home and port", + Self::Probing => "Looking for a runtime that is already listening", + Self::Attaching => "Attaching to the runtime that answered", + Self::Starting => "Starting the bundled runtime", + Self::Waiting => "Waiting for the runtime to report healthy", + Self::Ready => "Ready", + Self::Failed => "OpenCodex could not start its runtime", + } + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::Ready | Self::Failed) + } +} + +/// One phase, as the bootstrap page sees it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PhaseInfo { + pub id: &'static str, + pub label: &'static str, + pub terminal: bool, +} + +/// The phase list the page renders. Derived from [`PHASES`] so the two cannot drift. +pub fn phase_list() -> Vec { + PHASES + .iter() + .map(|phase| PhaseInfo { + id: phase.id(), + label: phase.label(), + terminal: phase.is_terminal(), + }) + .collect() +} + +/// What the bootstrap page is told. +/// +/// It carries the phases already finished, not just the current one. An event emitted before the +/// page's listener exists is gone, and the early phases finish in milliseconds, so a page that +/// reconstructed history from events alone would show a run in progress with nothing behind it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Progress { + pub phase: &'static str, + pub label: &'static str, + pub detail: Option, + pub completed: Vec<&'static str>, + pub failed_phase: Option<&'static str>, + pub elapsed_ms: u64, + pub dashboard: Option, + pub diagnostic: Option, + pub can_retry: bool, +} + +impl Progress { + fn new(phase: Phase, elapsed_ms: u64) -> Self { + Self { + phase: phase.id(), + label: phase.label(), + detail: None, + completed: Vec::new(), + failed_phase: None, + elapsed_ms, + dashboard: None, + diagnostic: None, + can_retry: phase == Phase::Failed, + } + } +} + +/// What the sequence resolved, once it has. +#[derive(Clone)] +struct Resolved { + endpoint: ProxyEndpoint, + home: PathBuf, +} + +struct Live { + latest: Progress, + reported: Vec<&'static str>, +} + +/// The sequence's managed state: the latest thing it said, what it has already finished, and +/// whether it is running, so a retry cannot start a second run alongside the first. +pub struct Startup { + live: Mutex, + running: AtomicBool, +} + +impl Startup { + pub fn new() -> Self { + Self { + live: Mutex::new(Live { + latest: Progress::new(Phase::Registering, 0), + reported: Vec::new(), + }), + running: AtomicBool::new(false), + } + } + + fn live(&self) -> MutexGuard<'_, Live> { + self.live.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The whole state of the run so far, which is what the page asks for when it loads. + pub fn latest(&self) -> Progress { + self.live().latest.clone() + } + + fn restart(&self) { + let mut live = self.live(); + live.reported.clear(); + live.latest = Progress::new(Phase::Registering, 0); + } + + fn publish(&self, progress: &mut Progress, failed_in: Option) { + let mut live = self.live(); + if !live.reported.contains(&progress.phase) + && progress.phase != Phase::Ready.id() + && progress.phase != Phase::Failed.id() + { + live.reported.push(progress.phase); + } + progress.completed = live + .reported + .iter() + .copied() + .filter(|id| *id != progress.phase) + .collect(); + progress.failed_phase = failed_in.map(Phase::id); + live.latest = progress.clone(); + } +} + +impl Default for Startup { + fn default() -> Self { + Self::new() + } +} + +/// Run the sequence, unless it is already running. This is also the retry. +pub fn begin(app: &AppHandle) { + let Some(startup) = app.try_state::() else { + return; + }; + if startup + .running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + startup.restart(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + run(&app).await; + if let Some(startup) = app.try_state::() { + startup.running.store(false, Ordering::Release); + } + }); +} + +async fn run(app: &AppHandle) { + let started = Instant::now(); + let deadline = started + DEADLINE; + let Some(watch) = app.try_state::().map(|state| state.watch.clone()) else { + return; + }; + report(app, started, Phase::Registering, None); + let login = register(app).await; + report( + app, + started, + Phase::Registering, + Some(login.describe().to_owned()), + ); + + report(app, started, Phase::Resolving, None); + // D5 hands this resolution to the bundled CLI, so a user on a custom config.port is not started + // on a different one. This is the call site that changes when lane A's resolve verb lands; it + // is already inside the sequence so the failure has a state, a diagnostic and a retry. + let (endpoint, home) = discovery::current(); + let resolved = Resolved { + endpoint, + home: home.clone(), + }; + let proxy = match ProxyClient::new(endpoint, Auth::new(home)) { + Ok(proxy) => proxy, + Err(error) => { + fail( + app, + started, + Some(&resolved), + login, + &watch, + Phase::Resolving, + error.to_string(), + ); + return; + } + }; + if let Some(state) = app.try_state::() { + state.attach(proxy.clone()); + } + report( + app, + started, + Phase::Resolving, + Some(format!( + "{} with a configuration home of {}", + resolved.endpoint.url(""), + resolved.home.display() + )), + ); + + report(app, started, Phase::Probing, None); + if healthy_by(&proxy, (started + ATTACH_BUDGET).min(deadline)).await { + report( + app, + started, + Phase::Attaching, + Some("a runtime was already listening, so this app is a guest on it".to_owned()), + ); + finish(app, started, endpoint); + return; + } + + // A retry must not leave a second proxy behind. A child that has not reported an exit is still + // out there, whatever the last run concluded, so the retry waits on that one rather than + // starting another and racing it for the port. + let owns_live_child = app + .try_state::() + .is_some_and(|state| state.owns_runtime()) + && watch.exit().is_none(); + if owns_live_child { + report( + app, + started, + Phase::Starting, + Some("the runtime this app started has not exited; waiting on it again".to_owned()), + ); + } else { + report(app, started, Phase::Starting, None); + watch.reset(); + match spawn_runtime(app, endpoint, &watch) { + Some(Ok(())) => {} + Some(Err(error)) => { + fail( + app, + started, + Some(&resolved), + login, + &watch, + Phase::Starting, + error, + ); + return; + } + // An exit is already in flight, so starting a runtime now would orphan it. + None => return, + } + } + + report(app, started, Phase::Waiting, None); + while Instant::now() < deadline { + if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { + finish(app, started, endpoint); + return; + } + // A child that has already exited will never answer, so the deadline is not worth waiting + // out. This is the case the discarded event stream used to hide behind a generic timeout. + if let Some(exit) = watch.exit() { + fail( + app, + started, + Some(&resolved), + login, + &watch, + Phase::Waiting, + format!("the runtime {}", exit.describe()), + ); + return; + } + sleep(POLL).await; + } + fail( + app, + started, + Some(&resolved), + login, + &watch, + Phase::Waiting, + format!( + "the runtime did not report healthy within {} seconds", + DEADLINE.as_secs() + ), + ); +} + +/// Establish the app's own surface: the tray verdict, the tray, and the login item. +async fn register(app: &AppHandle) -> StartAtLogin { + // The probe blocks on a session-bus round trip, so it does not belong on an async worker. + let tray = tauri::async_runtime::spawn_blocking(tray_availability::detect) + .await + .unwrap_or_else(|_| TrayAvailability::assumed()); + if let Some(coordinator) = app.try_state::() { + coordinator.set_tray(tray); + } + + // Before the tray, so its Start at Login checkbox reads the state this leaves behind rather + // than the state from before first run. + let login = first_run::apply_start_at_login_default(app); + first_run::adopt_launch_origin_argument(app); + + if tray.is_available() { + // Tray construction is a GTK call on Linux and must happen on the main thread. + let handle = app.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + let _ = + sender.send(crate::tray::install(&handle).map_err(|error| error.to_string())); + }) + .is_ok() + { + if let Ok(Err(error)) = receiver.await { + crate::logging::log_once("the tray could not be installed", &error); + } + } + } + + if let Some(window) = app.get_webview_window("main") { + if shows_window(LaunchOrigin::detect(), tray) { + crate::window::show(&window); + } + } + login +} + +/// Start the runtime, unless an exit is already in flight. +/// +/// The spawn and the record that we own the child happen under the exit coordinator's lock, so a +/// quit arriving mid-startup cannot observe "we own nothing", drain nothing, and let the process +/// end moments after this spawned a proxy that nothing will ever stop. +fn spawn_runtime( + app: &AppHandle, + endpoint: ProxyEndpoint, + watch: &SidecarWatch, +) -> Option> { + let coordinator = app.try_state::()?; + coordinator.spawn_unless_ending(|| { + let child = sidecar::start(app, endpoint, watch)?; + if let Some(state) = app.try_state::() { + state.adopt(child); + } + Ok(()) + }) +} + +async fn healthy_by(proxy: &ProxyClient, deadline: Instant) -> bool { + loop { + if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { + return true; + } + if Instant::now() >= deadline { + return false; + } + sleep(POLL).await; + } +} + +fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { + crate::tray::set_owned( + app, + app.try_state::() + .is_some_and(|state| state.owns_runtime()), + ); + let dashboard = endpoint.url("/#/usage"); + let mut progress = Progress::new(Phase::Ready, elapsed(started)); + progress.dashboard = Some(dashboard.clone()); + emit(app, progress, None); + if let Some(window) = app.get_webview_window("main") { + // justified: replacing the bootstrap page with the dashboard is how this window has always + // navigated, and the string is a URL this process resolved, not anything a page supplied. + let _ = window.eval(format!("window.location.replace({dashboard:?})")); + } +} + +#[allow(clippy::too_many_arguments)] +fn fail( + app: &AppHandle, + started: Instant, + resolved: Option<&Resolved>, + login: StartAtLogin, + watch: &SidecarWatch, + phase: Phase, + reason: String, +) { + let elapsed_ms = elapsed(started); + let mut progress = Progress::new(Phase::Failed, elapsed_ms); + progress.diagnostic = Some(diagnostic( + resolved.map(|resolved| (resolved.endpoint, resolved.home.clone())), + login, + watch, + phase, + &reason, + elapsed_ms, + )); + progress.detail = Some(reason); + emit(app, progress, Some(phase)); +} + +/// The text the failure surface offers for copying. +/// +/// It names the state it stopped in, the endpoint and home it was using, how the child ended and +/// what the child last said. Those together are what separates "the port was taken" from "the +/// binary will not run on this CPU" from "the home is not the one the accounts are in", and none of +/// them were reachable from the generic health failure this replaces. +pub fn diagnostic( + resolved: Option<(ProxyEndpoint, PathBuf)>, + login: StartAtLogin, + watch: &SidecarWatch, + phase: Phase, + reason: &str, + elapsed_ms: u64, +) -> String { + let mut lines = vec![ + format!( + "OpenCodex desktop {} on {}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + ), + format!("state: {}", phase.id()), + format!("reason: {reason}"), + format!("elapsed: {elapsed_ms}ms"), + ]; + match resolved { + Some((endpoint, home)) => { + lines.push(format!("endpoint: {}", endpoint.url(""))); + lines.push(format!("home: {}", home.display())); + } + None => lines.push("endpoint: not resolved".to_owned()), + } + lines.push(format!("start at login: {}", login.describe())); + lines.push(match watch.exit() { + Some(exit) => format!("runtime process: {}", exit.describe()), + None => "runtime process: still running or never started".to_owned(), + }); + let output = watch.lines(); + if output.is_empty() { + lines.push("runtime output: none".to_owned()); + } else { + lines.push("runtime output:".to_owned()); + lines.extend(output.into_iter().map(|line| format!(" {line}"))); + } + lines.join("\n") +} + +fn report(app: &AppHandle, started: Instant, phase: Phase, detail: Option) { + let mut progress = Progress::new(phase, elapsed(started)); + progress.detail = detail; + emit(app, progress, None); +} + +fn emit(app: &AppHandle, mut progress: Progress, failed_in: Option) { + if let Some(startup) = app.try_state::() { + startup.publish(&mut progress, failed_in); + } + let _ = app.emit(PHASE_EVENT, progress); +} + +fn elapsed(started: Instant) -> u64 { + started.elapsed().as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES}; + use crate::tray_availability::TrayAvailability; + + #[test] + fn only_the_autostart_argument_marks_a_login_launch() { + let user = ["/Applications/OpenCodex.app".to_owned()]; + assert_eq!( + LaunchOrigin::from_args(user.into_iter()), + LaunchOrigin::User + ); + let login = [ + "/Applications/OpenCodex.app".to_owned(), + AUTOSTART_FLAG.to_owned(), + ]; + assert_eq!( + LaunchOrigin::from_args(login.into_iter()), + LaunchOrigin::Autostart + ); + } + + #[test] + fn a_manual_launch_always_shows_the_window() { + assert!(shows_window( + LaunchOrigin::User, + TrayAvailability::Available + )); + assert!(shows_window( + LaunchOrigin::User, + TrayAvailability::Unavailable + )); + } + + #[test] + fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { + assert!(!shows_window( + LaunchOrigin::Autostart, + TrayAvailability::Available + )); + assert!(shows_window( + LaunchOrigin::Autostart, + TrayAvailability::Unavailable + )); + } + + #[test] + fn registration_runs_before_the_runtime_is_touched() { + let order: Vec<&str> = PHASES.iter().map(|phase| phase.id()).collect(); + let registering = order.iter().position(|id| *id == "registering").unwrap(); + for later in ["resolving", "probing", "starting", "waiting"] { + assert!(registering < order.iter().position(|id| *id == later).unwrap()); + } + } + + #[test] + fn every_phase_has_a_distinct_identifier_and_a_label() { + let mut ids: Vec<&str> = PHASES.iter().map(|phase| phase.id()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), PHASES.len()); + assert!(PHASES.iter().all(|phase| !phase.label().is_empty())); + assert_eq!(PHASES.iter().filter(|phase| phase.is_terminal()).count(), 2); + assert!(PHASES.contains(&Phase::Ready)); + } + + #[test] + fn the_whole_sequence_is_bounded_well_under_the_minute_it_used_to_take() { + assert!(DEADLINE.as_secs() <= 45); + } +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 5e5e818b182..ac324da31a7 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -1,4 +1,9 @@ -use crate::{formatting, proxy::ProxyClient, updater, widget, window}; +use crate::{ + exit::{self, ExitReason}, + formatting, + proxy::ProxyClient, + sidecar, updater, widget, window, +}; use serde_json::Value; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -13,13 +18,14 @@ use tauri_plugin_autostart::ManagerExt; use tauri_plugin_opener::OpenerExt; pub struct TrayState { - pub menu: Mutex>, + pub menu: Mutex>, pub installing: AtomicBool, } -pub struct UpdateMenu { +pub struct TrayMenu { check_updates: MenuItem, install_update: MenuItem, + stop: MenuItem, } impl Default for TrayState { @@ -31,7 +37,11 @@ impl Default for TrayState { } } -pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { +/// Build the tray. +/// +/// The proxy is not passed in. The tray is installed before a runtime has been resolved, so every +/// use reads the current client from the app instead of holding one that might not exist yet. +pub fn install(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; let login = CheckMenuItem::with_id( @@ -42,12 +52,12 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { app.autolaunch().is_enabled().unwrap_or(false), None::<&str>, )?; - let spawned_by_us = app - .state::() - .spawned_by_us - .load(Ordering::Relaxed); - let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", spawned_by_us, None::<&str>)?; - let stop_item = stop.clone(); + // The tray is built before the startup sequence has decided anything, so nothing owns a + // runtime yet. Ownership arrives later and reaches this item through [`set_owned`]. + let owned = app + .try_state::() + .is_some_and(|state| state.owns_runtime()); + let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", owned, None::<&str>)?; let check_updates = MenuItem::with_id( app, "check-updates", @@ -74,9 +84,10 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { ], )?; if let Ok(mut state) = app.state::().menu.lock() { - *state = Some(UpdateMenu { + *state = Some(TrayMenu { check_updates: check_updates.clone(), install_update: install_update.clone(), + stop: stop.clone(), }); } @@ -103,7 +114,13 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { } } "open-browser" => { - let endpoint = app.state::().proxy.endpoint(); + let Some(endpoint) = app + .state::() + .proxy() + .map(|proxy| proxy.endpoint()) + else { + return; + }; let _ = app .opener() .open_url(format!("{}#/usage", endpoint.url("/")), None::); @@ -117,31 +134,30 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { } } "stop-proxy" => { - if app - .state::() - .spawned_by_us - .load(Ordering::Relaxed) - { - let proxy = app.state::().proxy.clone(); - let app = app.clone(); - let stop_item = stop_item.clone(); - tauri::async_runtime::spawn(async move { - let mut stopped = proxy.stop().await.is_ok(); - for _ in 0..10 { - if stopped || proxy.is_alive().await.is_err() { - stopped = true; - break; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let pieces = app + .try_state::() + .map(|state| (state.proxy(), state.owns_runtime(), state.watch.clone())); + let Some((Some(proxy), owned, watch)) = pieces else { + return; + }; + // The same drain the quit path takes: ask the runtime to stop, then confirm + // that it actually has. The previous version accepted an unreachable endpoint + // as proof and then killed the child anyway. + let outcome = sidecar::drain(&proxy, owned, &watch).await; + match outcome.failure() { + None => { + if let Some(state) = app.try_state::() { + state.release(); } - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + set_owned(&app, false); } - if stopped { - app.state::().shutdown_child(); - let _ = stop_item.set_enabled(false); - } else { - eprintln!("tray: proxy still answering /healthz after stop request"); + Some(error) => { + crate::logging::log_once("graceful stop did not complete", &error) } - }); - } + } + }); } "check-updates" => { let app = app.clone(); @@ -175,18 +191,26 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { } }); } - "quit" => app.exit(0), + // The only gesture that ends the app. It does not call `exit` itself: the coordinator + // holds the exit, drains an app-owned runtime and only then lets the process end. + "quit" => exit::request(app, ExitReason::UserQuit), _ => {} }) .build(app)?; - refresh_title(&tray, &proxy); - widget::refresh(&proxy); + refresh(app, &tray); let tray = tray.clone(); + let app = app.clone(); tauri::async_runtime::spawn(async move { let mut tick = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; + let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + else { + continue; + }; refresh_title(&tray, &proxy); tick += 1; if tick % 5 == 0 { @@ -197,6 +221,28 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { Ok(()) } +fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { + let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + else { + return; + }; + refresh_title(tray, &proxy); + widget::refresh(&proxy); +} + +/// Reflect who owns the runtime in the tray's Stop item. +pub fn set_owned(app: &AppHandle, owned: bool) { + if let Some(state) = app.try_state::() { + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu.stop.set_enabled(owned); + } + } + } +} + pub fn show_update_available(app: &AppHandle, version: &str) { if let Some(state) = app.try_state::() { if let Ok(menu) = state.menu.lock() { diff --git a/desktop/src-tauri/src/tray_availability.rs b/desktop/src-tauri/src/tray_availability.rs new file mode 100644 index 00000000000..404c5b75e34 --- /dev/null +++ b/desktop/src-tauri/src/tray_availability.rs @@ -0,0 +1,131 @@ +//! Whether this session actually has a tray, as opposed to a tray backend that accepts an icon. +//! +//! `TrayIconBuilder::build` returning `Ok` proves nothing on Linux. The pinned backend creates an +//! AppIndicator and reports success without checking that anything will display it, so on stock +//! GNOME — which ships no AppIndicator extension — construction succeeds and no icon ever appears. +//! The shell's macOS-shaped assumptions then compound it: the window was created hidden and close +//! always hid, which leaves a running process with no way back in. +//! +//! So the question is asked of the session bus. Not whether the watcher exists — a watcher with no +//! host attached still accepts registrations and still draws nothing — but whether it reports a +//! host registered, which is the StatusNotifier specification's own answer to "is there somewhere +//! for an icon to appear". macOS and Windows have a status area that is always present and answer +//! without a probe. + +/// The result of asking whether this session can display a tray icon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayAvailability { + /// There is somewhere for the icon to appear, so hiding to the tray is a real place to hide. + Available, + /// There is not. The window is shown on launch and closing it ends the app through the drain. + Unavailable, +} + +impl TrayAvailability { + pub fn is_available(self) -> bool { + matches!(self, Self::Available) + } + + /// Whether a window close or a platform quit gesture may hide the app instead of ending it. + pub fn hides_to_tray(self) -> bool { + self.is_available() + } + + /// What to assume before the probe has answered. + /// + /// The probe is asynchronous, and a window close can land before it returns. Assuming a tray + /// that turns out not to exist is the failure this whole module is about, so the platforms that + /// need a probe assume nothing until they have one. + pub fn assumed() -> Self { + if cfg!(target_os = "linux") { + Self::Unavailable + } else { + Self::Available + } + } +} + +/// The StatusNotifier watcher, and the property that says a host is attached to it. +#[cfg(target_os = "linux")] +pub const WATCHER_NAME: &str = "org.kde.StatusNotifierWatcher"; +#[cfg(target_os = "linux")] +pub const WATCHER_PATH: &str = "/StatusNotifierWatcher"; +#[cfg(target_os = "linux")] +pub const HOST_REGISTERED: &str = "IsStatusNotifierHostRegistered"; + +/// How long the session-bus probe may take. +#[cfg(target_os = "linux")] +const PROBE_TIMEOUT_MS: u64 = 750; + +/// Read a host-registered answer as an availability verdict. +/// +/// `None` means the question could not be asked at all — no session bus, no watcher on it, no +/// reply, a malformed one. That is deliberately folded into the same answer as a watcher with no +/// host, because the two are indistinguishable from here and the safe response to both is +/// identical: show the window and let close mean close. Guessing the other way strands the user. +pub fn from_host_registered(registered: Option) -> TrayAvailability { + match registered { + Some(true) => TrayAvailability::Available, + Some(false) | None => TrayAvailability::Unavailable, + } +} + +#[cfg(not(target_os = "linux"))] +pub fn detect() -> TrayAvailability { + // macOS and Windows both have a status area that is always there, so the answer is known + // without asking anything. It still goes through the same reading so there is one place where + // an availability verdict is produced. + from_host_registered(Some(true)) +} + +#[cfg(target_os = "linux")] +pub fn detect() -> TrayAvailability { + from_host_registered(host_registered()) +} + +#[cfg(target_os = "linux")] +fn host_registered() -> Option { + use dbus::blocking::{stdintf::org_freedesktop_dbus::Properties, Connection}; + use std::time::Duration; + + let connection = Connection::new_session().ok()?; + let watcher = connection.with_proxy( + WATCHER_NAME, + WATCHER_PATH, + Duration::from_millis(PROBE_TIMEOUT_MS), + ); + // A watcher nobody owns makes this call fail rather than answer, which is the same verdict. + watcher.get(WATCHER_NAME, HOST_REGISTERED).ok() +} + +#[cfg(test)] +mod tests { + use super::{from_host_registered, TrayAvailability}; + + #[test] + fn only_a_registered_host_is_an_available_tray() { + assert_eq!( + from_host_registered(Some(true)), + TrayAvailability::Available + ); + assert_eq!( + from_host_registered(Some(false)), + TrayAvailability::Unavailable + ); + assert_eq!(from_host_registered(None), TrayAvailability::Unavailable); + } + + #[test] + fn hiding_is_only_offered_where_the_icon_would_be_drawn() { + assert!(TrayAvailability::Available.hides_to_tray()); + assert!(!TrayAvailability::Unavailable.hides_to_tray()); + } + + #[test] + fn nothing_is_assumed_on_the_platform_that_needs_a_probe() { + assert_eq!( + TrayAvailability::assumed().is_available(), + !cfg!(target_os = "linux") + ); + } +} diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs index 57086bd9193..6137afa99d5 100644 --- a/desktop/src-tauri/src/updater.rs +++ b/desktop/src-tauri/src/updater.rs @@ -52,7 +52,12 @@ pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> { .download_and_install(|_, _| {}, || {}) .await .map_err(|error| error.to_string())?; - app.restart(); + // R2: an update restart is a coordinated restart, not a quit. D2 forbids an *uncoordinated* + // exit, and `AppHandle::restart` was exactly that — it ran straight into the hard kill of the + // runtime this app owns. Going through the exit coordinator runs the same graceful drain the + // tray's Quit runs, and then the app comes back. + crate::exit::request_restart(app); + Ok(()) } pub fn update_label(version: &str) -> String { diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 10a1425dec1..ec31ad482e4 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -1,4 +1,4 @@ -use crate::{auth::Auth, discovery::ProxyEndpoint}; +use crate::{auth::Auth, exit, AppState}; use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent}; pub fn webview_user_agent() -> String { @@ -12,30 +12,54 @@ pub fn webview_user_agent() -> String { format!("{platform} {}", Auth::user_agent()) } +/// Decide what closing this window means, at the moment it is closed. +/// +/// The answer is not known when the window is built: on Linux it depends on a session-bus probe +/// that the startup sequence runs afterwards. So it is read here rather than captured. With a tray +/// a close hides and the runtime keeps serving; without one there is nowhere to hide, so D6 makes +/// the close a quit — and it takes the same graceful drain the tray's Quit does. pub fn configure(window: &WebviewWindow) { let window_for_close = window.clone(); window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - let _ = window_for_close.hide(); - apply_tray_policy(window_for_close.app_handle(), false); + let app = window_for_close.app_handle(); + if exit::hides_to_tray(app) { + hide(&window_for_close); + } else { + exit::request(app, exit::ExitReason::UserQuit); + } } }); } -pub fn navigation_allowed(endpoint: ProxyEndpoint) -> impl Fn(&Url) -> bool { +/// Where this window may navigate. +/// +/// The loopback endpoint is read from the app rather than captured, because the window now exists +/// before anything has been resolved. Until it has, an http target is refused outright instead of +/// being handed to the browser: nothing should be navigating anywhere yet, and opening an +/// unresolved address in the user's browser is a worse answer than doing nothing. +pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool { move |url| { if is_app_origin(url) { return true; } - if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { - return url.port_or_known_default() == Some(endpoint.port); + if url.scheme() == "about" && url.as_str() == "about:blank" { + return true; } - if matches!(url.scheme(), "http" | "https") { - let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); - return false; + let endpoint = app + .try_state::() + .and_then(|state| state.proxy()) + .map(|proxy| proxy.endpoint()); + if let Some(endpoint) = endpoint { + if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { + return url.port_or_known_default() == Some(endpoint.port); + } + if matches!(url.scheme(), "http" | "https") { + let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); + } } - url.scheme() == "about" && url.as_str() == "about:blank" + false } } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 6af54b5e1c2..83b2dc18886 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -8,6 +8,7 @@ "devUrl": "http://localhost:1420" }, "app": { + "withGlobalTauri": true, "security": { "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self'" } diff --git a/desktop/ui/index.html b/desktop/ui/index.html index e4d5c9a7de0..0a10a6e4534 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -7,22 +7,49 @@

OpenCodex

-

Connecting to the OpenCodex proxy…

- +

Starting OpenCodex…

+

+
    +
    diff --git a/desktop/ui/main.js b/desktop/ui/main.js index 85c4ea87326..3f4516bda1a 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -1,34 +1,134 @@ -const params = new URLSearchParams(window.location.search); -const port = Number(params.get("port") || "10100"); -const origin = `http://127.0.0.1:${port}`; -const dashboardUrl = `${origin}/#/usage`; -const status = document.querySelector("#status"); +// The bootstrap page is the startup surface. It does not probe anything itself: the shell owns the +// sequence, its deadline and its diagnostic, and this page renders what it is told. The phase list +// is asked for rather than written here, so a state added in the shell appears without a second +// edit — and one removed cannot leave a row behind. +// +// What each row shows comes from the shell too, including the states already finished. Rebuilding +// that history from events would be wrong: the first states finish in milliseconds, so a page whose +// listener attached a moment late would show a run in progress with nothing behind it. +// +// Nothing here uses alert, confirm or prompt. The embedded webview implements none of the +// WKUIDelegate panel methods on macOS, so a platform dialog is silently declined and the user sees +// nothing at all. Every message this page has goes into the page — including its own failures, +// because a surface that cannot report is the problem this file exists to fix. + +const bridge = window.__TAURI__; +const invoke = bridge && bridge.core && bridge.core.invoke; +const listen = bridge && bridge.event && bridge.event.listen; + +const headline = document.querySelector("#headline"); +const detail = document.querySelector("#detail"); +const phaseList = document.querySelector("#phases"); +const failure = document.querySelector("#failure"); const retry = document.querySelector("#retry"); -let checking = false; +const copy = document.querySelector("#copy"); +const copyState = document.querySelector("#copyState"); +const diagnostic = document.querySelector("#diagnostic"); -async function check() { - if (checking) return; - checking = true; - status.textContent = `Connecting to OpenCodex proxy at 127.0.0.1:${port}…`; - retry.disabled = true; - try { - const response = await fetch(`${origin}/healthz`, { - cache: "no-store", - }); - if (response.ok) { - status.textContent = "Proxy is ready. Loading dashboard…"; - window.location.replace(dashboardUrl); - return; +const MARKS = { done: "✓", failed: "✕", active: "…", pending: "·" }; + +let phases = []; + +function render(progress) { + const completed = new Set((progress && progress.completed) || []); + const failedPhase = (progress && progress.failedPhase) || null; + const current = progress && progress.phase; + phaseList.replaceChildren(); + for (const phase of phases) { + let state = "pending"; + if (phase.id === failedPhase) { + state = "failed"; + } else if (phase.id === current) { + state = "active"; + } else if (completed.has(phase.id)) { + state = "done"; } - throw new Error(`HTTP ${response.status}`); + const row = document.createElement("li"); + row.dataset.state = state; + const mark = document.createElement("span"); + mark.className = "mark"; + mark.textContent = MARKS[state]; + const label = document.createElement("span"); + label.textContent = phase.label; + row.append(mark, label); + phaseList.append(row); + } +} + +function apply(progress) { + if (!progress) return; + headline.textContent = progress.label; + detail.textContent = progress.detail || ""; + const failed = progress.phase === "failed"; + failure.hidden = !failed; + retry.disabled = !progress.canRetry; + if (failed) { + diagnostic.value = progress.diagnostic || ""; + copyState.textContent = ""; + } + render(progress); +} + +function reportPageFailure(message, error) { + const cause = error && error.message ? error.message : String(error); + headline.textContent = "OpenCodex could not read its own startup state."; + detail.textContent = message; + failure.hidden = false; + retry.disabled = false; + diagnostic.value = [message, cause].join("\n"); +} + +async function copyDiagnostic() { + const text = diagnostic.value; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + copyState.textContent = "Copied to the clipboard."; + return; + } catch { + // A webview without clipboard access is the reason the text is on screen in the first place. + } + diagnostic.focus(); + diagnostic.select(); + let copied = false; + try { + copied = document.execCommand("copy"); } catch { - status.textContent = "The proxy is not reachable yet."; - } finally { - checking = false; - retry.disabled = false; + copied = false; + } + copyState.textContent = copied + ? "Copied to the clipboard." + : "The text above is selected — copy it with your keyboard."; +} + +retry.addEventListener("click", async () => { + if (!invoke) return; + copyState.textContent = ""; + retry.disabled = true; + try { + await invoke("retry_startup"); + } catch (error) { + reportPageFailure("The retry could not be sent to the shell.", error); + } +}); +copy.addEventListener("click", copyDiagnostic); + +async function start() { + if (!invoke || !listen) { + headline.textContent = "This page is the OpenCodex desktop shell's startup surface."; + detail.textContent = "Open it from the OpenCodex app."; + return; + } + try { + phases = (await invoke("startup_phases")).filter((phase) => !phase.terminal); + render(null); + // The listener goes on before the snapshot is read, so a transition landing between the two is + // delivered rather than lost. + await listen("startup-phase", (event) => apply(event.payload)); + apply(await invoke("startup_snapshot")); + } catch (error) { + reportPageFailure("The startup surface could not reach the shell.", error); } } -retry.addEventListener("click", check); -check(); -setInterval(check, 1500); +start(); From 92bddec40ff49b6974ea6f5ada8f94123f6b57e1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:52:45 +0900 Subject: [PATCH 02/24] test(desktop): pin exit ownership, the startup surface and tray availability These are wiring facts, not behaviour a hosted runner can observe: CI builds the shell against a zero-byte sidecar and has no graphical session to press Cmd+Q in, so the ordering and the branches are read out of the source the way the Start at Login default already is. The no-kill scan enumerates the shell's Rust files from disk rather than from a list, so a new module cannot opt itself out. Every assertion was driven red once against the shape it replaces: tray Quit calling app.exit, a kill in the shell, the predefined macOS Quit, any endpoint error read as a stopped runtime, a spawn that ignores an exit in flight, _events discarded, the window shown after the sequence, resolve back in setup(), a probe bounded only by the client timeout, a page failure that cannot report itself, the weaker watcher question, a Linux tray assumed before the probe, and a migration marker claimed before its rewrite succeeded. --- scripts/test-layout/layout.json | 3 + tests/clients/desktop-exit-ownership.test.ts | 146 ++++++++++++++ .../desktop-start-at-login-default.test.ts | 24 ++- tests/clients/desktop-startup-surface.test.ts | 181 ++++++++++++++++++ .../clients/desktop-tray-availability.test.ts | 82 ++++++++ tests/fixtures/test-layout-expected.json | 3 + 6 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 tests/clients/desktop-exit-ownership.test.ts create mode 100644 tests/clients/desktop-startup-surface.test.ts create mode 100644 tests/clients/desktop-tray-availability.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 543c53a5773..ddf24e2759c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -720,7 +720,10 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts new file mode 100644 index 00000000000..0717a55a53a --- /dev/null +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * INV-DESKTOP-01 — only the tray's Quit ends the app, and nothing ends the runtime by force. + * + * Three gestures used to mean the same thing. Closing the window hid it, but Cmd+Q reached + * `RunEvent::Exit` with no `ExitRequested` handler in between, and that called + * `CommandChild::kill()` — a SIGKILL on Unix — on the runtime this app had started. The CLI's own + * stop restores client configuration, lets in-flight requests finish and clears state files; none + * of that survived a keystroke the user reads as "hide". The updater took the same path. + * + * macOS needs one thing more than the event handler, and it is the part that is easiest to get + * wrong while believing it works: Tauri's default menu carries a predefined Quit wired straight to + * Cocoa's `terminate:`, and the pinned tao raises no cancellable event for it, so `prevent_exit` + * never sees it. The replacement item is therefore part of this contract, not a detail. + * + * The wiring is the contract and it is not visible from behaviour alone — CI builds the shell + * against a zero-byte sidecar and has no session to press Cmd+Q in — so it is read out of the + * source, the way the Start at Login ordering already is. + */ +const SRC = "desktop/src-tauri/src"; +const LIB = repoPath(`${SRC}/lib.rs`); +const EXIT = repoPath(`${SRC}/exit.rs`); +const MENU = repoPath(`${SRC}/menu.rs`); +const TRAY = repoPath(`${SRC}/tray.rs`); +const WINDOW = repoPath(`${SRC}/window.rs`); +const UPDATER = repoPath(`${SRC}/updater.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +/** Every Rust file in the shell, read from disk so a new module cannot opt itself out. */ +function shellSources(): string[] { + return readdirSync(repoPath(SRC)) + .filter((entry) => entry.endsWith(".rs")) + .map((entry) => `${SRC}/${entry}`); +} + +describe("desktop exit ownership", () => { + test("the event loop intercepts the exit request instead of letting it through", () => { + const lib = code(LIB); + expect(lib).toContain("RunEvent::ExitRequested"); + expect(lib).toContain("exit::on_exit_requested(app, code, &api)"); + }); + + test("no file in the shell kills the runtime process", () => { + const sources = shellSources(); + expect(sources.length).toBeGreaterThan(10); + for (const source of sources) { + expect(code(repoPath(source))).not.toContain(".kill()"); + } + }); + + test("the tray's Quit asks the coordinator rather than ending the process itself", () => { + const tray = code(TRAY); + expect(tray).toContain('"quit" => exit::request(app, ExitReason::UserQuit)'); + expect(tray).not.toContain("app.exit("); + }); + + test("the macOS menu's Quit is an ordinary item routed through the gesture path", () => { + const menu = code(MENU); + // The predefined item is the one that cannot be held: it calls Cocoa's terminate: directly. + expect(menu).not.toContain("PredefinedMenuItem::quit"); + expect(menu).toContain('Some("CmdOrCtrl+Q")'); + expect(menu).toContain("crate::exit::gesture(app)"); + // Losing the default menu would take the clipboard items with it, and the failure diagnostic + // is a block of text the user is asked to copy. + for (const item of ["cut", "copy", "paste", "select_all"]) { + expect(menu).toContain(`PredefinedMenuItem::${item}`); + } + const lib = code(LIB); + expect(lib).toContain(".menu(menu::build)"); + expect(lib).toContain("menu::on_event(app, event.id().as_ref())"); + }); + + test("a restart is told apart from a quit and keeps its own reason", () => { + const exit = code(EXIT); + expect(exit).toContain("ExitReason::CoordinatedRestart"); + expect(exit).toContain("tauri::RESTART_EXIT_CODE"); + const updater = code(UPDATER); + expect(updater).toContain("exit::request_restart(app)"); + expect(updater).not.toContain("app.restart()"); + }); + + test("an update restart drains through the same path a quit does", () => { + const exit = code(EXIT); + const restart = exit.indexOf("pub fn request_restart"); + expect(restart).toBeGreaterThan(-1); + const body = exit.slice(restart, exit.indexOf("\n}", restart)); + expect(body).toContain("start_drain(app, ExitReason::CoordinatedRestart)"); + expect(exit).toContain("sidecar::drain(&proxy, owned, &watch)"); + }); + + test("the reason and the drain are claimed in one step", () => { + const exit = code(EXIT); + const claim = exit.indexOf("pub fn claim_drain"); + expect(claim).toBeGreaterThan(-1); + const body = exit.slice(claim, exit.indexOf("\n }", claim)); + expect(body).toContain("if inner.phase != ExitPhase::Idle"); + expect(body).toContain("inner.reason.get_or_insert(fallback)"); + expect(body).toContain("inner.phase = ExitPhase::Draining"); + }); + + test("a runtime is not started once an exit is in flight", () => { + const exit = code(EXIT); + expect(exit).toContain("pub fn spawn_unless_ending"); + expect(code(repoPath(`${SRC}/startup.rs`))).toContain("coordinator.spawn_unless_ending("); + }); + + test("the exit is held until the drain reports", () => { + const exit = code(EXIT); + for (const arm of [ + "ExitDecision::Hide =>", + "ExitDecision::Wait =>", + "ExitDecision::Drain(reason) =>", + "ExitDecision::Proceed =>", + ]) { + expect(exit).toContain(arm); + } + const proceed = exit.indexOf("ExitDecision::Proceed =>"); + expect(exit.slice(proceed, proceed + 40)).not.toContain("prevent_exit"); + expect(exit).toContain("coordinator.finish_drain()"); + }); + + test("closing the window only hides where there is a tray to come back from", () => { + const window = code(WINDOW); + const close = window.indexOf("CloseRequested"); + const branch = window.slice(close, window.indexOf("});", close)); + expect(branch).toContain("if exit::hides_to_tray(app) {"); + expect(branch).toContain("exit::request(app, exit::ExitReason::UserQuit)"); + }); + + test("only an observed exit or a refused connection proves the runtime stopped", () => { + const sidecar = code(repoPath(`${SRC}/sidecar.rs`)); + const start = sidecar.indexOf("async fn gone("); + expect(start).toBeGreaterThan(-1); + const body = sidecar.slice(start, sidecar.indexOf("\n}", start)); + expect(body).toContain("watch.exit().is_some()"); + expect(body).toContain("error.is_unreachable()"); + // Any-error-means-gone is the shape this replaces. + expect(sidecar).not.toContain("proxy.is_alive().await.is_err()"); + }); +}); diff --git a/tests/clients/desktop-start-at-login-default.test.ts b/tests/clients/desktop-start-at-login-default.test.ts index abf3472b79c..f968fd97ae5 100644 --- a/tests/clients/desktop-start-at-login-default.test.ts +++ b/tests/clients/desktop-start-at-login-default.test.ts @@ -12,9 +12,12 @@ import { repoPath } from "../helpers/repo-root"; * the current state, an existing marker returns early, and the tray is built afterwards so its * checkbox reflects the result. Get the write order backwards and a user who turns the setting off * has it turned back on for them on the next launch. + * + * The one-time rewrite that teaches an existing login item to announce itself is the exception, and + * it writes its marker the other way round on purpose — see the comment on it. */ const FIRST_RUN = repoPath("desktop/src-tauri/src/first_run.rs"); -const LIB = repoPath("desktop/src-tauri/src/lib.rs"); +const STARTUP = repoPath("desktop/src-tauri/src/startup.rs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); @@ -52,11 +55,24 @@ describe("start at login default", () => { }); test("it runs before the tray is installed", () => { - const lib = code(LIB); - const applied = lib.indexOf("first_run::apply_start_at_login_default"); - const tray = lib.indexOf("tray::install"); + const startup = code(STARTUP); + const applied = startup.indexOf("first_run::apply_start_at_login_default"); + const tray = startup.indexOf("crate::tray::install"); expect(applied).toBeGreaterThan(-1); expect(tray).toBeGreaterThan(-1); expect(applied).toBeLessThan(tray); }); + + test("the launch-origin rewrite claims its marker only once it has succeeded", () => { + const start = firstRun.indexOf("pub fn adopt_launch_origin_argument"); + expect(start).toBeGreaterThan(-1); + const body = firstRun.slice(start); + const enable = body.indexOf("autolaunch().enable()"); + const claim = body.indexOf("fs::write(&claimed"); + expect(enable).toBeGreaterThan(-1); + expect(claim).toBeGreaterThan(enable); + // It never turns the setting on or off; it only rewrites an entry that is already there. + expect(body).toContain("Ok(true) =>"); + expect(body).not.toContain("autolaunch().disable()"); + }); }); diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts new file mode 100644 index 00000000000..a49b3685b6d --- /dev/null +++ b/tests/clients/desktop-startup-surface.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The startup surface exists before the work it reports on. + * + * Discovery, the liveness probe, the sidecar spawn and the health wait all used to run inside + * `setup()`, and the window was created hidden afterwards. Every failure in that stretch was + * therefore invisible: the spawn event stream was destructured into `_events` and dropped, so the + * child's exit code went with it, and a run of probes that time out rather than refuse takes over a + * minute with nothing on screen. Ordering is the whole of the fix, and a state that reports work + * already finished elsewhere is not a state — it is a label. Both are read out of the source. + */ +const SRC = "desktop/src-tauri/src"; +const LIB = repoPath(`${SRC}/lib.rs`); +const SIDECAR = repoPath(`${SRC}/sidecar.rs`); +const STARTUP = repoPath(`${SRC}/startup.rs`); +const PROXY = repoPath(`${SRC}/proxy.rs`); +const PAGE = repoPath("desktop/ui/main.js"); +const CONFIG = repoPath("desktop/src-tauri/tauri.conf.json"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop startup surface", () => { + const lib = code(LIB); + const startup = code(STARTUP); + + test("the window is built and shown before the sequence that reports into it", () => { + const setup = lib.indexOf(".setup(|app|"); + expect(setup).toBeGreaterThan(-1); + const built = lib.indexOf("WebviewWindowBuilder::new", setup); + const shown = lib.indexOf("window::show(&window)", setup); + const begun = lib.indexOf("startup::begin(app.handle())", setup); + expect(built).toBeGreaterThan(-1); + expect(shown).toBeGreaterThan(built); + expect(begun).toBeGreaterThan(shown); + }); + + test("setup resolves nothing, registers nothing and starts nothing", () => { + const setup = lib.slice(lib.indexOf(".setup(|app|")); + for (const call of [ + "block_on", + "ensure_proxy", + "discovery::current()", + "ProxyClient::new", + "tray_availability::detect()", + "tray::install", + "first_run::", + ]) { + expect(setup).not.toContain(call); + } + }); + + test("resolving and registering are states that own their work", () => { + expect(startup).toContain("Phase::Resolving"); + expect(startup).toContain("discovery::current()"); + expect(startup).toContain("ProxyClient::new(endpoint"); + expect(startup).toContain("Phase::Registering"); + expect(startup).toContain("tray_availability::detect"); + expect(startup).toContain("first_run::apply_start_at_login_default(app)"); + expect(startup).toContain("crate::tray::install(&handle)"); + }); + + test("the app's own surface is registered before the runtime is touched", () => { + const registering = startup.indexOf("Phase::Registering, None)"); + const resolving = startup.indexOf("Phase::Resolving, None)"); + const starting = startup.indexOf("Phase::Starting, None)"); + expect(registering).toBeGreaterThan(-1); + expect(resolving).toBeGreaterThan(registering); + expect(starting).toBeGreaterThan(resolving); + }); + + test("the spawn event stream is consumed rather than discarded", () => { + const sidecar = code(SIDECAR); + expect(sidecar).not.toContain("_events"); + expect(sidecar).toContain("let (events, child) = command.spawn()"); + expect(sidecar).toContain("watch.follow(events)"); + expect(sidecar).toContain("CommandEvent::Terminated(payload)"); + }); + + test("the child's exit code is what ends the wait early", () => { + const wait = startup.indexOf("Phase::Waiting, None);"); + expect(wait).toBeGreaterThan(-1); + const loop = startup.slice(wait, startup.indexOf("async fn healthy_by", wait)); + expect(loop).toContain("watch.exit()"); + expect(loop).toContain("exit.describe()"); + }); + + test("one deadline covers the whole sequence and bounds every probe under it", () => { + expect(startup).toContain("pub const DEADLINE: Duration"); + expect(startup).toContain("let deadline = started + DEADLINE;"); + expect(startup).toContain("(started + ATTACH_BUDGET).min(deadline)"); + // A probe bounded only by the client's own timeout overruns whatever budget it was started + // under, which is how a stated ceiling becomes an unstated one. + expect(startup).not.toContain("proxy.is_alive()"); + expect(startup).toContain("proxy.alive_within(deadline)"); + const proxy = code(PROXY); + expect(proxy).toContain("timeout_at(deadline, self.is_alive())"); + expect(proxy).toContain("timeout_at(deadline, self.stop())"); + }); + + test("a retry waits on the child it already started rather than starting a second one", () => { + expect(startup).toContain("&& watch.exit().is_none()"); + const guard = startup.indexOf("if owns_live_child {"); + const spawn = startup.indexOf("sidecar::start(app, endpoint, watch)"); + expect(guard).toBeGreaterThan(-1); + expect(spawn).toBeGreaterThan(guard); + }); + + test("the diagnostic names the state, the endpoint, the home and how the child ended", () => { + const start = startup.indexOf("pub fn diagnostic("); + expect(start).toBeGreaterThan(-1); + const body = startup.slice(start, startup.indexOf("fn report(", start)); + for (const field of [ + "state:", + "reason:", + "elapsed:", + "endpoint:", + "home:", + "runtime process:", + "runtime output", + ]) { + expect(body).toContain(field); + } + expect(body).toContain("exit.describe()"); + }); + + test("the snapshot carries the finished states, not just the current one", () => { + expect(startup).toContain("pub completed: Vec<&'static str>"); + expect(startup).toContain("pub failed_phase: Option<&'static str>"); + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain("progress.completed"); + expect(page).toContain("progress.failedPhase"); + }); + + test("the retry, the snapshot and the phase list are reachable from the page", () => { + const handler = lib.slice( + lib.indexOf("generate_handler!["), + lib.indexOf("])", lib.indexOf("generate_handler![")), + ); + for (const command of ["startup_snapshot", "startup_phases", "retry_startup"]) { + expect(lib).toContain(`fn ${command}(`); + expect(handler).toContain(command); + } + expect(JSON.parse(readFileSync(CONFIG, "utf8")).app.withGlobalTauri).toBe(true); + }); + + test("the page derives its phases instead of restating them", () => { + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain('invoke("startup_phases")'); + expect(page).toContain('invoke("startup_snapshot")'); + expect(page).toContain('invoke("retry_startup")'); + expect(page).toContain('listen("startup-phase"'); + for (const phase of ["resolving", "probing", "attaching", "starting", "waiting", "registering"]) { + expect(page).not.toContain(`"${phase}"`); + } + }); + + test("every call into the shell can fail without leaving the page blank", () => { + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain("function reportPageFailure"); + // Both entry points — the first load and the retry — have to catch, because either one + // failing silently leaves a window that says "Starting…" forever. + expect(page.match(/reportPageFailure\(/g) || []).toHaveLength(3); + const retry = page.slice(page.indexOf('retry.addEventListener')); + expect(retry.slice(0, 400)).toContain("catch"); + }); + + test("the page never reaches for a dialog the webview cannot draw", () => { + const page = readFileSync(PAGE, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, ""); + // The call form, not the word: a method on a receiver or a property of that name is fine. + expect(page).not.toMatch(/(^|[^.\w$])(?:window\s*\.\s*)?(?:alert|confirm|prompt)\s*\(/); + expect(page).toContain("#diagnostic"); + expect(page).toContain("clipboard.writeText"); + }); +}); diff --git a/tests/clients/desktop-tray-availability.test.ts b/tests/clients/desktop-tray-availability.test.ts new file mode 100644 index 00000000000..4f1b5392048 --- /dev/null +++ b/tests/clients/desktop-tray-availability.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * INV-DESKTOP-02 — tray availability is an answer from the session, and where there is none the + * window is shown and closing it quits through the same graceful drain. + * + * The pinned Linux backend creates an AppIndicator and reports success without checking that + * anything will display it, so `TrayIconBuilder::build` returning `Ok` proves nothing. Nor does the + * watcher merely existing: a StatusNotifierWatcher with no host attached still accepts + * registrations and still draws nothing, which is why the question asked is the specification's own + * — is a host registered. On stock GNOME the answer is no, and the shell's macOS-shaped assumptions + * (a window created hidden, a close that always hides) then left a running process with no way back + * in. The probe and the branches it feeds are read out of the source because a hosted Linux runner + * has no graphical session to observe them in. + */ +const SRC = "desktop/src-tauri/src"; +const AVAILABILITY = repoPath(`${SRC}/tray_availability.rs`); +const LIB = repoPath(`${SRC}/lib.rs`); +const EXIT = repoPath(`${SRC}/exit.rs`); +const STARTUP = repoPath(`${SRC}/startup.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop tray availability", () => { + const availability = code(AVAILABILITY); + const startup = code(STARTUP); + + test("the probe asks whether a host is registered, not whether a watcher exists", () => { + expect(availability).toContain('"org.kde.StatusNotifierWatcher"'); + expect(availability).toContain('"IsStatusNotifierHostRegistered"'); + expect(availability).toContain("Connection::new_session()"); + expect(availability).toContain('#[cfg(target_os = "linux")]'); + // A name that merely has an owner is the weaker question this replaces. + expect(availability).not.toContain("NameHasOwner"); + }); + + test("an unanswerable probe is read the same way as a watcher with no host", () => { + const start = availability.indexOf("pub fn from_host_registered"); + expect(start).toBeGreaterThan(-1); + const body = availability.slice(start, availability.indexOf("\n}", start)); + expect(body).toContain("Some(true) => TrayAvailability::Available"); + expect(body).toContain("Some(false) | None => TrayAvailability::Unavailable"); + }); + + test("nothing is assumed on the platform that needs the probe", () => { + const start = availability.indexOf("pub fn assumed()"); + expect(start).toBeGreaterThan(-1); + const body = availability.slice(start, availability.indexOf("\n }", start)); + expect(body).toContain('cfg!(target_os = "linux")'); + expect(body).toContain("Self::Unavailable"); + // The window can be closed before the probe answers, so the coordinator starts from the + // platform assumption rather than from optimism. + expect(code(EXIT)).toContain("TrayAvailability::assumed().hides_to_tray()"); + }); + + test("the verdict reaches the coordinator that decides what a close means", () => { + expect(startup).toContain("coordinator.set_tray(tray)"); + expect(code(EXIT)).toContain("pub fn set_tray(&self, tray: TrayAvailability)"); + expect(code(EXIT)).toContain("pub fn hides_to_tray(app: &AppHandle) -> bool"); + }); + + test("no tray icon is claimed where none can be drawn", () => { + const install = startup.indexOf("crate::tray::install(&handle)"); + expect(install).toBeGreaterThan(-1); + expect(startup.slice(0, install)).toContain("if tray.is_available() {"); + }); + + test("tray availability decides the launch, not the origin of the launch", () => { + const start = startup.indexOf("pub fn shows_window"); + expect(start).toBeGreaterThan(-1); + const body = startup.slice(start, startup.indexOf("\n}", start)); + expect(body).toContain("!tray.is_available() || origin == LaunchOrigin::User"); + // The window is shown from inside the sequence, once the verdict is in, so a login launch on a + // session with no tray is not left hidden with nothing to reopen it from. + expect(startup).toContain("if shows_window(LaunchOrigin::detect(), tray)"); + expect(code(LIB)).toContain("startup::LaunchOrigin::detect() == startup::LaunchOrigin::User"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a257ad0551d..ccbaaf03384 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -551,7 +551,10 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", From f434e3328e367225a525b22d6a1689eab4737d98 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 08:52:46 +0900 Subject: [PATCH 03/24] docs(structure): record the desktop shell's exit, startup and tray contracts INV-DESKTOP-01 and INV-DESKTOP-02 bind the two rules that are easy to regress silently: what is allowed to end the app, and what counts as a tray. Both state the no-tray exception rather than claiming a uniform rule, and the shell document says plainly that an incomplete drain still exits and can leave the runtime standing. --- structure/desktop-shell.md | 61 ++++++++++++++++++++++++++++++++++---- structure/overview.md | 16 ++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index cb5768a0c92..14d550d08cc 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -5,11 +5,62 @@ discovers the loopback proxy, lazily retries management authentication, starts the bundled `ocx` sidecar only when the configured endpoint is unreachable, and owns the tray, autostart, single-instance, and window lifecycle behavior. -`desktop/ui/` is only a short bootstrap page. Once `/healthz` answers, the shell -navigates the webview to the proxy's loopback dashboard -(`/#/usage`) rather than bundling or serving `gui/dist` itself. -Only the bootstrap page has Tauri IPC capability; the loopback dashboard never -does because `dangerousRemoteDomainIpcAccess` is not configured. +`desktop/ui/` is the startup surface. Once the runtime reports healthy the shell navigates the +webview to the proxy's loopback dashboard (`/#/usage`) rather than bundling or serving `gui/dist` +itself. The page renders what the shell tells it and probes nothing on its own; it asks +`startup_phases` for the state list rather than restating it, takes the current state from +`startup_snapshot` on load because the first states finish in milliseconds, and then follows the +`startup-phase` event. It uses no `alert`, `confirm` or `prompt`: the embedded webview implements +none of the matching WKUIDelegate panel methods on macOS, so a platform dialog is declined without +drawing anything. +`withGlobalTauri` is on so that page can invoke without a bundler. Only the local app origin +carries a capability, so the loopback dashboard reaches no command: `capabilities/default.json` +declares no `remote` entry, and Tauri checks the ACL for any invoke from a non-local origin. + +## Startup, quit and the tray + +The window is created and shown before anything is registered, resolved, probed or started, and +`desktop/src-tauri/src/startup.rs` runs the whole sequence inside it as named states — +registering, resolving, probing, attaching or starting, waiting, then ready or failed — under one +30-second deadline. Every probe beneath that deadline is bounded by the time left rather than by the +HTTP client's own timeout, so the ceiling is the ceiling. The failure state carries a retry, the +child's exit code and a copyable diagnostic naming the state, the endpoint, the configuration home +and the runtime's last output; `desktop/src-tauri/src/sidecar.rs` consumes the spawn event stream +into that record instead of discarding it, which is what makes an immediate sidecar exit +distinguishable from a slow start. The page asks for the state list and the run's progress rather +than reconstructing either, because the early states finish faster than a listener can attach. + +Registering runs first, before the runtime is touched. A login launch starts hidden, so a tray +installed only after a successful start would leave a failed start with no window and no icon. The +login item is registered in that state too, before the tray, so its Start at Login checkbox reads +the state first run leaves behind. A launch carrying the `--autostart` argument that the login +item passes back is the only one that starts hidden, and only where there is a tray to hide in. + +`desktop/src-tauri/src/exit.rs` owns what ends the process. Where there is a usable tray, closing +the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an +installed update asks for a coordinated restart. Where there is no usable tray, closing the window +is the quit. macOS needs one thing beyond the event loop: Tauri's default menu carries a predefined +Quit wired to Cocoa's `terminate:` and the pinned tao raises no cancellable event for it, so +`desktop/src-tauri/src/menu.rs` rebuilds that menu with an ordinary item on the same accelerator. + +Every ending drains first. The app-owned runtime is asked to stop, and it counts as gone only when +the child reports its own exit or the endpoint refuses a connection — a timeout or an unauthorized +reply is not proof. Nothing kills the child; the CLI's stop restores client configuration and lets +in-flight requests finish. A drain that has not completed within `DRAIN_DEADLINE` is reported and +the exit still proceeds, which can leave the runtime running: refusing to quit when the user asked +is the worse answer, and a standing runtime is recoverable with `ocx stop`. A runtime this app did +not start is never stopped. A quit that arrives while the sequence is starting one is held: the +spawn and the record of ownership happen under the same lock the drain takes. + +`desktop/src-tauri/src/tray_availability.rs` asks the session bus whether +`org.kde.StatusNotifierWatcher` reports a host registered; macOS and Windows answer yes without a +probe. Neither construction success nor the watcher's mere existence is the question — the pinned +Linux backend creates an AppIndicator and reports success with no host attached, and a watcher with +no host accepts registrations and draws nothing. Until the probe answers, Linux assumes no tray, so +a window closed in the first moments quits rather than vanishing. Where the answer is no, no tray +icon is claimed, the window is shown on launch whatever the launch origin, and closing it quits +through the same drain. The update controls live in the tray menu, so a session without one checks +for updates in the background and has no place to install them from. `desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, before the tray is built so its checkbox reads the resulting state. A menu bar app diff --git a/structure/overview.md b/structure/overview.md index 55b6f89b21c..da30d93bef0 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -170,6 +170,22 @@ still cover the rule, which is a judgement only review makes. pages are compared against their English source, so a changed default fails a check instead of leaving two documents to disagree; see [`chat-compat.md`](providers/chat-compat.md). Enforced by `tests/ci-workflows/docs-developer-role-policy.test.ts`. +- **INV-DESKTOP-01** — Where the desktop app has a usable tray, only the tray's Quit ends it: + closing the window and the platform's quit gesture hide, which on macOS needs the default menu's + predefined Quit replaced because it raises no cancellable event. Every ending drains first — the + tray's Quit, an update's coordinated restart, and a window close on a session with no tray all + hold the exit, stop the app-owned runtime through the management stop, and treat only an observed + child exit or a refused connection as proof it stopped. No shell file kills the child, a runtime + this app did not start is never stopped, and a runtime started while an exit is in flight is not + started at all; see [`desktop-shell.md`](desktop-shell.md). + Enforced by `tests/clients/desktop-exit-ownership.test.ts`. +- **INV-DESKTOP-02** — Tray availability is an answer from the session, not the tray backend's + construction result and not the watcher's mere existence: the shell asks whether + `org.kde.StatusNotifierWatcher` reports a host registered, and reads an unanswerable probe the + same way as an unregistered one. Linux assumes no tray until the probe answers. Where there is + none, no icon is claimed, the window is shown on launch whatever the launch origin, and closing it + quits through the same drain; see [`desktop-shell.md`](desktop-shell.md). + Enforced by `tests/clients/desktop-tray-availability.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage From 4a63dc6179bf1ff8039215078be715bc6f325817 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:33:01 +0900 Subject: [PATCH 04/24] fix(desktop): keep the macOS-only menu out of every other platform's build Hosted CI rejected the first head: the menu module compiled everywhere while its only caller was macOS-gated, so gesture, QUIT_ID and on_event were dead code on Linux and clippy -D warnings refused them. The module is now macOS-only, and the window's close handler routes through exit::gesture instead of repeating the decision, which gives that function a caller on every platform and leaves one place where a close and a quit gesture are decided. Four more things a review round found, none of them visible from behaviour: The tray verdict was published before the icon existed and was never downgraded when the build failed, so a close in that window hid into nothing. It is now published only after a successful install, and a failed install is a session with no tray. Registering ran again on every retry, which would have built a second tray icon with its own refresh loop and its own menu handlers - the app appearing to duplicate itself each time the user pressed Retry. It now happens once per process and a retry re-runs only the runtime half. The exit coordinator held its lock across process creation, which put spawning in front of the main thread's exit handler; a wedged spawn would have been a Quit that never answered. The spawn is reserved instead, and a quit arriving in between is deferred until the child is owned and then drains it. Every tray menu setter dispatches to the main thread and waits, and the tray is built on the main thread holding the menu mutex, so calling a setter under that lock is a cycle. The handles are copied out from under it first. Registration's session-bus probe and its main-thread callback are also bounded by the sequence deadline now, so neither can strand the page in a state whose retry could do nothing. --- desktop/src-tauri/src/exit.rs | 123 ++++++++++++++++++++------ desktop/src-tauri/src/lib.rs | 3 + desktop/src-tauri/src/menu.rs | 12 --- desktop/src-tauri/src/startup.rs | 147 +++++++++++++++++++++++-------- desktop/src-tauri/src/tray.rs | 67 +++++++------- desktop/src-tauri/src/window.rs | 7 +- 6 files changed, 244 insertions(+), 115 deletions(-) diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs index c7dbe51a50e..bbd1f1b4b9b 100644 --- a/desktop/src-tauri/src/exit.rs +++ b/desktop/src-tauri/src/exit.rs @@ -38,6 +38,9 @@ pub enum ExitReason { pub enum ExitPhase { /// Nothing is in flight. Idle, + /// A runtime is being started. An exit arriving now is held until the child exists and is + /// recorded, because the alternative is a process nobody owns and nobody will stop. + Spawning, /// The drain is running. Further exit requests wait for it rather than starting a second one. Draining, /// The drain has reported. The next exit request is the real one. @@ -64,7 +67,7 @@ pub enum ExitDecision { /// a quit and takes the same graceful drain rather than leaving a running process unreachable. pub fn decide(phase: ExitPhase, reason: Option, hides_to_tray: bool) -> ExitDecision { match phase { - ExitPhase::Draining => ExitDecision::Wait, + ExitPhase::Spawning | ExitPhase::Draining => ExitDecision::Wait, ExitPhase::Drained => ExitDecision::Proceed, ExitPhase::Idle => match reason { Some(reason) => ExitDecision::Drain(reason), @@ -78,6 +81,8 @@ struct Inner { phase: ExitPhase, reason: Option, hides_to_tray: bool, + /// An exit that arrived while a runtime was being started, and still has to happen. + deferred: bool, } /// The exit sequence's state, managed by the app. @@ -94,6 +99,7 @@ impl ExitCoordinator { // Until the probe answers, assume only what the platform guarantees. Assuming a // tray that turns out not to exist is the exact failure D6 is about. hides_to_tray: TrayAvailability::assumed().hides_to_tray(), + deferred: false, }), } } @@ -107,15 +113,16 @@ impl ExitCoordinator { self.inner().hides_to_tray = tray.hides_to_tray(); } - pub fn hides_to_tray(&self) -> bool { - self.inner().hides_to_tray - } - #[cfg(test)] fn phase(&self) -> ExitPhase { self.inner().phase } + #[cfg(test)] + fn hides_to_tray(&self) -> bool { + self.inner().hides_to_tray + } + pub fn decision(&self) -> ExitDecision { let inner = self.inner(); decide(inner.phase, inner.reason, inner.hides_to_tray) @@ -133,32 +140,60 @@ impl ExitCoordinator { /// that claimed first could still be overtaken by an update that started the drain, and the app /// would restart under a user who asked it to stop. `fallback` is only used when nothing has /// claimed a reason yet. + /// + /// While a runtime is being started the answer is "not yet": the reason is recorded and the + /// drain is handed to [`ExitCoordinator::finish_spawn`], which runs once the child is ours. pub fn claim_drain(&self, fallback: ExitReason) -> Option { let mut inner = self.inner(); - if inner.phase != ExitPhase::Idle { - return None; + match inner.phase { + ExitPhase::Idle => { + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + ExitPhase::Spawning => { + inner.reason.get_or_insert(fallback); + inner.deferred = true; + None + } + ExitPhase::Draining | ExitPhase::Drained => None, } - let reason = *inner.reason.get_or_insert(fallback); - inner.phase = ExitPhase::Draining; - Some(reason) } pub fn finish_drain(&self) { self.inner().phase = ExitPhase::Drained; } - /// Start a runtime, but only while no exit is in flight. + /// Reserve the right to start a runtime. False once an exit is in flight. /// - /// The lock is held across the whole closure so that spawning a child and recording that we own - /// it cannot be split by a quit. Without that, a Quit arriving mid-startup reads "we own - /// nothing", drains nothing, and the process exits moments after the sequence spawned a proxy - /// that nothing will ever stop. The closure must not call back into this coordinator. - pub fn spawn_unless_ending(&self, spawn: impl FnOnce() -> T) -> Option { - let inner = self.inner(); + /// The reservation exists instead of holding the lock across the spawn. Holding it would make + /// the main thread's exit handler wait on process creation, so a wedged spawn would be a Quit + /// that never responds. Reserving instead keeps every lock hold short, and an exit arriving in + /// between is deferred rather than lost — which is the thing that must not happen, because a + /// quit that reads "we own nothing" leaves the child it just missed running forever. + pub fn begin_spawn(&self) -> bool { + let mut inner = self.inner(); if inner.phase != ExitPhase::Idle { + return false; + } + inner.phase = ExitPhase::Spawning; + true + } + + /// Release the reservation. Returns the reason to drain for when an exit arrived meanwhile. + pub fn finish_spawn(&self) -> Option { + let mut inner = self.inner(); + if inner.phase != ExitPhase::Spawning { return None; } - Some(spawn()) + if inner.deferred { + let reason = *inner.reason.get_or_insert(ExitReason::UserQuit); + inner.phase = ExitPhase::Draining; + inner.deferred = false; + return Some(reason); + } + inner.phase = ExitPhase::Idle; + None } } @@ -168,17 +203,11 @@ impl Default for ExitCoordinator { } } -/// Whether the current session hides to a tray rather than ending on a close. -pub fn hides_to_tray(app: &AppHandle) -> bool { - app.try_state::() - .map(|coordinator| coordinator.hides_to_tray()) - .unwrap_or_else(|| TrayAvailability::assumed().hides_to_tray()) -} - /// The platform's quit gesture, and what closing the window means. /// /// It is not a request to end. D2 makes it mean the same thing on all three platforms: hide where -/// there is a tray to come back from, and a graceful quit where there is not. +/// there is a tray to come back from, and a graceful quit where there is not. Closing the window +/// arrives here too: one decision point, so the two gestures cannot drift apart. pub fn gesture(app: &AppHandle) { let Some(coordinator) = app.try_state::() else { return; @@ -256,6 +285,14 @@ pub fn start_drain(app: &AppHandle, reason: ExitReason) { let Some(reason) = coordinator.claim_drain(reason) else { return; }; + drain_now(app, reason); +} + +/// Run the drain for a reason the coordinator has already been moved to draining for. +/// +/// The only other caller is the startup sequence, which reaches draining through +/// [`ExitCoordinator::finish_spawn`] when a quit arrived while it was starting a runtime. +pub fn drain_now(app: &AppHandle, reason: ExitReason) { let app = app.clone(); tauri::async_runtime::spawn(async move { let owned = app @@ -368,9 +405,39 @@ mod tests { #[test] fn a_runtime_is_not_started_once_an_exit_is_in_flight() { let coordinator = ExitCoordinator::new(); - assert_eq!(coordinator.spawn_unless_ending(|| 7), Some(7)); + assert!(coordinator.begin_spawn()); + assert_eq!(coordinator.finish_spawn(), None); + assert_eq!(coordinator.phase(), ExitPhase::Idle); coordinator.claim_drain(ExitReason::UserQuit); - assert_eq!(coordinator.spawn_unless_ending(|| 7), None); + assert!(!coordinator.begin_spawn()); + } + + #[test] + fn a_quit_during_a_spawn_is_deferred_rather_than_lost() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_spawn()); + // The exit handler holds the exit rather than letting the process end mid-spawn. + assert_eq!(coordinator.decision(), ExitDecision::Wait); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + assert_eq!(coordinator.phase(), ExitPhase::Spawning); + // The child is ours by now, so the deferred quit becomes the drain. + assert_eq!(coordinator.finish_spawn(), Some(ExitReason::UserQuit)); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + assert_eq!(coordinator.finish_spawn(), None); + } + + #[test] + fn a_deferred_update_restart_keeps_its_own_reason() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_spawn()); + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + None + ); + assert_eq!( + coordinator.finish_spawn(), + Some(ExitReason::CoordinatedRestart) + ); } #[test] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bbb85ba6c40..a781e59854b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,9 @@ mod exit; mod first_run; mod formatting; mod logging; +// macOS only: it exists to replace one item in a menu no other platform installs. Compiling it +// elsewhere would leave its contents unreachable, which -D warnings rejects. +#[cfg(target_os = "macos")] mod menu; mod proxy; mod sidecar; diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs index 5a50b071a44..58bc4346613 100644 --- a/desktop/src-tauri/src/menu.rs +++ b/desktop/src-tauri/src/menu.rs @@ -18,7 +18,6 @@ /// it is unambiguously this one. pub const QUIT_ID: &str = "app-menu-quit"; -#[cfg(target_os = "macos")] pub fn build(app: &tauri::AppHandle) -> tauri::Result> { use tauri::menu::{ AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, @@ -119,14 +118,3 @@ pub fn on_event(app: &tauri::AppHandle, id: &str) { crate::exit::gesture(app); } } - -#[cfg(test)] -mod tests { - use super::QUIT_ID; - - #[test] - fn the_replacement_quit_has_an_id_of_its_own() { - assert!(!QUIT_ID.is_empty()); - assert_ne!(QUIT_ID, "quit"); - } -} diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 7e1e7829624..6271afc51b4 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -224,6 +224,8 @@ struct Live { pub struct Startup { live: Mutex, running: AtomicBool, + /// The outcome of the one-time registration, once it has happened. + registered: Mutex>, } impl Startup { @@ -234,6 +236,7 @@ impl Startup { reported: Vec::new(), }), running: AtomicBool::new(false), + registered: Mutex::new(None), } } @@ -241,6 +244,20 @@ impl Startup { self.live.lock().unwrap_or_else(PoisonError::into_inner) } + fn registration(&self) -> Option { + *self + .registered + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + fn remember_registration(&self, login: StartAtLogin) { + *self + .registered + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(login); + } + /// The whole state of the run so far, which is what the page asks for when it loads. pub fn latest(&self) -> Progress { self.live().latest.clone() @@ -306,7 +323,7 @@ async fn run(app: &AppHandle) { return; }; report(app, started, Phase::Registering, None); - let login = register(app).await; + let login = register(app, deadline).await; report( app, started, @@ -437,63 +454,116 @@ async fn run(app: &AppHandle) { } /// Establish the app's own surface: the tray verdict, the tray, and the login item. -async fn register(app: &AppHandle) -> StartAtLogin { - // The probe blocks on a session-bus round trip, so it does not belong on an async worker. - let tray = tauri::async_runtime::spawn_blocking(tray_availability::detect) - .await - .unwrap_or_else(|_| TrayAvailability::assumed()); - if let Some(coordinator) = app.try_state::() { - coordinator.set_tray(tray); +/// +/// It happens once per process. A retry re-runs the runtime half of the sequence, and running this +/// half again would build a second tray icon with its own refresh loop and its own menu handlers — +/// the failure would look like the app duplicating itself every time the user pressed Retry. +async fn register(app: &AppHandle, deadline: Instant) -> StartAtLogin { + if let Some(done) = app + .try_state::() + .and_then(|startup| startup.registration()) + { + return done; } + // The probe blocks on a session-bus round trip, so it does not belong on an async worker — and + // it is bounded by the sequence's own deadline, because a bus that never answers would + // otherwise leave the page in this state with a retry that could do nothing about it. + let tray = match tokio::time::timeout_at( + deadline, + tauri::async_runtime::spawn_blocking(tray_availability::detect), + ) + .await + { + Ok(Ok(tray)) => tray, + _ => TrayAvailability::assumed(), + }; + // Before the tray, so its Start at Login checkbox reads the state this leaves behind rather // than the state from before first run. let login = first_run::apply_start_at_login_default(app); first_run::adopt_launch_origin_argument(app); - if tray.is_available() { - // Tray construction is a GTK call on Linux and must happen on the main thread. - let handle = app.clone(); - let (sender, receiver) = tokio::sync::oneshot::channel(); - if app - .run_on_main_thread(move || { - let _ = - sender.send(crate::tray::install(&handle).map_err(|error| error.to_string())); - }) - .is_ok() - { - if let Ok(Err(error)) = receiver.await { - crate::logging::log_once("the tray could not be installed", &error); - } - } + // The verdict is published only once an icon actually exists. Announcing a tray and then + // failing to install it would hide the window into nothing, which is the exact stranding D6 + // exists to prevent. + let verdict = if tray.is_available() && install_tray(app, deadline).await { + TrayAvailability::Available + } else { + TrayAvailability::Unavailable + }; + if let Some(coordinator) = app.try_state::() { + coordinator.set_tray(verdict); } if let Some(window) = app.get_webview_window("main") { - if shows_window(LaunchOrigin::detect(), tray) { + if shows_window(LaunchOrigin::detect(), verdict) { crate::window::show(&window); } } + if let Some(startup) = app.try_state::() { + startup.remember_registration(login); + } login } +/// Build the tray on the main thread, which is where GTK requires it on Linux. +async fn install_tray(app: &AppHandle, deadline: Instant) -> bool { + let handle = app.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + let _ = sender.send(crate::tray::install(&handle).map_err(|error| error.to_string())); + }) + .is_err() + { + return false; + } + match tokio::time::timeout_at(deadline, receiver).await { + Ok(Ok(Ok(()))) => true, + Ok(Ok(Err(error))) => { + crate::logging::log_once("the tray could not be installed", &error); + false + } + _ => { + crate::logging::log_once( + "the tray could not be installed", + "the main thread did not answer", + ); + false + } + } +} /// Start the runtime, unless an exit is already in flight. /// -/// The spawn and the record that we own the child happen under the exit coordinator's lock, so a -/// quit arriving mid-startup cannot observe "we own nothing", drain nothing, and let the process -/// end moments after this spawned a proxy that nothing will ever stop. +/// The coordinator reserves the spawn rather than holding its lock across it: holding it would put +/// process creation in front of the main thread's exit handler, so a wedged spawn would be a Quit +/// that never answers. A quit arriving in between is deferred until the child is ours and then +/// drains it, so it cannot observe "we own nothing" and leave a proxy running that nothing stops. fn spawn_runtime( app: &AppHandle, endpoint: ProxyEndpoint, watch: &SidecarWatch, ) -> Option> { let coordinator = app.try_state::()?; - coordinator.spawn_unless_ending(|| { - let child = sidecar::start(app, endpoint, watch)?; - if let Some(state) = app.try_state::() { - state.adopt(child); + if !coordinator.begin_spawn() { + return None; + } + let outcome = match sidecar::start(app, endpoint, watch) { + Ok(child) => { + if let Some(state) = app.try_state::() { + state.adopt(child); + } + Ok(()) } - Ok(()) - }) + Err(error) => Err(error), + }; + if let Some(reason) = coordinator.finish_spawn() { + // A quit landed while the child was being created. It is ours now, so it gets drained. + crate::exit::drain_now(app, reason); + return None; + } + Some(outcome) } async fn healthy_by(proxy: &ProxyClient, deadline: Instant) -> bool { @@ -614,8 +684,11 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { - use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES}; + use super::{ + shows_window, LaunchOrigin, Phase, ATTACH_BUDGET, AUTOSTART_FLAG, DEADLINE, PHASES, + }; use crate::tray_availability::TrayAvailability; + use tokio::time::Duration; #[test] fn only_the_autostart_argument_marks_a_login_launch() { @@ -680,6 +753,10 @@ mod tests { #[test] fn the_whole_sequence_is_bounded_well_under_the_minute_it_used_to_take() { - assert!(DEADLINE.as_secs() <= 45); + let budgets = [DEADLINE, ATTACH_BUDGET]; + assert!(budgets + .iter() + .all(|budget| *budget <= Duration::from_secs(45))); + assert!(ATTACH_BUDGET < DEADLINE); } } diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index ac324da31a7..0d8e16b653f 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -22,6 +22,7 @@ pub struct TrayState { pub installing: AtomicBool, } +#[derive(Clone)] pub struct TrayMenu { check_updates: MenuItem, install_update: MenuItem, @@ -232,41 +233,41 @@ fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { widget::refresh(&proxy); } +/// Take a copy of the menu handles, holding the lock only for the copy. +/// +/// Every Tauri menu setter dispatches to the main thread and waits for it. The tray is built *on* +/// the main thread and takes this same mutex while doing so, so calling a setter with the lock held +/// is a cycle: a background update owns the mutex and waits for the main thread, and the main +/// thread waits for the mutex. The app would stop answering Quit. +fn menu_handles(app: &AppHandle) -> Option { + let state = app.try_state::()?; + let handles = state.menu.lock().ok()?; + handles.as_ref().cloned() +} + /// Reflect who owns the runtime in the tray's Stop item. pub fn set_owned(app: &AppHandle, owned: bool) { - if let Some(state) = app.try_state::() { - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu.stop.set_enabled(owned); - } - } + if let Some(menu) = menu_handles(app) { + let _ = menu.stop.set_enabled(owned); } } pub fn show_update_available(app: &AppHandle, version: &str) { - if let Some(state) = app.try_state::() { - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu.install_update.set_text(updater::update_label(version)); - let _ = menu.install_update.set_enabled(true); - let _ = menu.check_updates.set_enabled(true); - let _ = menu.check_updates.set_text("Check for Updates…"); - } - } + if let Some(menu) = menu_handles(app) { + let _ = menu.install_update.set_text(updater::update_label(version)); + let _ = menu.install_update.set_enabled(true); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.check_updates.set_text("Check for Updates…"); } } pub fn show_up_to_date(app: &AppHandle) { - if let Some(state) = app.try_state::() { - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu - .check_updates - .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); - let _ = menu.check_updates.set_enabled(true); - let _ = menu.install_update.set_enabled(false); - } - } + if let Some(menu) = menu_handles(app) { + let _ = menu + .check_updates + .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.install_update.set_enabled(false); } } @@ -278,15 +279,13 @@ pub fn is_installing(app: &AppHandle) -> bool { fn set_installing(app: &AppHandle, version: &str) { if let Some(state) = app.try_state::() { state.installing.store(true, Ordering::Release); - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu - .install_update - .set_text(format!("Installing update v{version}…")); - let _ = menu.install_update.set_enabled(false); - let _ = menu.check_updates.set_enabled(false); - } - } + } + if let Some(menu) = menu_handles(app) { + let _ = menu + .install_update + .set_text(format!("Installing update v{version}…")); + let _ = menu.install_update.set_enabled(false); + let _ = menu.check_updates.set_enabled(false); } } diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index ec31ad482e4..7dc7b07fda8 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -23,12 +23,7 @@ pub fn configure(window: &WebviewWindow) { window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - let app = window_for_close.app_handle(); - if exit::hides_to_tray(app) { - hide(&window_for_close); - } else { - exit::request(app, exit::ExitReason::UserQuit); - } + exit::gesture(window_for_close.app_handle()); } }); } From b3b59c659318b0c874ccefaf020c4300851aa822 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:33:01 +0900 Subject: [PATCH 05/24] test(desktop): follow the shell contracts to where they now live The close handler, the spawn reservation, the tray verdict and the one-time registration all moved, so the oracles move with them. Two assertions were also too weak to bind what they claimed: the no-kill scan now walks the source tree instead of listing its top level, and the setup-does-nothing check is bounded to the setup closure rather than running to end of file. Each literal and each ordering these files assert was checked against the current source by reading it, not by running them. --- tests/clients/desktop-exit-ownership.test.ts | 86 +++++++++++++++---- tests/clients/desktop-startup-surface.test.ts | 11 ++- .../clients/desktop-tray-availability.test.ts | 26 ++++-- 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index 0717a55a53a..856ec305618 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -32,11 +32,13 @@ function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); } -/** Every Rust file in the shell, read from disk so a new module cannot opt itself out. */ -function shellSources(): string[] { - return readdirSync(repoPath(SRC)) - .filter((entry) => entry.endsWith(".rs")) - .map((entry) => `${SRC}/${entry}`); +/** Every Rust file in the shell, walked from disk so a new module cannot opt itself out. */ +function shellSources(directory: string = SRC): string[] { + return readdirSync(repoPath(directory), { withFileTypes: true }).flatMap((entry) => { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) return shellSources(path); + return entry.name.endsWith(".rs") ? [path] : []; + }); } describe("desktop exit ownership", () => { @@ -104,33 +106,87 @@ describe("desktop exit ownership", () => { expect(body).toContain("inner.phase = ExitPhase::Draining"); }); - test("a runtime is not started once an exit is in flight", () => { + test("a quit that lands while a runtime is starting is deferred, not lost", () => { const exit = code(EXIT); - expect(exit).toContain("pub fn spawn_unless_ending"); - expect(code(repoPath(`${SRC}/startup.rs`))).toContain("coordinator.spawn_unless_ending("); + // The lock is never held across process creation, so the main thread's exit handler cannot + // end up waiting on a spawn; the exit is held by the phase instead. + expect(exit).toContain("ExitPhase::Spawning"); + expect(exit).toContain("ExitPhase::Spawning | ExitPhase::Draining => ExitDecision::Wait"); + const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); + expect(claim).toContain("ExitPhase::Spawning => {"); + expect(claim).toContain("inner.deferred = true;"); + const finish = exit.slice(exit.indexOf("pub fn finish_spawn"), exit.indexOf("impl Default for ExitCoordinator")); + expect(finish).toContain("inner.deferred"); + expect(finish).toContain("inner.phase = ExitPhase::Draining;"); + const startup = code(repoPath(`${SRC}/startup.rs`)); + const spawn = startup.slice(startup.indexOf("fn spawn_runtime(")); + expect(spawn).toContain("if !coordinator.begin_spawn() {"); + expect(spawn).toContain("crate::exit::drain_now(app, reason)"); + expect(spawn.indexOf("state.adopt(child)")).toBeLessThan(spawn.indexOf("coordinator.finish_spawn()")); + }); + + test("no tray setter is called while the tray mutex is held", () => { + const tray = code(repoPath(`${SRC}/tray.rs`)); + // Those setters dispatch to the main thread and wait for it, and the tray is built on the main + // thread while holding this mutex, so the two together are a cycle. + expect(tray).toContain("fn menu_handles(app: &AppHandle) -> Option"); + // Every setter is reached through the copy, never through a live guard: the nearest thing + // before it is the handle copy, not the lock. + const setters = [...tray.matchAll(/\.(?:set_enabled|set_text)\(/g)]; + expect(setters.length).toBeGreaterThan(5); + for (const setter of setters) { + const before = tray.slice(0, setter.index); + expect(before.lastIndexOf("menu_handles(app)")).toBeGreaterThan( + before.lastIndexOf("menu.lock()"), + ); + } }); test("the exit is held until the drain reports", () => { const exit = code(EXIT); + const handler = exit.slice( + exit.indexOf("pub fn on_exit_requested"), + exit.indexOf("pub fn start_drain"), + ); + expect(handler.length).toBeGreaterThan(0); for (const arm of [ "ExitDecision::Hide =>", "ExitDecision::Wait =>", "ExitDecision::Drain(reason) =>", - "ExitDecision::Proceed =>", ]) { - expect(exit).toContain(arm); + const at = handler.indexOf(arm); + expect(at).toBeGreaterThan(-1); + expect(handler.slice(at, at + 160)).toContain("api.prevent_exit()"); } - const proceed = exit.indexOf("ExitDecision::Proceed =>"); - expect(exit.slice(proceed, proceed + 40)).not.toContain("prevent_exit"); + const proceed = handler.indexOf("ExitDecision::Proceed =>"); + expect(proceed).toBeGreaterThan(-1); + expect(handler.slice(proceed)).not.toContain("prevent_exit"); expect(exit).toContain("coordinator.finish_drain()"); }); - test("closing the window only hides where there is a tray to come back from", () => { + test("closing the window takes the same decision the quit gesture does", () => { const window = code(WINDOW); const close = window.indexOf("CloseRequested"); + expect(close).toBeGreaterThan(-1); const branch = window.slice(close, window.indexOf("});", close)); - expect(branch).toContain("if exit::hides_to_tray(app) {"); - expect(branch).toContain("exit::request(app, exit::ExitReason::UserQuit)"); + expect(branch).toContain("api.prevent_close()"); + expect(branch).toContain("exit::gesture("); + const exit = code(EXIT); + const gesture = exit.slice(exit.indexOf("pub fn gesture(")); + const body = gesture.slice(0, gesture.indexOf("\n}")); + expect(body).toContain("ExitDecision::Hide => hide_windows(app)"); + expect(body).toContain("ExitDecision::Drain(reason) => start_drain(app, reason)"); + }); + + test("a drain never runs against a runtime this app did not start", () => { + const sidecar = code(repoPath(`${SRC}/sidecar.rs`)); + const drain = sidecar.slice( + sidecar.indexOf("pub async fn drain("), + sidecar.indexOf("async fn gone("), + ); + expect(drain).toContain("if !owned {"); + expect(drain).toContain("return DrainOutcome::NotOwned;"); + expect(drain.indexOf("if !owned {")).toBeLessThan(drain.indexOf("proxy.stop_within(")); }); test("only an observed exit or a refused connection proves the runtime stopped", () => { diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index a49b3685b6d..6dd7499d21a 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -40,7 +40,11 @@ describe("desktop startup surface", () => { }); test("setup resolves nothing, registers nothing and starts nothing", () => { - const setup = lib.slice(lib.indexOf(".setup(|app|")); + const setup = lib.slice( + lib.indexOf(".setup(|app|"), + lib.indexOf(".build(tauri::generate_context!())"), + ); + expect(setup.length).toBeGreaterThan(0); for (const call of [ "block_on", "ensure_proxy", @@ -49,6 +53,7 @@ describe("desktop startup surface", () => { "tray_availability::detect()", "tray::install", "first_run::", + "sidecar::", ]) { expect(setup).not.toContain(call); } @@ -97,6 +102,10 @@ describe("desktop startup surface", () => { // under, which is how a stated ceiling becomes an unstated one. expect(startup).not.toContain("proxy.is_alive()"); expect(startup).toContain("proxy.alive_within(deadline)"); + // Registration waits on a session bus and on the main thread, and both can stall; neither is + // allowed to leave the page in a state whose retry could do nothing. + expect(startup).toContain("tokio::time::timeout_at(\n deadline,"); + expect(startup).toContain("tokio::time::timeout_at(deadline, receiver)"); const proxy = code(PROXY); expect(proxy).toContain("timeout_at(deadline, self.is_alive())"); expect(proxy).toContain("timeout_at(deadline, self.stop())"); diff --git a/tests/clients/desktop-tray-availability.test.ts b/tests/clients/desktop-tray-availability.test.ts index 4f1b5392048..8744d91950f 100644 --- a/tests/clients/desktop-tray-availability.test.ts +++ b/tests/clients/desktop-tray-availability.test.ts @@ -58,15 +58,27 @@ describe("desktop tray availability", () => { }); test("the verdict reaches the coordinator that decides what a close means", () => { - expect(startup).toContain("coordinator.set_tray(tray)"); + expect(startup).toContain("coordinator.set_tray(verdict)"); expect(code(EXIT)).toContain("pub fn set_tray(&self, tray: TrayAvailability)"); - expect(code(EXIT)).toContain("pub fn hides_to_tray(app: &AppHandle) -> bool"); + expect(code(repoPath(`${SRC}/window.rs`))).toContain("exit::gesture("); + expect(code(EXIT)).toContain("decide(inner.phase, inner.reason, inner.hides_to_tray)"); }); - test("no tray icon is claimed where none can be drawn", () => { - const install = startup.indexOf("crate::tray::install(&handle)"); - expect(install).toBeGreaterThan(-1); - expect(startup.slice(0, install)).toContain("if tray.is_available() {"); + test("a tray is only claimed once an icon exists to claim", () => { + // The verdict is published after the install, not before it: announcing a tray and then + // failing to build one would hide the window into nothing. + const verdict = startup.indexOf("let verdict = if tray.is_available() && install_tray(app, deadline).await"); + const published = startup.indexOf("coordinator.set_tray(verdict)"); + expect(verdict).toBeGreaterThan(-1); + expect(published).toBeGreaterThan(verdict); + expect(startup.slice(verdict, published)).toContain("TrayAvailability::Unavailable"); + }); + + test("a retry does not register a second tray", () => { + const register = startup.slice(startup.indexOf("async fn register("), startup.indexOf("async fn install_tray(")); + expect(register).toContain("startup.registration()"); + expect(register.indexOf("return done;")).toBeLessThan(register.indexOf("install_tray(app, deadline)")); + expect(register).toContain("startup.remember_registration(login)"); }); test("tray availability decides the launch, not the origin of the launch", () => { @@ -76,7 +88,7 @@ describe("desktop tray availability", () => { expect(body).toContain("!tray.is_available() || origin == LaunchOrigin::User"); // The window is shown from inside the sequence, once the verdict is in, so a login launch on a // session with no tray is not left hidden with nothing to reopen it from. - expect(startup).toContain("if shows_window(LaunchOrigin::detect(), tray)"); + expect(startup).toContain("if shows_window(LaunchOrigin::detect(), verdict)"); expect(code(LIB)).toContain("startup::LaunchOrigin::detect() == startup::LaunchOrigin::User"); }); }); From e4d743bca3ffe48708e215e36b220f372b58273d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:33:01 +0900 Subject: [PATCH 06/24] docs(structure): state the tray verdict, registration and spawn rules exactly The contracts now say when the tray verdict is published rather than implying it is known up front, that registration happens once per process, that a quit during a spawn is deferred rather than refused, and why a menu setter is never called under the menu mutex. --- structure/desktop-shell.md | 23 +++++++++++++++++------ structure/overview.md | 12 +++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 14d550d08cc..8ae8258eb8e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -34,7 +34,10 @@ Registering runs first, before the runtime is touched. A login launch starts hid installed only after a successful start would leave a failed start with no window and no icon. The login item is registered in that state too, before the tray, so its Start at Login checkbox reads the state first run leaves behind. A launch carrying the `--autostart` argument that the login -item passes back is the only one that starts hidden, and only where there is a tray to hide in. +item passes back is the only one that starts hidden, and only where there is a tray to hide in: a +manual launch shows its window before the sequence begins, a login launch after the tray verdict. +Registering happens once per process, so a retry re-runs only the runtime half and cannot build a +second tray icon with its own refresh loop. `desktop/src-tauri/src/exit.rs` owns what ends the process. Where there is a usable tray, closing the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an @@ -50,17 +53,25 @@ in-flight requests finish. A drain that has not completed within `DRAIN_DEADLINE the exit still proceeds, which can leave the runtime running: refusing to quit when the user asked is the worse answer, and a standing runtime is recoverable with `ocx stop`. A runtime this app did not start is never stopped. A quit that arrives while the sequence is starting one is held: the -spawn and the record of ownership happen under the same lock the drain takes. +coordinator reserves the spawn rather than holding its lock across process creation, and the quit is +deferred until the child is owned and then drains it. `desktop/src-tauri/src/tray_availability.rs` asks the session bus whether `org.kde.StatusNotifierWatcher` reports a host registered; macOS and Windows answer yes without a probe. Neither construction success nor the watcher's mere existence is the question — the pinned Linux backend creates an AppIndicator and reports success with no host attached, and a watcher with no host accepts registrations and draws nothing. Until the probe answers, Linux assumes no tray, so -a window closed in the first moments quits rather than vanishing. Where the answer is no, no tray -icon is claimed, the window is shown on launch whatever the launch origin, and closing it quits -through the same drain. The update controls live in the tray menu, so a session without one checks -for updates in the background and has no place to install them from. +a window closed in the first moments quits rather than vanishing, and the verdict is published only +once an icon actually exists — a tray that fails to build is a session with no tray, not a claimed +one. Where the answer is no, no tray icon is claimed, the window is shown on launch whatever the +launch origin, and closing it quits through the same drain. The update controls live in the tray +menu, so a session without one checks for updates in the background and has no place to install +them from. + +Every tray menu setter dispatches to the main thread and waits for it, and the tray is built on the +main thread while holding the menu mutex, so the handles are copied out from under that mutex before +any setter is called. Holding it across a setter is a cycle, and the symptom would be an app that +stops answering Quit. `desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, before the tray is built so its checkbox reads the resulting state. A menu bar app diff --git a/structure/overview.md b/structure/overview.md index da30d93bef0..a908d8e9e8b 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -176,15 +176,17 @@ still cover the rule, which is a judgement only review makes. tray's Quit, an update's coordinated restart, and a window close on a session with no tray all hold the exit, stop the app-owned runtime through the management stop, and treat only an observed child exit or a refused connection as proof it stopped. No shell file kills the child, a runtime - this app did not start is never stopped, and a runtime started while an exit is in flight is not - started at all; see [`desktop-shell.md`](desktop-shell.md). + this app did not start is never stopped, and a quit that lands while one is being started is + deferred until the child is owned and then drains it rather than being lost; + see [`desktop-shell.md`](desktop-shell.md). Enforced by `tests/clients/desktop-exit-ownership.test.ts`. - **INV-DESKTOP-02** — Tray availability is an answer from the session, not the tray backend's construction result and not the watcher's mere existence: the shell asks whether `org.kde.StatusNotifierWatcher` reports a host registered, and reads an unanswerable probe the - same way as an unregistered one. Linux assumes no tray until the probe answers. Where there is - none, no icon is claimed, the window is shown on launch whatever the launch origin, and closing it - quits through the same drain; see [`desktop-shell.md`](desktop-shell.md). + same way as an unregistered one. Linux assumes no tray until the probe answers, and the verdict is + published only once an icon exists, so a tray that fails to build is a session without one. Where + there is none, no icon is claimed, the window is shown on launch whatever the launch origin, and + closing it quits through the same drain; see [`desktop-shell.md`](desktop-shell.md). Enforced by `tests/clients/desktop-tray-availability.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general From 08aa92f27a67a69880bdf230909c7d16f2b27bc6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 09:40:27 +0900 Subject: [PATCH 07/24] test(desktop): follow claim_drain into the match that replaced its guard Hosted CI caught this one: the assertion still looked for the early-return guard that claim_drain used before it grew a Spawning arm, and the phrase it searched for had moved to begin_spawn - so a global search for the text found it while the scoped assertion did not. It now reads the Idle arm itself and pins the number of places that move the phase to draining. --- tests/clients/desktop-exit-ownership.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index 856ec305618..88f069fe0b1 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -100,10 +100,18 @@ describe("desktop exit ownership", () => { const exit = code(EXIT); const claim = exit.indexOf("pub fn claim_drain"); expect(claim).toBeGreaterThan(-1); - const body = exit.slice(claim, exit.indexOf("\n }", claim)); - expect(body).toContain("if inner.phase != ExitPhase::Idle"); - expect(body).toContain("inner.reason.get_or_insert(fallback)"); - expect(body).toContain("inner.phase = ExitPhase::Draining"); + const body = exit.slice(claim, exit.indexOf("pub fn finish_drain", claim)); + expect(body.length).toBeGreaterThan(0); + // One match over the phase, so the reason a caller wins and the move out of Idle cannot be + // separated by a second caller arriving between them. + expect(body).toContain("match inner.phase {"); + const idle = body.indexOf("ExitPhase::Idle => {"); + expect(idle).toBeGreaterThan(-1); + const arm = body.slice(idle, body.indexOf("ExitPhase::Spawning => {", idle)); + expect(arm).toContain("inner.reason.get_or_insert(fallback)"); + expect(arm).toContain("inner.phase = ExitPhase::Draining;"); + // Nothing else in the file moves the phase to draining. + expect(exit.split("inner.phase = ExitPhase::Draining;")).toHaveLength(3); }); test("a quit that lands while a runtime is starting is deferred, not lost", () => { From d6130e1426bd41c1a473ea2a3b8c2af5ad1afcbd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:08:22 +0900 Subject: [PATCH 08/24] feat(desktop): hold this installation's own id and read ownership by lane C's rule The shared service install state now records who owns the running proxy, and the claim names the owning installation rather than the user or the machine. So the app needs a value of its own to compare against: identity.rs mints one into the app's config directory, once and exclusively, so two launches racing each other answer to the same id rather than to two - and a second id would find a claim that is not its own and ask again for consent the user had already given. An id kept only in the shared record would be whoever wrote it last, which is why D3 accepted two records and the re-consent a lost app-local one forces. ownership.rs mirrors the claim, the three answers a read can give and the comparison, all of which src/service/state.ts defines. It does not read the record: resolving one means reading every state path and failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second weaker implementation of a question core already answers is the mistake that gave discovery.rs its own port guess. The types are the CLI's answer as it will arrive on the wire, field for field, so lane A's contract fills a hole instead of reshaping this file. Until it lands, resolve is unavailable - which is not "nobody owns it", because the question has not been put - so no takeover is attempted and nothing is recorded. The registering state and the failure diagnostic say which of the two it is. --- desktop/src-tauri/src/identity.rs | 111 +++++++++++++ desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/ownership.rs | 249 +++++++++++++++++++++++++++++ desktop/src-tauri/src/startup.rs | 60 ++++--- 4 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 desktop/src-tauri/src/identity.rs create mode 100644 desktop/src-tauri/src/ownership.rs diff --git a/desktop/src-tauri/src/identity.rs b/desktop/src-tauri/src/identity.rs new file mode 100644 index 00000000000..9deb1c32e0a --- /dev/null +++ b/desktop/src-tauri/src/identity.rs @@ -0,0 +1,111 @@ +//! This installation's own identity. +//! +//! The shared service install state records who owns the running proxy, and the claim names the +//! owning *installation* rather than the user or the machine. So the app has to hold a value of its +//! own to compare against, which is this: an opaque id written once into the app's config directory +//! and never rewritten. +//! +//! Two records rather than one is D3, and its cost is recorded there. An id stored only in the +//! shared record would be whoever wrote it last, which gives a reinstalled app no way to tell its +//! own prior consent from another installation's. The price is that a reinstall which keeps this +//! directory keeps its consent, and one that loses it has to ask again. + +use std::{ + fs, + io::{ErrorKind, Write}, + path::Path, +}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +/// The file holding this installation's id, in the app's own config directory. +const FILE: &str = "install-id"; + +pub fn install_id(app: &AppHandle) -> Option { + let directory = app.path().app_config_dir().ok()?; + install_id_in(&directory) +} + +/// Read this installation's id, minting it the first time. +/// +/// The mint is exclusive and the value is read back afterwards, so two launches racing each other +/// both answer to the id that won rather than to two different ones. Two ids would be two +/// installations as far as the recorded claim is concerned, and the second would find a claim that +/// is not its own and ask again for consent the user had already given. +pub fn install_id_in(directory: &Path) -> Option { + let path = directory.join(FILE); + if let Some(existing) = read(&path) { + return Some(existing); + } + fs::create_dir_all(directory).ok()?; + match mint(&path) { + Ok(()) => {} + // The file is there and says nothing: a blank or truncated write from an interrupted first + // run. An empty id matches nothing, so every comparison against the recorded claim would + // quietly be false and the app would ask for consent it already had. Replace it. + Err(ErrorKind::AlreadyExists) => { + if read(&path).is_none() { + fs::write(&path, Uuid::new_v4().to_string()).ok()?; + } + } + Err(_) => return None, + } + read(&path) +} + +fn mint(path: &Path) -> Result<(), ErrorKind> { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .and_then(|mut file| file.write_all(Uuid::new_v4().to_string().as_bytes())) + .map_err(|error| error.kind()) +} + +fn read(path: &Path) -> Option { + let value = fs::read_to_string(path).ok()?; + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::{install_id_in, FILE}; + use std::fs; + + fn scratch(name: &str) -> std::path::PathBuf { + let directory = + std::env::temp_dir().join(format!("ocx-identity-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + directory + } + + #[test] + fn the_id_is_minted_once_and_then_read_back() { + let directory = scratch("mint"); + let first = install_id_in(&directory).expect("an id"); + assert!(!first.is_empty()); + assert_eq!(install_id_in(&directory).as_deref(), Some(first.as_str())); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn two_installations_do_not_share_an_id() { + let one = scratch("one"); + let two = scratch("two"); + assert_ne!(install_id_in(&one), install_id_in(&two)); + let _ = fs::remove_dir_all(&one); + let _ = fs::remove_dir_all(&two); + } + + #[test] + fn a_blank_record_is_replaced_rather_than_answered_with() { + let directory = scratch("blank"); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join(FILE), " \n").unwrap(); + let minted = install_id_in(&directory).expect("an id"); + assert!(!minted.trim().is_empty()); + assert_eq!(install_id_in(&directory).as_deref(), Some(minted.as_str())); + let _ = fs::remove_dir_all(&directory); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a781e59854b..463b79d6cce 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,11 +3,13 @@ mod discovery; mod exit; mod first_run; mod formatting; +mod identity; mod logging; // macOS only: it exists to replace one item in a menu no other platform installs. Compiling it // elsewhere would leave its contents unreachable, which -D warnings rejects. #[cfg(target_os = "macos")] mod menu; +mod ownership; mod proxy; mod sidecar; mod startup; diff --git a/desktop/src-tauri/src/ownership.rs b/desktop/src-tauri/src/ownership.rs new file mode 100644 index 00000000000..a27bbf6a8e2 --- /dev/null +++ b/desktop/src-tauri/src/ownership.rs @@ -0,0 +1,249 @@ +//! Who owns the running proxy, as the shared service install state records it. +//! +//! The rule is not this lane's to invent. `src/service/state.ts` defines the claim — an owner, an +//! opaque install id naming the owning installation, and a consent generation — and +//! `ownershipGrantedTo` defines the comparison an installation applies to its own locally stored +//! install id. This is that comparison, and the three-valued reading it is applied to, so the shell +//! reaches the same verdict the CLI does instead of a weaker one of its own. +//! +//! What the shell deliberately does not do is read the record itself. Resolving a claim means +//! reading every state path and failing closed on an unreadable one, on a corrupt anchor record and +//! on paths that name different owners; absence is the only thing that means nobody owns the +//! runtime. Reimplementing that here is how `discovery.rs` ended up asking a weaker liveness +//! question than the one core already answered. The bundled CLI answers it: see [`resolve`]. +//! +//! The types below are the CLI's own answer as it will arrive on the wire, field for field, so the +//! contract that lands fills a hole rather than reshaping this file. + +use serde::Deserialize; +use tauri::AppHandle; + +/// Who a claim names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Owner { + Cli, + Desktop, +} + +/// A recorded ownership claim. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Claim { + pub owner: Owner, + pub install_id: String, + /// Moves once per grant, and never back: the recorded ceiling survives a release so a later + /// grant cannot reuse a number an app-local record may still be holding. + /// + /// The record accepts any non-negative integer, and this accepts the ones it can represent. A + /// generation outside that range fails to parse, which makes the whole answer unreadable and + /// so refuses a takeover — the safe direction, and unreachable in practice by a counter that + /// moves by one per grant. + pub consent_generation: u64, +} + +/// What the recorded state says, in the CLI's own three answers. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Recorded { + /// No claim. The CLI install that registered the service owns the runtime, which is also what + /// every record written before the field existed says. + None, + /// A claim, whoever it names. + Owned { ownership: Claim }, + /// The claim could not be read for a decision. This is not "nobody owns it": an unreadable + /// path, a corrupt anchor record and paths naming different owners all land here. + Unknown { reason: String }, +} + +/// The comparison `ownershipGrantedTo` defines: same owner, same install id. +/// +/// True means this installation already holds consent. False against a recorded claim means a +/// different installation owns the runtime and consent has to be asked again. No claim means the +/// CLI install still owns it. The generation is not part of the comparison. +pub fn granted_to(claim: Option<&Claim>, owner: Owner, install_id: &str) -> bool { + matches!(claim, Some(claim) if claim.owner == owner && claim.install_id == install_id) +} + +/// What this installation should do about consent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Consent { + /// This installation already holds consent. Asked once, owned permanently — so a second launch + /// does not ask again. + Held, + /// Nothing is recorded, so the CLI install still owns the runtime. This is the first discovery + /// of an existing installation, and the one time the user is asked. + AskFirstTime, + /// A claim exists and it is not ours: another installation, or the CLI explicitly. + AskAgain, + /// The record could not be read for a decision, so nothing is taken over on a guess. + Refuse, +} + +pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { + match recorded { + Recorded::Unknown { .. } => Consent::Refuse, + Recorded::None => Consent::AskFirstTime, + Recorded::Owned { ownership } => { + if granted_to(Some(ownership), Owner::Desktop, install_id) { + Consent::Held + } else { + Consent::AskAgain + } + } + } +} + +/// Read the recorded claim through the bundled CLI. +/// +/// Empty on purpose. Lane A publishes the machine-readable resolve the shell drives, and this is +/// the one call site that changes when it lands: it has to return the CLI's own answer, including +/// its refusals, rather than a verdict computed here. Until then the answer is *unavailable*, which +/// is not [`Recorded::None`] — the shell has not been told that nobody owns the runtime, it has not +/// asked — so no takeover is attempted and nothing is recorded. +pub fn resolve(_app: &AppHandle) -> Option { + None +} + +/// One line for the startup state and for the diagnostic. +pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String { + let installation = match install_id { + Some(id) => format!("installation {id}"), + None => "installation id unavailable".to_owned(), + }; + let verdict = match (recorded, install_id) { + (None, _) => { + "recorded owner not read: the bundled CLI's resolve contract has not landed".to_owned() + } + (Some(Recorded::Unknown { reason }), _) => { + format!("recorded owner could not be read ({reason}), so nothing is claimed") + } + (Some(_), None) => { + "recorded owner read, but this installation has no id to compare".to_owned() + } + (Some(recorded), Some(id)) => match (consent(recorded, id), recorded) { + (Consent::Held, Recorded::Owned { ownership }) => format!( + "this installation owns the runtime (consent generation {})", + ownership.consent_generation + ), + (Consent::AskFirstTime, _) => "the CLI install owns the runtime".to_owned(), + (Consent::AskAgain, _) => "another installation owns the runtime".to_owned(), + _ => "recorded owner could not be read, so nothing is claimed".to_owned(), + }, + }; + format!("{installation}; {verdict}") +} + +#[cfg(test)] +mod tests { + use super::{consent, describe, granted_to, Claim, Consent, Owner, Recorded}; + + fn owned(owner: Owner, install_id: &str, generation: u64) -> Recorded { + Recorded::Owned { + ownership: Claim { + owner, + install_id: install_id.to_owned(), + consent_generation: generation, + }, + } + } + + fn claim(owner: Owner, install_id: &str) -> Claim { + Claim { + owner, + install_id: install_id.to_owned(), + consent_generation: 1, + } + } + + #[test] + fn the_comparison_is_the_owner_and_the_install_id_together() { + let ours = claim(Owner::Desktop, "abc"); + assert!(granted_to(Some(&ours), Owner::Desktop, "abc")); + assert!(!granted_to(Some(&ours), Owner::Desktop, "def")); + assert!(!granted_to(Some(&ours), Owner::Cli, "abc")); + assert!(!granted_to(None, Owner::Desktop, "abc")); + } + + #[test] + fn the_generation_is_not_part_of_the_comparison() { + let mut later = claim(Owner::Desktop, "abc"); + later.consent_generation = 9; + assert!(granted_to(Some(&later), Owner::Desktop, "abc")); + } + + #[test] + fn consent_is_asked_once_and_then_held() { + assert_eq!( + consent(&owned(Owner::Desktop, "abc", 1), "abc"), + Consent::Held + ); + assert_eq!(consent(&Recorded::None, "abc"), Consent::AskFirstTime); + } + + #[test] + fn a_claim_that_is_not_ours_asks_again() { + assert_eq!( + consent(&owned(Owner::Desktop, "other", 2), "abc"), + Consent::AskAgain + ); + assert_eq!( + consent(&owned(Owner::Cli, "abc", 1), "abc"), + Consent::AskAgain + ); + } + + #[test] + fn an_unreadable_record_refuses_instead_of_reading_as_unowned() { + let unknown = Recorded::Unknown { + reason: "a service state path could not be read".to_owned(), + }; + assert_eq!(consent(&unknown, "abc"), Consent::Refuse); + assert!(describe(Some(&unknown), Some("abc")).contains("could not be read")); + } + + #[test] + fn the_wire_shape_is_the_one_the_cli_records() { + let resolution: Recorded = serde_json::from_str( + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3}}"#, + ) + .expect("the recorded resolution"); + assert_eq!(resolution, owned(Owner::Desktop, "abc", 3)); + assert_eq!(consent(&resolution, "abc"), Consent::Held); + assert!(describe(Some(&resolution), Some("abc")).contains("consent generation 3")); + assert_eq!( + serde_json::from_str::(r#"{"kind":"none"}"#).expect("no claim"), + Recorded::None + ); + assert_eq!( + serde_json::from_str::(r#"{"kind":"unknown","reason":"why"}"#) + .expect("a refusal"), + Recorded::Unknown { + reason: "why".to_owned() + } + ); + } + + #[test] + fn the_description_separates_not_asked_from_nobody_owns_it() { + let not_asked = describe(None, Some("abc")); + let unowned = describe(Some(&Recorded::None), Some("abc")); + assert!(not_asked.contains("abc")); + assert_ne!(not_asked, unowned); + assert!(describe(None, None).contains("unavailable")); + } + + #[test] + fn a_generation_this_cannot_represent_is_not_read_as_a_claim() { + // Refusing beats granting on a number we cannot compare, and the claim is what a takeover + // would be authorised against. + assert!(serde_json::from_str::( + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":-1}}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"kind":"owned","ownership":{"owner":"cli","installId":"abc","consentGeneration":"3"}}"# + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 6271afc51b4..8c223bdae5e 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -19,6 +19,7 @@ use crate::{ auth::Auth, discovery::{self, ProxyEndpoint}, first_run::{self, StartAtLogin}, + identity, ownership, proxy::ProxyClient, sidecar::{self, SidecarWatch}, tray_availability::{self, TrayAvailability}, @@ -214,6 +215,14 @@ struct Resolved { home: PathBuf, } +/// What registering established about this installation. +#[derive(Clone, Debug)] +pub struct Registration { + pub login: StartAtLogin, + /// This installation's own id, and what the recorded runtime owner says about it. + pub identity: String, +} + struct Live { latest: Progress, reported: Vec<&'static str>, @@ -225,7 +234,7 @@ pub struct Startup { live: Mutex, running: AtomicBool, /// The outcome of the one-time registration, once it has happened. - registered: Mutex>, + registered: Mutex>, } impl Startup { @@ -244,18 +253,18 @@ impl Startup { self.live.lock().unwrap_or_else(PoisonError::into_inner) } - fn registration(&self) -> Option { - *self - .registered + fn registration(&self) -> Option { + self.registered .lock() .unwrap_or_else(PoisonError::into_inner) + .clone() } - fn remember_registration(&self, login: StartAtLogin) { + fn remember_registration(&self, registration: Registration) { *self .registered .lock() - .unwrap_or_else(PoisonError::into_inner) = Some(login); + .unwrap_or_else(PoisonError::into_inner) = Some(registration); } /// The whole state of the run so far, which is what the page asks for when it loads. @@ -323,12 +332,16 @@ async fn run(app: &AppHandle) { return; }; report(app, started, Phase::Registering, None); - let login = register(app, deadline).await; + let registration = register(app, deadline).await; report( app, started, Phase::Registering, - Some(login.describe().to_owned()), + Some(format!( + "{}; {}", + registration.login.describe(), + registration.identity + )), ); report(app, started, Phase::Resolving, None); @@ -347,7 +360,7 @@ async fn run(app: &AppHandle) { app, started, Some(&resolved), - login, + ®istration, &watch, Phase::Resolving, error.to_string(), @@ -405,7 +418,7 @@ async fn run(app: &AppHandle) { app, started, Some(&resolved), - login, + ®istration, &watch, Phase::Starting, error, @@ -430,7 +443,7 @@ async fn run(app: &AppHandle) { app, started, Some(&resolved), - login, + ®istration, &watch, Phase::Waiting, format!("the runtime {}", exit.describe()), @@ -443,7 +456,7 @@ async fn run(app: &AppHandle) { app, started, Some(&resolved), - login, + ®istration, &watch, Phase::Waiting, format!( @@ -458,7 +471,7 @@ async fn run(app: &AppHandle) { /// It happens once per process. A retry re-runs the runtime half of the sequence, and running this /// half again would build a second tray icon with its own refresh loop and its own menu handlers — /// the failure would look like the app duplicating itself every time the user pressed Retry. -async fn register(app: &AppHandle, deadline: Instant) -> StartAtLogin { +async fn register(app: &AppHandle, deadline: Instant) -> Registration { if let Some(done) = app .try_state::() .and_then(|startup| startup.registration()) @@ -501,10 +514,18 @@ async fn register(app: &AppHandle, deadline: Instant) -> StartAtLogin { crate::window::show(&window); } } + // This installation's own id, and what the recorded runtime owner says about it. The claim + // lives in the shared service install state and the CLI is what reads it; the comparison + // against our own id is the rule that record publishes. + let install_id = identity::install_id(app); + let registration = Registration { + login, + identity: ownership::describe(ownership::resolve(app).as_ref(), install_id.as_deref()), + }; if let Some(startup) = app.try_state::() { - startup.remember_registration(login); + startup.remember_registration(registration.clone()); } - login + registration } /// Build the tray on the main thread, which is where GTK requires it on Linux. @@ -600,7 +621,7 @@ fn fail( app: &AppHandle, started: Instant, resolved: Option<&Resolved>, - login: StartAtLogin, + registration: &Registration, watch: &SidecarWatch, phase: Phase, reason: String, @@ -609,7 +630,7 @@ fn fail( let mut progress = Progress::new(Phase::Failed, elapsed_ms); progress.diagnostic = Some(diagnostic( resolved.map(|resolved| (resolved.endpoint, resolved.home.clone())), - login, + registration, watch, phase, &reason, @@ -627,7 +648,7 @@ fn fail( /// them were reachable from the generic health failure this replaces. pub fn diagnostic( resolved: Option<(ProxyEndpoint, PathBuf)>, - login: StartAtLogin, + registration: &Registration, watch: &SidecarWatch, phase: Phase, reason: &str, @@ -650,7 +671,8 @@ pub fn diagnostic( } None => lines.push("endpoint: not resolved".to_owned()), } - lines.push(format!("start at login: {}", login.describe())); + lines.push(format!("start at login: {}", registration.login.describe())); + lines.push(format!("runtime ownership: {}", registration.identity)); lines.push(match watch.exit() { Some(exit) => format!("runtime process: {}", exit.describe()), None => "runtime process: still running or never started".to_owned(), From 83cb5cc69581d0ff8fa1728305bbc82340d6a8c2 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:08:22 +0900 Subject: [PATCH 09/24] test(desktop): read both halves of the ownership claim together The shell's half and src/service/state.ts's half are asserted in one file, so a change to the owner values, the wire field names, the three resolution kinds or the comparison rule breaks here rather than leaving the two to disagree somewhere only a real takeover would reveal. It also pins what the comparison does not look at: the generation moves on every grant, and comparing it would make a consent the app already holds look foreign. --- scripts/test-layout/layout.json | 1 + .../clients/desktop-install-identity.test.ts | 119 ++++++++++++++++++ .../clients/desktop-tray-availability.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/clients/desktop-install-identity.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ddf24e2759c..60bf811fb00 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -721,6 +721,7 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", "desktop-startup-surface.test.ts": "clients", "desktop-tray-availability.test.ts": "clients", diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts new file mode 100644 index 00000000000..256fdc8100e --- /dev/null +++ b/tests/clients/desktop-install-identity.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The desktop app's half of the runtime-ownership claim. + * + * The claim lives in the shared service install state and `src/service/state.ts` owns it: the + * owner values, the field names, the three answers a read can give, and `ownershipGrantedTo`, which + * is the comparison an installation applies to its own locally stored install id. The shell holds + * the other half — an id of its own to compare against — and mirrors the rule rather than inventing + * one, because a weaker version of a question core already answers is how the shell ended up + * guessing a port it should have been told. + * + * Both halves are read here together, so a change on either side breaks this rather than leaving + * the two to disagree in a place only a takeover would reveal. + */ +const SHELL = "desktop/src-tauri/src"; +const IDENTITY = repoPath(`${SHELL}/identity.rs`); +const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const STATE = repoPath("src/service/state.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop install identity", () => { + const identity = code(IDENTITY); + const ownership = code(OWNERSHIP); + const state = code(STATE); + + test("the installation's id is minted once and never rewritten", () => { + // Exclusive, because two launches racing to mint would answer to two ids, and the second one + // would find a claim that is not its own and ask again for consent already given. + expect(identity).toContain(".create_new(true)"); + const mint = identity.slice(identity.indexOf("pub fn install_id_in")); + const body = mint.slice(0, mint.indexOf("\n}")); + expect(body.indexOf("if let Some(existing) = read(&path)")).toBeLessThan( + body.indexOf("mint(&path)"), + ); + expect(body).toContain("return Some(existing);"); + // It is the app's own directory, not the shared record: an id stored only in the shared one + // would be whoever wrote it last. + expect(identity).toContain("app_config_dir()"); + }); + + test("a blank record is replaced rather than answered with", () => { + expect(identity).toContain("ErrorKind::AlreadyExists"); + const replace = identity.slice(identity.indexOf("ErrorKind::AlreadyExists")); + expect(replace.slice(0, 300)).toContain("read(&path).is_none()"); + }); + + test("the owner values are the ones the record accepts", () => { + expect(state).toContain('ownership.owner !== "cli" && ownership.owner !== "desktop"'); + expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); + expect(ownership).toContain(" Cli,"); + expect(ownership).toContain(" Desktop,"); + }); + + test("the claim's wire fields are the recorded ones", () => { + for (const field of ["installId", "consentGeneration"]) { + expect(state).toContain(`ownership.${field}`); + } + expect(ownership).toContain('#[serde(rename_all = "camelCase")]'); + expect(ownership).toContain("pub install_id: String"); + expect(ownership).toContain("pub consent_generation: u64"); + }); + + test("the three answers a read can give are all three", () => { + for (const kind of ["none", "owned", "unknown"]) { + expect(state).toContain(`kind: "${kind}"`); + } + expect(ownership).toContain('#[serde(tag = "kind", rename_all = "lowercase")]'); + expect(ownership).toContain(" None,"); + expect(ownership).toContain("Owned { ownership: Claim }"); + expect(ownership).toContain("Unknown { reason: String }"); + }); + + test("the comparison is the one the record publishes, and no more", () => { + const rule = state.slice(state.indexOf("export function ownershipGrantedTo")); + expect(rule.slice(0, 300)).toContain( + "ownership.owner === owner && ownership.installId === installId", + ); + const mirror = ownership.slice(ownership.indexOf("pub fn granted_to")); + const body = mirror.slice(0, mirror.indexOf("\n}")); + expect(body).toContain("claim.owner == owner && claim.install_id == install_id"); + // The generation moves on every grant; comparing it would make a held consent look foreign. + expect(body).not.toContain("consent_generation"); + }); + + test("an unreadable record refuses instead of reading as unowned", () => { + const verdict = ownership.slice(ownership.indexOf("pub fn consent(")); + const body = verdict.slice(0, verdict.indexOf("\n}")); + expect(body).toContain("Recorded::Unknown { .. } => Consent::Refuse"); + expect(body).toContain("Recorded::None => Consent::AskFirstTime"); + }); + + test("the shell does not read the recorded claim itself", () => { + // Resolving means reading every state path and failing closed on an unreadable one, a corrupt + // anchor and paths that disagree. That answer belongs to the CLI. + for (const leak of ["service-state", "serviceStatePaths", "read_to_string", "fs::"]) { + expect(ownership).not.toContain(leak); + } + const seam = ownership.slice(ownership.indexOf("pub fn resolve(_app: &AppHandle)")); + expect(seam.slice(0, 120)).toContain("None"); + }); + + test("not having asked is distinct from nobody owning it", () => { + // Option::None means the CLI has not been asked; Recorded::None means it answered that no + // claim exists. Collapsing them would let a takeover proceed on a question never put. + expect(ownership).toContain("pub fn resolve(_app: &AppHandle) -> Option"); + expect(ownership).toContain("(None, _) => "); + const startup = code(STARTUP); + expect(startup).toContain("ownership::describe(ownership::resolve(app).as_ref()"); + expect(startup).toContain("identity::install_id(app)"); + expect(startup).toContain('format!("runtime ownership: {}", registration.identity)'); + }); +}); diff --git a/tests/clients/desktop-tray-availability.test.ts b/tests/clients/desktop-tray-availability.test.ts index 8744d91950f..d230f606edd 100644 --- a/tests/clients/desktop-tray-availability.test.ts +++ b/tests/clients/desktop-tray-availability.test.ts @@ -78,7 +78,7 @@ describe("desktop tray availability", () => { const register = startup.slice(startup.indexOf("async fn register("), startup.indexOf("async fn install_tray(")); expect(register).toContain("startup.registration()"); expect(register.indexOf("return done;")).toBeLessThan(register.indexOf("install_tray(app, deadline)")); - expect(register).toContain("startup.remember_registration(login)"); + expect(register).toContain("startup.remember_registration(registration.clone())"); }); test("tray availability decides the launch, not the origin of the launch", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ccbaaf03384..d91c3f3c084 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -552,6 +552,7 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", "desktop-startup-surface.test.ts": "clients", "desktop-tray-availability.test.ts": "clients", From 14e64a9fd519b2021f831dd36fd07e7a12aaf287 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:08:22 +0900 Subject: [PATCH 10/24] docs(structure): record the app's half of the runtime-ownership claim Points at the contract lane C published rather than restating it, and says plainly that an unavailable answer is not an unowned runtime. --- structure/desktop-shell.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 8ae8258eb8e..2f2af2fa55e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -73,6 +73,26 @@ main thread while holding the menu mutex, so the handles are copied out from und any setter is called. Holding it across a setter is a cycle, and the symptom would be an app that stops answering Quit. +## Runtime ownership, from the app's side + +`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted +once into the app's config directory and never rewritten, exclusively so two launches racing each +other answer to the same one. It exists because the recorded claim names the owning *installation*, +so the app needs a value of its own to compare against; an id kept only in the shared record would +be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another +installation's. The cost is that a reinstall which keeps the directory keeps its consent and one +that loses it asks again. + +`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the +comparison, all of which are defined by +[background-service runtime ownership](runtime.md#background-service-runtime-ownership) and not +here. The shell does not read the record: resolving a claim means reading every state path and +failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second +weaker implementation of a question core already answers is the mistake this tree has made before. +The bundled CLI answers it. Until that contract lands, `resolve` returns *unavailable*, which is +not the same as "nobody owns it" — the question has not been put — so the shell attempts no takeover +and records nothing, and the startup state and the diagnostic say which of the two it is. + `desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, before the tray is built so its checkbox reads the resulting state. A menu bar app that is not running has no menu bar item, so leaving autostart off by default left an From a0f0e3cd2997c340a56d7d7140466a771c6ae867 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:48:16 +0900 Subject: [PATCH 11/24] fix(desktop): drain before an update installs, and stop calling a failed drain a drain Removing the direct kill put the update on a coordinated path only if the coordination is reached. On Windows it was not: the pinned updater's install hands off to the installer process and ends this one with process::exit(0), so the restart asked for after download_and_install() never ran, and the package was replaced under a runtime still serving out of those files. The order is now download and signature-check, confirm who owns the running runtime, drain it and confirm the child is gone, and only then install. A drain that did not complete refuses the install and leaves the update pending rather than proceeding. A failed drain was also being recorded as a drain: the same completion path ran for both, so a stop that was refused or timed out still ended in the exiting or restarting branch. For a quit that is a defensible trade - refusing to close when the user asked is worse, and a standing runtime is recoverable. For a restart it is not the same judgement, because the new app comes back attached to the old runtime while the user believes they upgraded. DrainFailed and OwnershipUnknown are now states of their own, a quit proceeds from either, and a coordinated restart refuses both. The tray's Stop ran its own drain beside the coordinator, so Stop pressed twice, Stop then Quit, and Stop during an update were separate executions over one child. It takes the same phase now, and a quit that lands during a stop is deferred and run afterwards rather than dropped. --- desktop/src-tauri/src/exit.rs | 481 +++++++++++++++++++++++-------- desktop/src-tauri/src/tray.rs | 29 +- desktop/src-tauri/src/updater.rs | 32 +- 3 files changed, 395 insertions(+), 147 deletions(-) diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs index bbd1f1b4b9b..b38aafd080e 100644 --- a/desktop/src-tauri/src/exit.rs +++ b/desktop/src-tauri/src/exit.rs @@ -3,23 +3,25 @@ //! Three gestures arrive looking like an exit: closing the window, the platform's own quit gesture //! (Cmd+Q on macOS, Alt+F4 on Windows, the window manager's close on Linux), and the tray's Quit //! item. Only the last one means "end the runtime". Until this module existed the shell had no -//! `ExitRequested` handler at all, so the quit gesture went straight to `RunEvent::Exit`, which -//! called `CommandChild::kill()` — a SIGKILL on Unix — on a keystroke the user reads as "hide". +//! `ExitRequested` handler, so the quit gesture went straight to `RunEvent::Exit`, which called +//! `CommandChild::kill()` — a SIGKILL on Unix — on a keystroke the user reads as "hide". //! //! Tauri separates a user gesture from a programmatic exit: `RunEvent::ExitRequested` carries //! `code: None` for the gesture and `Some(_)` for `AppHandle::exit` or `AppHandle::restart`. What -//! it cannot tell apart is the tray's Quit from an update's restart, and those end differently: a -//! quit stops the runtime and stays stopped, an update restart runs the same drain and comes back. -//! [`ExitReason`] records which one asked. macOS needs one more thing on top, because its menu -//! Quit never raises the event at all; see [`crate::menu`]. +//! it cannot tell apart is the tray's Quit from an update's restart, and those end differently. +//! [`ExitReason`] records which one asked. macOS needs one more thing on top, because its menu Quit +//! never raises the event at all; see [`crate::menu`]. //! -//! The drain cannot run inside the event handler — it is asynchronous and can take seconds — so -//! every path funnels through one three-step phase: hold the exit, drain, ask again. The phase, the -//! reason and the permission to start a runtime all live under one lock, because they are one -//! decision: a quit that lands while the startup sequence is spawning must not leave the spawned -//! process behind. +//! Everything that stops the runtime funnels through one phase here — the tray's Quit, the tray's +//! Stop, an update, and a window close on a session with no tray. Two of them running at once is +//! two stops racing over one child, so a second one waits rather than starting its own. +//! +//! A drain that does not complete is **not** recorded as a drain. A quit may still proceed on one, +//! because refusing to close is the worse answer and a standing runtime is recoverable. A restart +//! may not: coming back onto a runtime that was never stopped puts the user on the old version +//! while they believe they are on the new one. -use crate::{sidecar, tray_availability::TrayAvailability, window, AppState}; +use crate::{proxy::ProxyClient, sidecar, tray_availability::TrayAvailability, window, AppState}; use std::sync::{Mutex, MutexGuard, PoisonError}; use tauri::{AppHandle, ExitRequestApi, Manager}; @@ -33,7 +35,7 @@ pub enum ExitReason { CoordinatedRestart, } -/// How far the one drain-and-exit sequence has got. +/// How far the one drain sequence has got. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExitPhase { /// Nothing is in flight. @@ -41,10 +43,17 @@ pub enum ExitPhase { /// A runtime is being started. An exit arriving now is held until the child exists and is /// recorded, because the alternative is a process nobody owns and nobody will stop. Spawning, - /// The drain is running. Further exit requests wait for it rather than starting a second one. + /// The runtime is being stopped without ending the app: the tray's Stop. + Stopping, + /// The drain that ends the app is running. Draining, - /// The drain has reported. The next exit request is the real one. + /// The runtime this app owned is confirmed stopped, or was never ours to stop. Drained, + /// The stop was refused, or the runtime still answered after the deadline. + DrainFailed, + /// Who owns the runtime could not be established, so nothing was stopped and nothing may be + /// replaced on the assumption that it was. + OwnershipUnknown, } /// What the event loop should do with an exit request. @@ -54,10 +63,47 @@ pub enum ExitDecision { Hide, /// Hold the exit and drain for this reason; the exit is requested again when the drain reports. Drain(ExitReason), - /// A drain is already running. Hold the exit and let that one finish. + /// Something else is already draining. Hold the exit and let that one finish. Wait, - /// The drain has reported. Let the process end. + /// The drain has reported success. Let the process end. Proceed, + /// The drain did not complete, and this reason is one that may not proceed on that. + Refuse, +} + +/// What a drain established. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DrainVerdict { + /// The runtime this app owned is gone, or there was never one of ours to stop. + Drained, + /// The stop was refused, or the endpoint still answered after the deadline. + Failed, + /// The process answering could not be identified, so nothing was stopped. + OwnershipUnknown, +} + +/// Whether an update may start replacing files. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestartReadiness { + /// The runtime is confirmed stopped. The install may proceed. + Ready, + /// The drain did not complete, so the install must not start. + DrainFailed, + /// Who owns the runtime could not be established. + OwnershipUnknown, + /// Something else is already ending or stopping the runtime. + Busy, +} + +impl RestartReadiness { + pub fn describe(self) -> &'static str { + match self { + Self::Ready => "the runtime is stopped", + Self::DrainFailed => "the runtime did not stop", + Self::OwnershipUnknown => "the running proxy could not be identified", + Self::Busy => "the app is already stopping its runtime", + } + } } /// Decide what an exit request means. @@ -67,8 +113,15 @@ pub enum ExitDecision { /// a quit and takes the same graceful drain rather than leaving a running process unreachable. pub fn decide(phase: ExitPhase, reason: Option, hides_to_tray: bool) -> ExitDecision { match phase { - ExitPhase::Spawning | ExitPhase::Draining => ExitDecision::Wait, + ExitPhase::Spawning | ExitPhase::Stopping | ExitPhase::Draining => ExitDecision::Wait, ExitPhase::Drained => ExitDecision::Proceed, + // A quit that could not drain still closes the app: refusing to close is the worse answer + // and the runtime is recoverable. A restart is a different judgement — it would come back + // attached to a runtime that was never stopped, under a user who believes they upgraded. + ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => match reason { + Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse, + _ => ExitDecision::Proceed, + }, ExitPhase::Idle => match reason { Some(reason) => ExitDecision::Drain(reason), None if hides_to_tray => ExitDecision::Hide, @@ -81,7 +134,7 @@ struct Inner { phase: ExitPhase, reason: Option, hides_to_tray: bool, - /// An exit that arrived while a runtime was being started, and still has to happen. + /// An exit that arrived while a runtime was being started or stopped, and still has to happen. deferred: bool, } @@ -108,21 +161,11 @@ impl ExitCoordinator { self.inner.lock().unwrap_or_else(PoisonError::into_inner) } - /// Record the session's tray verdict once the probe has answered. + /// Record the session's tray verdict once the probe has answered and an icon exists. pub fn set_tray(&self, tray: TrayAvailability) { self.inner().hides_to_tray = tray.hides_to_tray(); } - #[cfg(test)] - fn phase(&self) -> ExitPhase { - self.inner().phase - } - - #[cfg(test)] - fn hides_to_tray(&self) -> bool { - self.inner().hides_to_tray - } - pub fn decision(&self) -> ExitDecision { let inner = self.inner(); decide(inner.phase, inner.reason, inner.hides_to_tray) @@ -141,8 +184,9 @@ impl ExitCoordinator { /// would restart under a user who asked it to stop. `fallback` is only used when nothing has /// claimed a reason yet. /// - /// While a runtime is being started the answer is "not yet": the reason is recorded and the - /// drain is handed to [`ExitCoordinator::finish_spawn`], which runs once the child is ours. + /// While a runtime is being started or stopped the answer is "not yet": the reason is recorded + /// and the drain is handed to whichever of [`ExitCoordinator::finish_spawn`] or + /// [`ExitCoordinator::finish_stop`] is holding the phase. pub fn claim_drain(&self, fallback: ExitReason) -> Option { let mut inner = self.inner(); match inner.phase { @@ -151,39 +195,65 @@ impl ExitCoordinator { inner.phase = ExitPhase::Draining; Some(reason) } - ExitPhase::Spawning => { + ExitPhase::Spawning | ExitPhase::Stopping => { inner.reason.get_or_insert(fallback); inner.deferred = true; None } - ExitPhase::Draining | ExitPhase::Drained => None, + ExitPhase::Draining + | ExitPhase::Drained + | ExitPhase::DrainFailed + | ExitPhase::OwnershipUnknown => None, } } - pub fn finish_drain(&self) { - self.inner().phase = ExitPhase::Drained; + /// Record what the drain established. A failure is not a drain. + pub fn finish_drain(&self, verdict: DrainVerdict) { + self.inner().phase = match verdict { + DrainVerdict::Drained => ExitPhase::Drained, + DrainVerdict::Failed => ExitPhase::DrainFailed, + DrainVerdict::OwnershipUnknown => ExitPhase::OwnershipUnknown, + }; } - /// Reserve the right to start a runtime. False once an exit is in flight. + /// Reserve the right to start a runtime. False once something else owns the phase. /// /// The reservation exists instead of holding the lock across the spawn. Holding it would make /// the main thread's exit handler wait on process creation, so a wedged spawn would be a Quit - /// that never responds. Reserving instead keeps every lock hold short, and an exit arriving in - /// between is deferred rather than lost — which is the thing that must not happen, because a - /// quit that reads "we own nothing" leaves the child it just missed running forever. + /// that never responds. An exit arriving in between is deferred rather than lost — which is the + /// thing that must not happen, because a quit that reads "we own nothing" leaves the child it + /// just missed running forever. pub fn begin_spawn(&self) -> bool { + self.begin(ExitPhase::Spawning) + } + + /// Release the spawn reservation, handing back a reason that arrived meanwhile. + pub fn finish_spawn(&self) -> Option { + self.finish(ExitPhase::Spawning) + } + + /// Reserve the runtime for a stop that does not end the app. + pub fn begin_stop(&self) -> bool { + self.begin(ExitPhase::Stopping) + } + + /// Release the stop, handing back a reason that arrived meanwhile. + pub fn finish_stop(&self) -> Option { + self.finish(ExitPhase::Stopping) + } + + fn begin(&self, phase: ExitPhase) -> bool { let mut inner = self.inner(); if inner.phase != ExitPhase::Idle { return false; } - inner.phase = ExitPhase::Spawning; + inner.phase = phase; true } - /// Release the reservation. Returns the reason to drain for when an exit arrived meanwhile. - pub fn finish_spawn(&self) -> Option { + fn finish(&self, phase: ExitPhase) -> Option { let mut inner = self.inner(); - if inner.phase != ExitPhase::Spawning { + if inner.phase != phase { return None; } if inner.deferred { @@ -195,6 +265,16 @@ impl ExitCoordinator { inner.phase = ExitPhase::Idle; None } + + #[cfg(test)] + fn phase(&self) -> ExitPhase { + self.inner().phase + } + + #[cfg(test)] + fn hides_to_tray(&self) -> bool { + self.inner().hides_to_tray + } } impl Default for ExitCoordinator { @@ -215,7 +295,7 @@ pub fn gesture(app: &AppHandle) { match coordinator.decision() { ExitDecision::Hide => hide_windows(app), ExitDecision::Drain(reason) => start_drain(app, reason), - ExitDecision::Wait | ExitDecision::Proceed => {} + ExitDecision::Wait | ExitDecision::Proceed | ExitDecision::Refuse => {} } } @@ -227,23 +307,56 @@ pub fn request(app: &AppHandle, reason: ExitReason) { app.exit(0); } -/// Ask the app to drain and come back, which is what an installed update needs. +/// Stop the runtime without ending the app: the tray's Stop item. /// -/// This does not go through [`request`]. `AppHandle::restart` ignores `prevent_exit`, so the event -/// loop cannot hold a restart long enough to drain inside it; the drain has to happen first and -/// issue the restart itself, which is what [`start_drain`] does for this reason. -pub fn request_restart(app: &AppHandle) { - if let Some(coordinator) = app.try_state::() { - coordinator.claim(ExitReason::CoordinatedRestart); +/// It takes the same phase the quit path takes, so pressing Stop twice, or Stop and then Quit, or +/// Stop during an update, is one execution rather than two racing over one child. Unlike a quit it +/// returns the coordinator to idle, because the app is still running and may start a runtime again. +pub fn request_stop(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(claimed) = app + .try_state::() + .map(|coordinator| coordinator.begin_stop()) + else { + return; + }; + if !claimed { + return; + } + let verdict = drain_current(&app).await; + if verdict == DrainVerdict::Drained { + if let Some(state) = app.try_state::() { + state.release(); + } + crate::tray::set_owned(&app, false); + } else { + crate::logging::log_once("the runtime could not be stopped", verdict.describe()); + } + let deferred = app + .try_state::() + .and_then(|coordinator| coordinator.finish_stop()); + if let Some(reason) = deferred { + drain_now(&app, reason); + } + }); +} + +impl DrainVerdict { + pub fn describe(self) -> &'static str { + match self { + Self::Drained => "the runtime is stopped", + Self::Failed => "the stop was refused or the runtime still answered", + Self::OwnershipUnknown => "the running proxy could not be identified", + } } - start_drain(app, ExitReason::CoordinatedRestart); } /// Handle `RunEvent::ExitRequested`. pub fn on_exit_requested(app: &AppHandle, code: Option, api: &ExitRequestApi) { // `AppHandle::restart` documents that `prevent_exit` is ignored for its own exit code, so a - // restart cannot be held here even to drain. The updater therefore drains before it restarts, - // and this branch only records the reason so nothing downstream reads the restart as a quit. + // restart cannot be held here even to drain. The update path therefore drains before it + // restarts, and this branch only records the reason so nothing reads the restart as a quit. if code == Some(tauri::RESTART_EXIT_CODE) { if let Some(coordinator) = app.try_state::() { coordinator.claim(ExitReason::CoordinatedRestart); @@ -259,6 +372,7 @@ pub fn on_exit_requested(app: &AppHandle, code: Option, api: &ExitRequestAp hide_windows(app); } ExitDecision::Wait => api.prevent_exit(), + ExitDecision::Refuse => api.prevent_exit(), ExitDecision::Drain(reason) => { api.prevent_exit(); start_drain(app, reason); @@ -267,17 +381,7 @@ pub fn on_exit_requested(app: &AppHandle, code: Option, api: &ExitRequestAp } } -/// Drain an app-owned runtime and then ask to end again. -/// -/// Nothing kills the child. The old path did, with `CommandChild::kill()`, and that is a SIGKILL on -/// Unix: it cut off the in-flight requests, the client-configuration restore and the state-file -/// clearing that the CLI's own stop performs. -/// -/// A drain that does not complete is reported and the exit still proceeds. That is a deliberate -/// trade and it has a cost: the runtime this app started can outlive it. The alternative is -/// refusing to quit when the user asked to, on a window that is showing the dashboard and has -/// nowhere to explain itself, and a runtime left standing is recoverable with `ocx stop` while a -/// half-restored client configuration is not. +/// Drain and then ask to end again. pub fn start_drain(app: &AppHandle, reason: ExitReason) { let Some(coordinator) = app.try_state::() else { return; @@ -288,35 +392,129 @@ pub fn start_drain(app: &AppHandle, reason: ExitReason) { drain_now(app, reason); } -/// Run the drain for a reason the coordinator has already been moved to draining for. -/// -/// The only other caller is the startup sequence, which reaches draining through -/// [`ExitCoordinator::finish_spawn`] when a quit arrived while it was starting a runtime. +/// Run the drain for a reason the coordinator has already moved to draining for. pub fn drain_now(app: &AppHandle, reason: ExitReason) { let app = app.clone(); tauri::async_runtime::spawn(async move { - let owned = app - .try_state::() - .map(|state| (state.proxy(), state.owns_runtime(), state.watch.clone())); - if let Some((Some(proxy), owned, watch)) = owned { - let outcome = sidecar::drain(&proxy, owned, &watch).await; - match outcome.failure() { - None => { - if let Some(state) = app.try_state::() { - state.release(); - } - } - Some(error) => crate::logging::log_once("graceful stop did not complete", &error), - } + finish_and_exit_after(&app, reason).await; + }); +} + +async fn finish_and_exit_after(app: &AppHandle, reason: ExitReason) { + let verdict = drain_current(app).await; + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(verdict); + } + match (reason, verdict) { + (ExitReason::UserQuit, _) => app.exit(0), + (ExitReason::CoordinatedRestart, DrainVerdict::Drained) => { + app.restart(); } - if let Some(coordinator) = app.try_state::() { - coordinator.finish_drain(); + (ExitReason::CoordinatedRestart, _) => { + crate::logging::log_once("the update restart was refused", verdict.describe()); } - match reason { - ExitReason::UserQuit => app.exit(0), - ExitReason::CoordinatedRestart => app.restart(), + } +} + +/// Prepare for an update's restart: confirm ownership, drain, and confirm the child is gone. +/// +/// This is awaited rather than fired and forgotten, because the pinned updater's Windows install +/// ends the process itself. A restart asked for after `install` returns is a restart that never +/// happens there, so the stop has to be finished before the installer is started at all. +pub async fn prepare_restart(app: &AppHandle) -> RestartReadiness { + let Some(reason) = app + .try_state::() + .and_then(|coordinator| coordinator.claim_drain(ExitReason::CoordinatedRestart)) + else { + return RestartReadiness::Busy; + }; + if reason != ExitReason::CoordinatedRestart { + // A quit claimed the exit first. It owns the drain now, and the update does not install + // into an app that is on its way out. + drain_now(app, reason); + return RestartReadiness::Busy; + } + let verdict = drain_current(app).await; + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(verdict); + } + match verdict { + DrainVerdict::Drained => RestartReadiness::Ready, + DrainVerdict::Failed => RestartReadiness::DrainFailed, + DrainVerdict::OwnershipUnknown => RestartReadiness::OwnershipUnknown, + } +} + +/// Come back, once the installer has finished and returned. +pub fn complete_restart(app: &AppHandle) -> ! { + app.restart() +} + +/// Stop the runtime this app owns and confirm it is gone. +/// +/// Ownership is re-established here rather than read off a flag. A flag set when the child was +/// spawned says nothing about the process answering the endpoint now: the child can have exited and +/// a service can have taken the port back. Sending an owner's stop to that listener is sending it +/// to somebody else's runtime, so the pid is checked first and a listener that cannot be identified +/// is left alone. +/// +/// Nothing kills the child. The old path did, with `CommandChild::kill()`, and that is a SIGKILL on +/// Unix: it cut off the in-flight requests, the client-configuration restore and the state-file +/// clearing that the CLI's own stop performs. +pub async fn drain_current(app: &AppHandle) -> DrainVerdict { + let Some((proxy, child_pid, watch)) = app + .try_state::() + .map(|state| (state.proxy(), state.child_pid(), state.watch.clone())) + else { + return DrainVerdict::OwnershipUnknown; + }; + let Some(proxy) = proxy else { + // Nothing resolved, so there is nothing of ours listening anywhere. + return DrainVerdict::Drained; + }; + let Some(child_pid) = child_pid else { + // This app never started a runtime, so it does not stop one. + return DrainVerdict::Drained; + }; + match confirm(&proxy, child_pid, &watch).await { + Ownership::Gone => DrainVerdict::Drained, + Ownership::Foreign => DrainVerdict::Drained, + Ownership::Unknown => DrainVerdict::OwnershipUnknown, + Ownership::Ours => match sidecar::drain(&proxy, true, &watch).await.failure() { + None => DrainVerdict::Drained, + Some(error) => { + crate::logging::log_once("graceful stop did not complete", &error); + DrainVerdict::Failed + } + }, + } +} + +enum Ownership { + /// The process answering is the child this app started. + Ours, + /// Something else holds the port. + Foreign, + /// Nothing is listening, and the child has reported its own exit. + Gone, + /// The listener could not be identified. + Unknown, +} + +async fn confirm(proxy: &ProxyClient, child_pid: u32, watch: &sidecar::SidecarWatch) -> Ownership { + match proxy.identify().await { + Ok(identity) if identity.pid == child_pid => Ownership::Ours, + Ok(_) => Ownership::Foreign, + Err(error) if error.is_unreachable() => { + // Nothing is listening. That is only proof the child is gone if the child said so. + if watch.exit().is_some() { + Ownership::Gone + } else { + Ownership::Unknown + } } - }); + Err(_) => Ownership::Unknown, + } } fn hide_windows(app: &AppHandle) { @@ -327,7 +525,10 @@ fn hide_windows(app: &AppHandle) { #[cfg(test)] mod tests { - use super::{decide, ExitCoordinator, ExitDecision, ExitPhase, ExitReason}; + use super::{ + decide, DrainVerdict, ExitCoordinator, ExitDecision, ExitPhase, ExitReason, + RestartReadiness, + }; use crate::tray_availability::TrayAvailability; #[test] @@ -352,21 +553,17 @@ mod tests { } #[test] - fn an_update_restart_keeps_its_own_reason_through_the_drain() { - assert_eq!( - decide(ExitPhase::Idle, Some(ExitReason::CoordinatedRestart), true), - ExitDecision::Drain(ExitReason::CoordinatedRestart) - ); - } - - #[test] - fn a_second_request_waits_instead_of_starting_a_second_drain() { - for hides in [true, false] { + fn every_in_flight_phase_holds_the_exit() { + for phase in [ + ExitPhase::Spawning, + ExitPhase::Stopping, + ExitPhase::Draining, + ] { assert_eq!( - decide(ExitPhase::Draining, Some(ExitReason::UserQuit), hides), + decide(phase, Some(ExitReason::UserQuit), true), ExitDecision::Wait ); - assert_eq!(decide(ExitPhase::Draining, None, hides), ExitDecision::Wait); + assert_eq!(decide(phase, None, false), ExitDecision::Wait); } } @@ -378,6 +575,39 @@ mod tests { ); } + #[test] + fn a_quit_tolerates_a_failed_drain_and_a_restart_refuses_it() { + for phase in [ExitPhase::DrainFailed, ExitPhase::OwnershipUnknown] { + assert_eq!( + decide(phase, Some(ExitReason::UserQuit), true), + ExitDecision::Proceed + ); + assert_eq!( + decide(phase, Some(ExitReason::CoordinatedRestart), true), + ExitDecision::Refuse + ); + } + } + + #[test] + fn a_failed_drain_is_not_recorded_as_a_drain() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::Failed); + assert_eq!(coordinator.phase(), ExitPhase::DrainFailed); + // The restart that asked for it does not get to proceed on that. + assert_eq!(coordinator.decision(), ExitDecision::Refuse); + } + + #[test] + fn an_unidentified_runtime_is_its_own_state() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::OwnershipUnknown); + assert_eq!(coordinator.phase(), ExitPhase::OwnershipUnknown); + assert_eq!(coordinator.decision(), ExitDecision::Refuse); + } + #[test] fn the_first_claimed_reason_wins_the_drain() { let coordinator = ExitCoordinator::new(); @@ -397,33 +627,45 @@ mod tests { Some(ExitReason::UserQuit) ); assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); - coordinator.finish_drain(); + coordinator.finish_drain(DrainVerdict::Drained); assert_eq!(coordinator.phase(), ExitPhase::Drained); assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); } #[test] - fn a_runtime_is_not_started_once_an_exit_is_in_flight() { + fn a_stop_and_a_spawn_both_hold_the_runtime_alone() { + for begin in [ExitPhase::Spawning, ExitPhase::Stopping] { + let coordinator = ExitCoordinator::new(); + let started = match begin { + ExitPhase::Spawning => coordinator.begin_spawn(), + _ => coordinator.begin_stop(), + }; + assert!(started); + assert!(!coordinator.begin_spawn()); + assert!(!coordinator.begin_stop()); + assert_eq!(coordinator.decision(), ExitDecision::Wait); + } + } + + #[test] + fn a_quit_during_a_stop_is_deferred_rather_than_lost() { let coordinator = ExitCoordinator::new(); - assert!(coordinator.begin_spawn()); - assert_eq!(coordinator.finish_spawn(), None); - assert_eq!(coordinator.phase(), ExitPhase::Idle); - coordinator.claim_drain(ExitReason::UserQuit); - assert!(!coordinator.begin_spawn()); + assert!(coordinator.begin_stop()); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + assert_eq!(coordinator.phase(), ExitPhase::Stopping); + assert_eq!(coordinator.finish_stop(), Some(ExitReason::UserQuit)); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + assert_eq!(coordinator.finish_stop(), None); } #[test] fn a_quit_during_a_spawn_is_deferred_rather_than_lost() { let coordinator = ExitCoordinator::new(); assert!(coordinator.begin_spawn()); - // The exit handler holds the exit rather than letting the process end mid-spawn. assert_eq!(coordinator.decision(), ExitDecision::Wait); assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); - assert_eq!(coordinator.phase(), ExitPhase::Spawning); - // The child is ours by now, so the deferred quit becomes the drain. assert_eq!(coordinator.finish_spawn(), Some(ExitReason::UserQuit)); assert_eq!(coordinator.phase(), ExitPhase::Draining); - assert_eq!(coordinator.finish_spawn(), None); } #[test] @@ -440,6 +682,19 @@ mod tests { ); } + #[test] + fn an_undrained_runtime_is_never_reported_as_ready_to_install_over() { + assert_eq!(RestartReadiness::Ready.describe(), "the runtime is stopped"); + for refused in [ + RestartReadiness::DrainFailed, + RestartReadiness::OwnershipUnknown, + RestartReadiness::Busy, + ] { + assert_ne!(refused, RestartReadiness::Ready); + assert!(!refused.describe().is_empty()); + } + } + #[test] fn the_tray_verdict_replaces_the_platform_assumption() { let coordinator = ExitCoordinator::new(); diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 0d8e16b653f..57923e19340 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -2,7 +2,7 @@ use crate::{ exit::{self, ExitReason}, formatting, proxy::ProxyClient, - sidecar, updater, widget, window, + updater, widget, window, }; use serde_json::Value; use std::sync::{ @@ -135,30 +135,9 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { } } "stop-proxy" => { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let pieces = app - .try_state::() - .map(|state| (state.proxy(), state.owns_runtime(), state.watch.clone())); - let Some((Some(proxy), owned, watch)) = pieces else { - return; - }; - // The same drain the quit path takes: ask the runtime to stop, then confirm - // that it actually has. The previous version accepted an unreachable endpoint - // as proof and then killed the child anyway. - let outcome = sidecar::drain(&proxy, owned, &watch).await; - match outcome.failure() { - None => { - if let Some(state) = app.try_state::() { - state.release(); - } - set_owned(&app, false); - } - Some(error) => { - crate::logging::log_once("graceful stop did not complete", &error) - } - } - }); + // Through the coordinator, not beside it: Stop pressed twice, Stop then Quit, and + // Stop during an update all have to be one execution over one child. + exit::request_stop(app); } "check-updates" => { let app = app.clone(); diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs index 6137afa99d5..456ffe76c5c 100644 --- a/desktop/src-tauri/src/updater.rs +++ b/desktop/src-tauri/src/updater.rs @@ -1,4 +1,4 @@ -use crate::{logging, tray}; +use crate::{exit::RestartReadiness, logging, tray}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; use tauri_plugin_updater::{Update, UpdaterExt}; @@ -48,16 +48,30 @@ pub async fn check(app: &AppHandle) -> Result, String> { } pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> { - update - .download_and_install(|_, _| {}, || {}) + // Download and verify first, and separately from installing. The pinned updater checks the + // release signature inside `download`, so these bytes are the ones the key signed; nothing has + // been replaced yet, and a failure here costs only the download. + let package = update + .download(|_, _| {}, || {}) .await .map_err(|error| error.to_string())?; - // R2: an update restart is a coordinated restart, not a quit. D2 forbids an *uncoordinated* - // exit, and `AppHandle::restart` was exactly that — it ran straight into the hard kill of the - // runtime this app owns. Going through the exit coordinator runs the same graceful drain the - // tray's Quit runs, and then the app comes back. - crate::exit::request_restart(app); - Ok(()) + + // Then stop the runtime, and confirm it stopped, *before* anything is replaced. Asking for the + // restart after `install` is the shape that does not work: the pinned Windows installer hands off + // to the installer process and ends this one, so the call after it is never reached and the + // update would replace files under a runtime that is still serving. R2 still holds — this is a + // coordinated restart and not a quit — but the coordination has to finish first. + let readiness = crate::exit::prepare_restart(app).await; + if readiness != RestartReadiness::Ready { + return Err(format!( + "the update was downloaded but not installed: {}", + readiness.describe() + )); + } + + update.install(package).map_err(|error| error.to_string())?; + // Only reached where the installer returns. On Windows it does not. + crate::exit::complete_restart(app) } pub fn update_label(version: &str) -> String { From 5e642056d3cfb41d4c5c5aabb1d77c98cef41946 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:48:17 +0900 Subject: [PATCH 12/24] fix(desktop): confirm the instance before owning it or sending it a credential Ownership of the running process was a bool set when the child was spawned, and attaching to a different proxy left it set. A child that dies and an npm service that takes the port back gives the combination the audit named: the connection is somebody else's runtime and the flag still says ours, and Stop or Quit then sends an owner's stop to it. Durable consent and current process ownership are now separate facts. Consent stays in the recorded claim; ownership is re-established each time from the pid the endpoint reports, and an answer that cannot be read leaves the app owning nothing. The same unauthenticated health body settles who the management token may be sent to. It carries the marker, the pid and the port, so the client confirms the instance before the credential rather than sending it to whatever holds the port, and a request is bound to that pid, that port and the generation it was authorised under. The client also refuses redirects - the pinned reqwest does not treat this custom header as sensitive, so it would carry across a hop - and refuses system proxies. This is the local management client only; the updater's download client keeps its own policy. Two smaller ones in the same area. The Windows app origin is allowed: the pinned Tauri serves the app from tauri.localhost there because wry needs an http origin, and without it the window's first navigation to its own page went to the external browser. That exact host with no port, not localhost generally. And the budget for finding an existing runtime is counted from when probing starts rather than from process start, so a slow tray or session-bus registration cannot spend it and turn into "nothing is listening", which starts a second proxy beside the one already there. --- desktop/src-tauri/src/lib.rs | 41 +++++++- desktop/src-tauri/src/proxy.rs | 155 +++++++++++++++++++++++++++++-- desktop/src-tauri/src/startup.rs | 28 +++++- desktop/src-tauri/src/window.rs | 65 ++++++++----- 4 files changed, 253 insertions(+), 36 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 463b79d6cce..ca803b39a0e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -31,8 +31,17 @@ pub struct AppState { /// Absent until the startup sequence has resolved a home and a port. Nothing guesses an /// endpoint any more, so there is no client to hand out before that. proxy: Mutex>, - spawned_by_us: AtomicBool, child: Mutex>, + /// The pid of the child this app started, if it started one. + child_pid: Mutex>, + /// Whether the process answering the endpoint has been confirmed to be that child. + /// + /// Durable consent and current process ownership are different facts. Consent is a recorded + /// claim that survives restarts; this is a statement about the process on the other end of the + /// endpoint right now, and it has to be re-established whenever the endpoint or the answering + /// process can have changed. Carrying a bool across an attach is how a retry that lands on a + /// foreign runtime would still send it an owner's stop. + confirmed: AtomicBool, /// The consumed spawn event stream of the child, if this app started one. pub watch: sidecar::SidecarWatch, } @@ -41,8 +50,9 @@ impl AppState { pub fn new() -> Self { Self { proxy: Mutex::new(None), - spawned_by_us: AtomicBool::new(false), child: Mutex::new(None), + child_pid: Mutex::new(None), + confirmed: AtomicBool::new(false), watch: sidecar::SidecarWatch::default(), } } @@ -55,17 +65,37 @@ impl AppState { Self::slot(&self.proxy).clone() } + /// Point at a runtime. Nothing is owned until it is confirmed again. pub fn attach(&self, proxy: proxy::ProxyClient) { + self.confirmed.store(false, Ordering::Release); *Self::slot(&self.proxy) = Some(proxy); } pub fn owns_runtime(&self) -> bool { - self.spawned_by_us.load(Ordering::Acquire) + self.confirmed.load(Ordering::Acquire) + } + + pub fn child_pid(&self) -> Option { + *Self::slot(&self.child_pid) + } + + /// Confirm that the instance answering is the child this app started. + /// + /// This is the only thing that grants ownership. A spawn records a pid; it does not record that + /// the pid is what holds the port, because between the two the child can exit and a service can + /// take the port back. + pub fn confirm_ownership(&self, identity: proxy::RuntimeIdentity) -> bool { + let ours = self.child_pid() == Some(identity.pid); + self.confirmed.store(ours, Ordering::Release); + ours } pub fn adopt(&self, child: CommandChild) { + *Self::slot(&self.child_pid) = Some(child.pid()); *Self::slot(&self.child) = Some(child); - self.spawned_by_us.store(true, Ordering::Release); + // Spawned, not yet confirmed: the health probe is what establishes that this pid is the + // one answering. + self.confirmed.store(false, Ordering::Release); } /// Let go of a runtime that has already been drained. @@ -73,7 +103,8 @@ impl AppState { /// Dropping the handle does not signal the process — the shell plugin installs no `Drop` — so /// this releases ownership without reintroducing the `kill()` that D2 removed. pub fn release(&self) { - self.spawned_by_us.store(false, Ordering::Release); + self.confirmed.store(false, Ordering::Release); + let _ = Self::slot(&self.child_pid).take(); let _ = Self::slot(&self.child).take(); } } diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index e0dbdb2ebf4..5b522db745e 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -1,14 +1,42 @@ use crate::{auth::Auth, discovery::ProxyEndpoint}; -use reqwest::{Client, Method, StatusCode}; +use reqwest::{redirect, Client, Method, StatusCode}; use serde_json::Value; -use std::time::Duration; +use std::{ + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; use tokio::time::{timeout_at, Instant}; +/// Which instance answered, taken from the unauthenticated health body. +/// +/// The management token is the admin credential for this machine's proxy. Sending it to whatever +/// happens to hold the port is the thing to avoid, so identity is established first — from a +/// response that needs no credential to read — and the credential follows only if the answer is the +/// instance the shell decided to trust. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeIdentity { + pub pid: u32, + pub port: u16, +} + +/// The instance this client is bound to, and the binding it was bound under. +/// +/// The generation moves every time the shell binds to a runtime. A request authorised under an +/// earlier binding is not authorised under this one, which is what stops an in-flight management +/// call from landing on a runtime the shell rebound to in between. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeBinding { + pub identity: RuntimeIdentity, + pub generation: u64, +} + #[derive(Clone)] pub struct ProxyClient { client: Client, endpoint: ProxyEndpoint, auth: Auth, + binding: Arc>>, + generations: Arc>, } #[derive(Debug)] @@ -17,6 +45,9 @@ pub enum ProxyError { Unauthorized, Http(StatusCode), Decode(reqwest::Error), + /// The listener answered, but not as the instance this client is bound to — a foreign service + /// on the port, or a different process than the one the shell confirmed. + Foreign, } impl ProxyError { @@ -31,19 +62,46 @@ impl ProxyError { } } +/// Read an identity out of a health body. +/// +/// The marker is required: a 200 from something else on the port is not this proxy. The port is +/// required to be the one addressed, so a body describing a different listener cannot authorise a +/// credential for this one. +pub fn identity_from(body: &Value, addressed_port: u16) -> Option { + if body.get("service").and_then(Value::as_str) != Some("opencodex") { + return None; + } + let pid = u32::try_from(body.get("pid").and_then(Value::as_u64)?).ok()?; + let port = u16::try_from(body.get("port").and_then(Value::as_u64)?).ok()?; + if port != addressed_port { + return None; + } + Some(RuntimeIdentity { pid, port }) +} + impl ProxyClient { pub fn new(endpoint: ProxyEndpoint, auth: Auth) -> Result { Ok(Self { client: Client::builder() .timeout(Duration::from_secs(4)) .user_agent(Auth::user_agent()) - // The admin token attached to these requests is for loopback only. - // reqwest honours system proxy configuration by default, which would - // route the credential through whatever proxy the machine declares. + // The admin token attached to these requests is for the loopback endpoint and + // nowhere else. Two defaults would carry it off that endpoint, so both are turned + // off here rather than re-checked anywhere in the request path. + // + // A redirect is the first: the pinned client does not treat this custom credential + // header as sensitive, so it would follow the hop to wherever it pointed. + .redirect(redirect::Policy::none()) + // System proxy resolution is the second: reqwest honours system proxy + // configuration by default, which would route the credential through whatever + // proxy the machine declares and put another process between the shell and its + // own runtime. .no_proxy() .build()?, endpoint, auth, + binding: Arc::new(Mutex::new(None)), + generations: Arc::new(Mutex::new(0)), }) } @@ -51,6 +109,33 @@ impl ProxyClient { self.endpoint } + fn slot(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Bind this client to an instance, and return the binding it is now on. + pub fn bind(&self, identity: RuntimeIdentity) -> RuntimeBinding { + let mut generations = Self::slot(&self.generations); + *generations += 1; + let binding = RuntimeBinding { + identity, + generation: *generations, + }; + *Self::slot(&self.binding) = Some(binding); + binding + } + + pub fn binding(&self) -> Option { + *Self::slot(&self.binding) + } + + /// Ask the endpoint who it is, without sending anything secret. + pub async fn identify(&self) -> Result { + let response = self.send(&Method::GET, "/healthz", None).await?; + let body = decode(response).await?; + identity_from(&body, self.endpoint.port).ok_or(ProxyError::Foreign) + } + pub async fn is_alive(&self) -> Result { self.get("/healthz").await } @@ -105,13 +190,33 @@ impl ProxyClient { async fn request(&self, method: Method, path: &str) -> Result { let response = self.send(&method, path, None).await?; if response.status() == StatusCode::UNAUTHORIZED { - let token = self.auth.token().ok_or(ProxyError::Unauthorized)?; + let token = self.authorised_token().await?; let response = self.send(&method, path, Some(token)).await?; return decode(response).await; } decode(response).await } + /// The management token, but only for the instance this client is bound to. + /// + /// The binding is re-confirmed here rather than trusted from when it was made: between then and + /// now the child can have exited and something else can hold the port. A request is therefore + /// bound to a pid, a port and the generation the shell authorised, and a mismatch is refused + /// instead of being sent the credential. + async fn authorised_token(&self) -> Result { + let Some(binding) = self.binding() else { + return Err(ProxyError::Unauthorized); + }; + let identity = self.identify().await?; + if identity != binding.identity { + return Err(ProxyError::Foreign); + } + if self.binding() != Some(binding) { + return Err(ProxyError::Foreign); + } + self.auth.token().ok_or(ProxyError::Unauthorized) + } + async fn send( &self, method: &Method, @@ -141,3 +246,41 @@ async fn decode(response: reqwest::Response) -> Result { } response.json().await.map_err(ProxyError::Decode) } + +#[cfg(test)] +mod tests { + use super::{identity_from, RuntimeIdentity}; + use serde_json::json; + + #[test] + fn a_health_body_without_the_marker_is_not_this_proxy() { + let body = json!({ "status": "ok", "pid": 42, "port": 10100 }); + assert!(identity_from(&body, 10100).is_none()); + let foreign = json!({ "service": "something-else", "pid": 42, "port": 10100 }); + assert!(identity_from(&foreign, 10100).is_none()); + } + + #[test] + fn the_body_has_to_describe_the_listener_that_was_addressed() { + let body = json!({ "service": "opencodex", "pid": 42, "port": 10101 }); + assert!(identity_from(&body, 10100).is_none()); + } + + #[test] + fn a_complete_body_identifies_the_instance() { + let body = json!({ "service": "opencodex", "version": "2.61.0", "pid": 42, "port": 10100 }); + assert_eq!( + identity_from(&body, 10100), + Some(RuntimeIdentity { + pid: 42, + port: 10100 + }) + ); + } + + #[test] + fn a_body_missing_the_instance_facts_identifies_nothing() { + assert!(identity_from(&json!({ "service": "opencodex", "port": 10100 }), 10100).is_none()); + assert!(identity_from(&json!({ "service": "opencodex", "pid": 42 }), 10100).is_none()); + } +} diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 8c223bdae5e..fbace293256 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -383,13 +383,19 @@ async fn run(app: &AppHandle) { ); report(app, started, Phase::Probing, None); - if healthy_by(&proxy, (started + ATTACH_BUDGET).min(deadline)).await { + // The budget for an existing runtime is counted from here, not from the start of the sequence. + // Counted from the start, the registration above can spend it — and a tray or session-bus + // registration that took its time would then present as "nothing is listening", which starts a + // second proxy next to the one that was already there. + let probing_from = Instant::now(); + if healthy_by(&proxy, (probing_from + ATTACH_BUDGET).min(deadline)).await { report( app, started, Phase::Attaching, Some("a runtime was already listening, so this app is a guest on it".to_owned()), ); + bind(app, &proxy, deadline).await; finish(app, started, endpoint); return; } @@ -433,6 +439,7 @@ async fn run(app: &AppHandle) { report(app, started, Phase::Waiting, None); while Instant::now() < deadline { if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { + bind(app, &proxy, deadline).await; finish(app, started, endpoint); return; } @@ -599,7 +606,26 @@ async fn healthy_by(proxy: &ProxyClient, deadline: Instant) -> bool { } } +/// Establish which instance is answering, and whether it is the child this app started. +/// +/// The health body is unauthenticated and carries the marker, the pid and the port, so identity is +/// settled before any credential is sent. It is also the only thing that grants process ownership: +/// a spawn records a pid, and this is what says that pid is the one holding the port. An answer +/// that cannot be read leaves the app owning nothing, which is the safe way round — an owner's stop +/// sent to a listener that is not ours is a stop sent to somebody else's runtime. +async fn bind(app: &AppHandle, proxy: &ProxyClient, deadline: Instant) { + let identity = match tokio::time::timeout_at(deadline, proxy.identify()).await { + Ok(Ok(identity)) => identity, + _ => return, + }; + proxy.bind(identity); + if let Some(state) = app.try_state::() { + state.confirm_ownership(identity); + } +} + fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { + // Ownership is whatever the confirmation above established, not whatever a spawn assumed. crate::tray::set_owned( app, app.try_state::() diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 7dc7b07fda8..a641f7b0314 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -58,13 +58,20 @@ pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool { } } -/// The bundled `frontendDist` origin. Tauri serves it as `tauri://localhost` on macOS and -/// Linux, and as `http://tauri.localhost` on Windows, where WebView2 has no custom-scheme -/// support. +/// The bundled `frontendDist` origin. +/// +/// Tauri serves it as `tauri://localhost` on macOS and Linux, and as `http://tauri.localhost` on +/// Windows, where WebView2 has no custom-scheme support. Without that second spelling the window's +/// first navigation to its own page on Windows falls through to the branch that hands a URL to the +/// external browser. +/// +/// It is that one host and nothing near it. `https` is not the scheme the pinned Tauri serves the +/// app over, and a port means something else is answering rather than the app — neither localhost +/// generally, nor a name that merely ends in it, is this origin. fn is_app_origin(url: &Url) -> bool { match url.scheme() { "tauri" => true, - "http" => url.host_str() == Some("tauri.localhost"), + "http" => url.host_str() == Some("tauri.localhost") && url.port().is_none(), _ => false, } } @@ -100,29 +107,39 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::{is_app_origin, navigation_allowed, webview_user_agent}; - use crate::discovery::ProxyEndpoint; + use super::{is_app_origin, webview_user_agent}; use tauri::Url; + fn url(value: &str) -> Url { + Url::parse(value).expect("a url") + } + #[test] - fn navigation_allows_the_app_origin_on_every_platform() { - let allowed = navigation_allowed(ProxyEndpoint { - host: "127.0.0.1", - port: 10100, - }); - assert!(allowed( - &Url::parse("tauri://localhost/index.html?port=10100").unwrap() - )); - assert!(allowed( - &Url::parse("http://tauri.localhost/index.html?port=10100").unwrap() - )); - assert!(allowed( - &Url::parse("http://127.0.0.1:10100/#/usage").unwrap() - )); - assert!(!is_app_origin( - &Url::parse("https://tauri.localhost/index.html").unwrap() - )); - assert!(!allowed(&Url::parse("file:///C:/index.html").unwrap())); + fn the_app_origin_is_allowed_by_both_spellings_on_every_platform() { + // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. The + // second is not gated on the platform: the origin is the app's wherever it is served. + assert!(is_app_origin(&url("tauri://localhost/index.html?port=10100"))); + assert!(is_app_origin(&url( + "http://tauri.localhost/index.html?port=10100" + ))); + } + + #[test] + fn nothing_near_that_origin_is_that_origin() { + for value in [ + // Not the scheme the pinned Tauri serves the app over. + "https://tauri.localhost/index.html", + // A port means something else is answering. + "http://tauri.localhost:8080/", + // Neither localhost generally nor a name that merely contains it. + "http://localhost/", + "http://127.0.0.1/", + "http://evil.tauri.localhost/", + "http://tauri.localhost.example.com/", + "file:///C:/index.html", + ] { + assert!(!is_app_origin(&url(value)), "{value}"); + } } #[test] From 56bf8f971d28d23fe851b089d61b381c39d3a62e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:48:17 +0900 Subject: [PATCH 13/24] test(desktop): pin the update order, the failure states and the instance check The order inside the update, which states a restart refuses, that Stop and Quit share one execution, that ownership comes from the answering pid, and that the credential follows the confirmation rather than the other way round. The Windows origin case asserts what is not allowed as well as what is, since the risk there is width rather than absence. --- scripts/test-layout/layout.json | 1 + tests/clients/desktop-exit-ownership.test.ts | 75 ++++++++-- .../clients/desktop-runtime-identity.test.ts | 135 ++++++++++++++++++ tests/clients/desktop-startup-surface.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 1 + 5 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 tests/clients/desktop-runtime-identity.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60bf811fb00..cc3333d4d4f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -727,6 +727,7 @@ "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index 88f069fe0b1..06e434ec680 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -83,17 +83,64 @@ describe("desktop exit ownership", () => { expect(exit).toContain("ExitReason::CoordinatedRestart"); expect(exit).toContain("tauri::RESTART_EXIT_CODE"); const updater = code(UPDATER); - expect(updater).toContain("exit::request_restart(app)"); + expect(updater).toContain("crate::exit::prepare_restart(app).await"); expect(updater).not.toContain("app.restart()"); }); + test("an update stops the runtime before it replaces anything", () => { + const updater = code(UPDATER); + const install = updater.slice(updater.indexOf("pub async fn install(")); + const body = install.slice(0, install.indexOf("\n}")); + const downloaded = body.indexOf(".download("); + const prepared = body.indexOf("crate::exit::prepare_restart(app).await"); + const installed = body.indexOf("update.install(package)"); + expect(downloaded).toBeGreaterThan(-1); + expect(prepared).toBeGreaterThan(downloaded); + expect(installed).toBeGreaterThan(prepared); + // The combined call is the shape that cannot drain first. + expect(body).not.toContain("download_and_install"); + // A drain that did not complete refuses the install rather than proceeding. + expect(body.slice(prepared, installed)).toContain("if readiness != RestartReadiness::Ready {"); + expect(body.slice(prepared, installed)).toContain("return Err("); + }); + test("an update restart drains through the same path a quit does", () => { const exit = code(EXIT); - const restart = exit.indexOf("pub fn request_restart"); - expect(restart).toBeGreaterThan(-1); - const body = exit.slice(restart, exit.indexOf("\n}", restart)); - expect(body).toContain("start_drain(app, ExitReason::CoordinatedRestart)"); - expect(exit).toContain("sidecar::drain(&proxy, owned, &watch)"); + const prepare = exit.indexOf("pub async fn prepare_restart"); + expect(prepare).toBeGreaterThan(-1); + const body = exit.slice(prepare, exit.indexOf("pub fn complete_restart", prepare)); + expect(body).toContain("claim_drain(ExitReason::CoordinatedRestart)"); + expect(body).toContain("drain_current(app).await"); + expect(body).toContain("coordinator.finish_drain(verdict)"); + expect(exit).toContain("sidecar::drain(&proxy, true, &watch)"); + }); + + test("a failed drain is not recorded as a drain, and a restart refuses it", () => { + const exit = code(EXIT); + const record = exit.slice(exit.indexOf("pub fn finish_drain(")); + const body = record.slice(0, record.indexOf("\n }")); + expect(body).toContain("DrainVerdict::Drained => ExitPhase::Drained"); + expect(body).toContain("DrainVerdict::Failed => ExitPhase::DrainFailed"); + expect(body).toContain("DrainVerdict::OwnershipUnknown => ExitPhase::OwnershipUnknown"); + const rule = exit.slice(exit.indexOf("pub fn decide("), exit.indexOf("struct Inner {")); + expect(rule).toContain("ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => match reason"); + expect(rule).toContain("Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse"); + // A quit still closes the app on one, which is the trade that is defensible. + expect(rule).toContain("_ => ExitDecision::Proceed"); + }); + + test("stop, quit and update are one execution over one child", () => { + const tray = code(TRAY); + expect(tray).toContain("exit::request_stop(app)"); + expect(tray).not.toContain("sidecar::drain"); + const exit = code(EXIT); + const stop = exit.slice(exit.indexOf("pub fn request_stop(")); + const body = stop.slice(0, stop.indexOf("\n}")); + expect(body).toContain("coordinator.begin_stop()"); + expect(body).toContain("drain_current(&app).await"); + // A quit that landed during the stop is handed back and run, not dropped. + expect(body).toContain("coordinator.finish_stop()"); + expect(body).toContain("drain_now(&app, reason)"); }); test("the reason and the drain are claimed in one step", () => { @@ -107,7 +154,10 @@ describe("desktop exit ownership", () => { expect(body).toContain("match inner.phase {"); const idle = body.indexOf("ExitPhase::Idle => {"); expect(idle).toBeGreaterThan(-1); - const arm = body.slice(idle, body.indexOf("ExitPhase::Spawning => {", idle)); + const arm = body.slice( + idle, + body.indexOf("ExitPhase::Spawning | ExitPhase::Stopping => {", idle), + ); expect(arm).toContain("inner.reason.get_or_insert(fallback)"); expect(arm).toContain("inner.phase = ExitPhase::Draining;"); // Nothing else in the file moves the phase to draining. @@ -119,11 +169,16 @@ describe("desktop exit ownership", () => { // The lock is never held across process creation, so the main thread's exit handler cannot // end up waiting on a spawn; the exit is held by the phase instead. expect(exit).toContain("ExitPhase::Spawning"); - expect(exit).toContain("ExitPhase::Spawning | ExitPhase::Draining => ExitDecision::Wait"); + expect(exit).toContain( + "ExitPhase::Spawning | ExitPhase::Stopping | ExitPhase::Draining => ExitDecision::Wait", + ); const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); - expect(claim).toContain("ExitPhase::Spawning => {"); + expect(claim).toContain("ExitPhase::Spawning | ExitPhase::Stopping => {"); expect(claim).toContain("inner.deferred = true;"); - const finish = exit.slice(exit.indexOf("pub fn finish_spawn"), exit.indexOf("impl Default for ExitCoordinator")); + const finish = exit.slice( + exit.indexOf("fn finish(&self, phase: ExitPhase)"), + exit.indexOf("impl Default for ExitCoordinator"), + ); expect(finish).toContain("inner.deferred"); expect(finish).toContain("inner.phase = ExitPhase::Draining;"); const startup = code(repoPath(`${SRC}/startup.rs`)); diff --git a/tests/clients/desktop-runtime-identity.test.ts b/tests/clients/desktop-runtime-identity.test.ts new file mode 100644 index 00000000000..94ff93b2263 --- /dev/null +++ b/tests/clients/desktop-runtime-identity.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * Which instance the shell is talking to, and what it will send there. + * + * The management token is the admin credential for this machine's proxy, and the endpoint is a + * port a local process can take. So identity comes first, from the unauthenticated health body the + * runtime already publishes — the marker, the pid and the port — and the credential follows only + * for the instance the shell decided to trust. The same facts answer a second question the shell + * used to answer with a boolean: whether the process holding the port is the child it started. + * + * Both are read out of the source, because CI has no running proxy to address and no Windows + * webview to navigate. + */ +const SHELL = "desktop/src-tauri/src"; +const PROXY = repoPath(`${SHELL}/proxy.rs`); +const EXIT = repoPath(`${SHELL}/exit.rs`); +const LIB = repoPath(`${SHELL}/lib.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const WINDOW = repoPath(`${SHELL}/window.rs`); +const SERVE = repoPath("src/server/index/serve-options.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop runtime identity", () => { + const proxy = code(PROXY); + const exit = code(EXIT); + + test("the identity it reads is the one the runtime publishes", () => { + // Unauthenticated, so it can be read before anything secret is sent. + const health = code(SERVE); + for (const field of ['service: "opencodex"', "pid: process.pid", "port: healthPort"]) { + expect(health).toContain(field); + } + const reader = proxy.slice(proxy.indexOf("pub fn identity_from(")); + const body = reader.slice(0, reader.indexOf("\n}")); + expect(body).toContain('body.get("service")'); + expect(body).toContain('body.get("pid")'); + expect(body).toContain('body.get("port")'); + // A 200 from something else on the port is not this proxy, and a body describing a different + // listener does not authorise a credential for this one. + expect(body).toContain("if port != addressed_port {"); + }); + + test("the credential is never sent to an unconfirmed instance", () => { + const start = proxy.indexOf("async fn authorised_token("); + expect(start).toBeGreaterThan(-1); + const body = proxy.slice(start, proxy.indexOf("async fn send(", start)); + expect(body).toContain("let Some(binding) = self.binding() else"); + // Re-confirmed here, not trusted from when it was made: in between, the child can exit and + // something else can hold the port. + expect(body).toContain("let identity = self.identify().await?;"); + expect(body).toContain("if identity != binding.identity"); + expect(body).toContain("if self.binding() != Some(binding)"); + const token = body.indexOf("self.auth.token()"); + expect(token).toBeGreaterThan(body.indexOf("if self.binding() != Some(binding)")); + }); + + test("a request is bound to the pid, the port and the generation it was authorised under", () => { + expect(proxy).toContain("pub struct RuntimeIdentity {"); + expect(proxy).toContain("pub pid: u32"); + expect(proxy).toContain("pub port: u16"); + expect(proxy).toContain("pub struct RuntimeBinding {"); + expect(proxy).toContain("pub generation: u64"); + const bind = proxy.slice(proxy.indexOf("pub fn bind(")); + expect(bind.slice(0, bind.indexOf("\n }"))).toContain("*generations += 1;"); + }); + + test("the local management client refuses redirects and system proxies", () => { + const builder = proxy.slice(proxy.indexOf("Client::builder()"), proxy.indexOf(".build()?")); + expect(builder).toContain("redirect(redirect::Policy::none())"); + expect(builder).toContain(".no_proxy()"); + }); + + test("attaching to a runtime does not carry ownership of the last one", () => { + const lib = code(LIB); + const attach = lib.slice(lib.indexOf("pub fn attach(")); + const body = attach.slice(0, attach.indexOf("\n }")); + expect(body).toContain("self.confirmed.store(false, Ordering::Release)"); + // A spawn records a pid; it does not record that the pid is the one holding the port. + const adopt = lib.slice(lib.indexOf("pub fn adopt(")); + expect(adopt.slice(0, adopt.indexOf("\n }"))).toContain( + "self.confirmed.store(false, Ordering::Release)", + ); + const confirm = lib.slice(lib.indexOf("pub fn confirm_ownership(")); + expect(confirm.slice(0, confirm.indexOf("\n }"))).toContain( + "self.child_pid() == Some(identity.pid)", + ); + }); + + test("ownership is confirmed from the answering pid before anything is stopped", () => { + const start = exit.indexOf("async fn confirm("); + expect(start).toBeGreaterThan(-1); + const body = exit.slice(start, exit.indexOf("fn hide_windows(", start)); + expect(body).toContain("identity.pid == child_pid => Ownership::Ours"); + expect(body).toContain("Ok(_) => Ownership::Foreign"); + // Nothing listening is only proof the child is gone if the child said so. + expect(body).toContain("watch.exit().is_some()"); + expect(body).toContain("Ownership::Unknown"); + const drain = exit.slice(exit.indexOf("pub async fn drain_current(")); + const drainBody = drain.slice(0, drain.indexOf("\nenum Ownership")); + expect(drainBody).toContain("Ownership::Foreign => DrainVerdict::Drained"); + expect(drainBody).toContain("Ownership::Unknown => DrainVerdict::OwnershipUnknown"); + }); + + test("the startup sequence is what grants ownership, and only on a readable answer", () => { + const startup = code(STARTUP); + const bind = startup.slice(startup.indexOf("async fn bind(")); + const body = bind.slice(0, bind.indexOf("\n}")); + expect(body).toContain("proxy.identify()"); + expect(body).toContain("proxy.bind(identity)"); + expect(body).toContain("state.confirm_ownership(identity)"); + // An answer that cannot be read leaves the app owning nothing. + expect(body).toContain("_ => return,"); + }); + + test("the Windows app origin is allowed, and nothing wider", () => { + const window = code(WINDOW); + const rule = window.slice(window.indexOf("pub fn app_origin_allowed(")); + const body = rule.slice(0, rule.indexOf("\n}")); + expect(body).toContain('url.host_str() == Some("tauri.localhost")'); + expect(body).toContain("url.port().is_none()"); + expect(body).toContain("windows"); + expect(window).toContain('app_origin_allowed(url, cfg!(target_os = "windows"))'); + // Not localhost generally, and not a remote IPC widening. + expect(window).not.toContain('Some("localhost")'); + expect(readFileSync(repoPath("desktop/src-tauri/capabilities/default.json"), "utf8")).not.toContain( + "remote", + ); + }); +}); diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 6dd7499d21a..4f6ecbca416 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -97,7 +97,9 @@ describe("desktop startup surface", () => { test("one deadline covers the whole sequence and bounds every probe under it", () => { expect(startup).toContain("pub const DEADLINE: Duration"); expect(startup).toContain("let deadline = started + DEADLINE;"); - expect(startup).toContain("(started + ATTACH_BUDGET).min(deadline)"); + expect(startup).toContain("let probing_from = Instant::now();"); + expect(startup).toContain("(probing_from + ATTACH_BUDGET).min(deadline)"); + expect(startup).not.toContain("(started + ATTACH_BUDGET)"); // A probe bounded only by the client's own timeout overruns whatever budget it was started // under, which is how a stated ceiling becomes an unstated one. expect(startup).not.toContain("proxy.is_alive()"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d91c3f3c084..c7a5ad57a5d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -558,6 +558,7 @@ "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", From 2ce8507b92f88998fbd4a501ad793c6fd8ce5b83 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:48:18 +0900 Subject: [PATCH 14/24] docs(structure): record the update order, the drain verdicts and the client policy Says which failure a quit tolerates and a restart refuses, why the install waits for a confirmed stop, and that the management client has a network policy of its own separate from the updater's download client. --- structure/desktop-shell.md | 53 +++++++++++++++++++++++++++++++------- structure/overview.md | 9 ++++--- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 2f2af2fa55e..3d8fe97fb3c 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -23,7 +23,10 @@ The window is created and shown before anything is registered, resolved, probed `desktop/src-tauri/src/startup.rs` runs the whole sequence inside it as named states — registering, resolving, probing, attaching or starting, waiting, then ready or failed — under one 30-second deadline. Every probe beneath that deadline is bounded by the time left rather than by the -HTTP client's own timeout, so the ceiling is the ceiling. The failure state carries a retry, the +HTTP client's own timeout, so the ceiling is the ceiling, and the budget for finding an existing +runtime is counted from when probing starts rather than from process start — counted from the start, +a slow tray or session-bus registration would spend it and then present as nothing listening, which +starts a second proxy beside the one already there. The failure state carries a retry, the child's exit code and a copyable diagnostic naming the state, the endpoint, the configuration home and the runtime's last output; `desktop/src-tauri/src/sidecar.rs` consumes the spawn event stream into that record instead of discarding it, which is what makes an immediate sidecar exit @@ -46,15 +49,45 @@ is the quit. macOS needs one thing beyond the event loop: Tauri's default menu c Quit wired to Cocoa's `terminate:` and the pinned tao raises no cancellable event for it, so `desktop/src-tauri/src/menu.rs` rebuilds that menu with an ordinary item on the same accelerator. -Every ending drains first. The app-owned runtime is asked to stop, and it counts as gone only when -the child reports its own exit or the endpoint refuses a connection — a timeout or an unauthorized -reply is not proof. Nothing kills the child; the CLI's stop restores client configuration and lets -in-flight requests finish. A drain that has not completed within `DRAIN_DEADLINE` is reported and -the exit still proceeds, which can leave the runtime running: refusing to quit when the user asked -is the worse answer, and a standing runtime is recoverable with `ocx stop`. A runtime this app did -not start is never stopped. A quit that arrives while the sequence is starting one is held: the -coordinator reserves the spawn rather than holding its lock across process creation, and the quit is -deferred until the child is owned and then drains it. +Every ending drains first, and so does the tray's Stop, which is not an ending: all of them take the +same phase, so Stop pressed twice, Stop then Quit, and Stop during an update are one execution over +one child rather than several racing. Ownership is re-established at the start of each drain rather +than read off a flag — the pid the endpoint reports has to be the child this app started — because +between the spawn and now the child can have exited and a service can have taken the port back, and +an owner's stop sent to that listener is a stop sent to somebody else's runtime. A listener that +cannot be identified is left alone. + +A runtime counts as gone only when the child reports its own exit or the endpoint refuses a +connection; a timeout or an unauthorized reply is not proof. Nothing kills the child; the CLI's stop +restores client configuration and lets in-flight requests finish. + +A drain that does not complete within `DRAIN_DEADLINE` is **not** recorded as a drain. It becomes +`DrainFailed`, and an unidentifiable runtime becomes `OwnershipUnknown`. A user's quit still +proceeds from either — refusing to close when the user asked is the worse answer, and a standing +runtime is recoverable with `ocx stop`. A coordinated restart does not: coming back onto a runtime +that was never stopped puts the user on the old version while they believe they upgraded. A runtime +this app did not start is never stopped. A quit that arrives while the sequence is starting one is +held: the coordinator reserves the spawn rather than holding its lock across process creation, and +the quit is deferred until the child is owned and then drains it. + +An in-app update downloads and signature-checks the package, confirms who owns the running runtime, +drains it and confirms the child is gone, and only then installs. The order is not cosmetic: the +pinned updater's Windows installer hands off to the installer process and ends this one, so a +restart asked for after `install` is never reached, and the package would be replaced under a +runtime still serving out of those files. A drain that did not complete refuses the install and +leaves the update pending. + +The window may navigate to the `tauri://` scheme, to the loopback endpoint the sequence resolved, +and on Windows to `tauri.localhost`, which is where the pinned Tauri serves the app itself because +wry needs an http origin there. That is the one host and no port — not localhost generally, and not +a widening of what the loopback dashboard may reach. + +`desktop/src-tauri/src/proxy.rs` is the local management client and has its own network policy, +separate from the updater's download client. It refuses redirects and system proxies, and it will +not send the management token until it has confirmed, from the unauthenticated health body, that the +instance answering is the one the shell bound to: the marker, the pid, and the port it addressed. +The binding carries a generation, so a request authorised under an earlier binding is not authorised +after the shell rebinds. `desktop/src-tauri/src/tray_availability.rs` asks the session bus whether `org.kde.StatusNotifierWatcher` reports a host registered; macOS and Windows answer yes without a diff --git a/structure/overview.md b/structure/overview.md index a908d8e9e8b..a53748864c9 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -175,9 +175,12 @@ still cover the rule, which is a judgement only review makes. predefined Quit replaced because it raises no cancellable event. Every ending drains first — the tray's Quit, an update's coordinated restart, and a window close on a session with no tray all hold the exit, stop the app-owned runtime through the management stop, and treat only an observed - child exit or a refused connection as proof it stopped. No shell file kills the child, a runtime - this app did not start is never stopped, and a quit that lands while one is being started is - deferred until the child is owned and then drains it rather than being lost; + child exit or a refused connection as proof it stopped. Ownership is re-established from the pid + the endpoint reports rather than carried in a flag, a drain that does not complete is recorded as + failed rather than drained — which a quit tolerates and a coordinated restart refuses — and an + update installs only after the runtime it is replacing is confirmed stopped. No shell file kills + the child, a runtime this app did not start is never stopped, and a quit that lands while one is + being started or stopped is deferred rather than lost; see [`desktop-shell.md`](desktop-shell.md). Enforced by `tests/clients/desktop-exit-ownership.test.ts`. - **INV-DESKTOP-02** — Tray availability is an answer from the session, not the tray backend's From b9cde2a098af726108e697e7cf75bbfc913d8165 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 10:57:16 +0900 Subject: [PATCH 15/24] fix(desktop): let a refused restart be tried again The new failure states were a dead end. A drain that did not complete left the coordinator in DrainFailed, and every later claim returned None - so the update stayed pending in the tray and pressing Install again did nothing, on the one machine where the user most needs to retry: the one whose runtime would not stop. A terminal failure is not work in flight, so claiming it again re-enters the drain. A successful drain still cannot be re-entered, and a quit that claimed the reason first still wins it, so the retry cannot turn a pending quit into a restart. --- desktop/src-tauri/src/exit.rs | 38 +++++++++++++++++--- tests/clients/desktop-exit-ownership.test.ts | 7 +++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs index b38aafd080e..4a86950ebbc 100644 --- a/desktop/src-tauri/src/exit.rs +++ b/desktop/src-tauri/src/exit.rs @@ -200,10 +200,16 @@ impl ExitCoordinator { inner.deferred = true; None } - ExitPhase::Draining - | ExitPhase::Drained - | ExitPhase::DrainFailed - | ExitPhase::OwnershipUnknown => None, + // A failed drain is a terminal failure, not work in flight, and retrying it is the + // recovery: the update stayed pending, so the next attempt runs the stop again. Without + // this the first refusal would be permanent until the app was restarted by hand — which + // is the one thing a user with a runtime that would not stop cannot easily do. + ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => { + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + ExitPhase::Draining | ExitPhase::Drained => None, } } @@ -608,6 +614,30 @@ mod tests { assert_eq!(coordinator.decision(), ExitDecision::Refuse); } + #[test] + fn a_refused_restart_can_be_tried_again() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::Failed); + // The update stayed pending, so pressing Install again runs the stop again rather than + // finding the app permanently unable to try. + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + Some(ExitReason::CoordinatedRestart) + ); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.decision(), ExitDecision::Proceed); + } + + #[test] + fn a_successful_drain_is_not_re_entered() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::UserQuit); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + } + #[test] fn the_first_claimed_reason_wins_the_drain() { let coordinator = ExitCoordinator::new(); diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index 06e434ec680..6e01cc4f592 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -127,6 +127,11 @@ describe("desktop exit ownership", () => { expect(rule).toContain("Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse"); // A quit still closes the app on one, which is the trade that is defensible. expect(rule).toContain("_ => ExitDecision::Proceed"); + // And the refusal is recoverable: the update stayed pending, so the next attempt runs the + // stop again rather than finding the app permanently unable to try. + const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); + expect(claim).toContain("ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => {"); + expect(claim).toContain("ExitPhase::Draining | ExitPhase::Drained => None,"); }); test("stop, quit and update are one execution over one child", () => { @@ -161,7 +166,7 @@ describe("desktop exit ownership", () => { expect(arm).toContain("inner.reason.get_or_insert(fallback)"); expect(arm).toContain("inner.phase = ExitPhase::Draining;"); // Nothing else in the file moves the phase to draining. - expect(exit.split("inner.phase = ExitPhase::Draining;")).toHaveLength(3); + expect(exit.split("inner.phase = ExitPhase::Draining;")).toHaveLength(4); }); test("a quit that lands while a runtime is starting is deferred, not lost", () => { From 46de9cf0e12cf58690ea40501792d3f017ef0351 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 11:45:40 +0900 Subject: [PATCH 16/24] test(desktop): follow finish_drain into its verdict argument The call takes the verdict now, so the assertion that still looked for a bare finish_drain() was stale. Hosted CI caught it, which my own literal scan should have: the scan used a look-behind, rg's default engine rejects that, and a rejected pattern produces no output - so the loop ran zero times and reported clean on every file. It is fixed and now fails loudly if the extraction errors, and the corrected run over all five files found this one assertion and nothing else. --- tests/clients/desktop-exit-ownership.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index 6e01cc4f592..ed88b89abd8 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -229,7 +229,7 @@ describe("desktop exit ownership", () => { const proceed = handler.indexOf("ExitDecision::Proceed =>"); expect(proceed).toBeGreaterThan(-1); expect(handler.slice(proceed)).not.toContain("prevent_exit"); - expect(exit).toContain("coordinator.finish_drain()"); + expect(exit).toContain("coordinator.finish_drain(verdict)"); }); test("closing the window takes the same decision the quit gesture does", () => { From 2f54d38975433b99145c271355d3649289adbb1c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:27:46 +0900 Subject: [PATCH 17/24] feat(desktop): resolve the runtime through the bundled CLI, and stop guessing D5. The shell used to answer this itself, in a file called discovery.rs that read runtime-port.json, fell back to 10100 and started there - so a user with a configured config.port was started on a port they had not chosen, and the tuned probe budgets that decision needs were sitting unused one layer down. It asks ocx resolve --json now and reads one ocx-resolve/1 document. Liveness keeps its three answers, and the third one is the point. live means attach as a guest; absent-proven means every recorded and configured endpoint was definitively dead, and only that authorises starting a runtime. Everything else is unknown - a non-zero exit, a timeout, output that will not parse, a schema this shell does not know, a missing binary - and unknown fails the state with a diagnostic and a retry. It is never read as absence, because that is the reading that puts a second proxy next to the one already running. Two things a live verdict does not settle on its own. Core's liveness predicate accepts a connected client's listener on purpose, so duplicate-start avoidance can see it, and a caller that needs the management plane has to discriminate on the role rather than narrow that predicate - this shell needs it, so a client listener is live and unusable rather than something to attach to. And a runtime bound somewhere 127.0.0.1 cannot reach is the same kind of answer. Neither is an absence, so neither authorises a start. The sequence also stops reporting Ready against an instance it could not identify. bind returns its answer now instead of swallowing it, and both call sites fail the state on None: the management token is only ever sent to a bound instance, so a dashboard there would not load anyway. --- desktop/src-tauri/src/discovery.rs | 116 ---------- desktop/src-tauri/src/endpoint.rs | 33 +++ desktop/src-tauri/src/resolve.rs | 329 +++++++++++++++++++++++++++++ desktop/src-tauri/src/startup.rs | 186 ++++++++++------ 4 files changed, 488 insertions(+), 176 deletions(-) delete mode 100644 desktop/src-tauri/src/discovery.rs create mode 100644 desktop/src-tauri/src/endpoint.rs create mode 100644 desktop/src-tauri/src/resolve.rs diff --git a/desktop/src-tauri/src/discovery.rs b/desktop/src-tauri/src/discovery.rs deleted file mode 100644 index 2eaded758df..00000000000 --- a/desktop/src-tauri/src/discovery.rs +++ /dev/null @@ -1,116 +0,0 @@ -use serde::Deserialize; -use std::path::{Path, PathBuf}; - -pub const DEFAULT_PORT: u16 = 10100; -const HOME_ENV: &str = "OPENCODEX_HOME"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProxyEndpoint { - pub host: &'static str, - pub port: u16, -} - -impl ProxyEndpoint { - pub fn url(&self, path: &str) -> String { - format!("http://{}:{}{}", self.host, self.port, path) - } -} - -#[derive(Debug, Deserialize)] -struct RuntimePort { - port: u16, -} - -pub fn config_directory(environment: impl Fn(&str) -> Option, home: &Path) -> PathBuf { - environment(HOME_ENV) - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) - .map(|value| expand_tilde(PathBuf::from(value), home)) - .unwrap_or_else(|| home.join(".opencodex")) -} - -pub fn resolve(environment: impl Fn(&str) -> Option, home: &Path) -> ProxyEndpoint { - let directory = config_directory(environment, home); - let path = directory.join("runtime-port.json"); - let port = std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .map(|record| record.port) - .filter(|port| (1..=u16::MAX).contains(port)) - .unwrap_or(DEFAULT_PORT); - ProxyEndpoint { - host: "127.0.0.1", - port, - } -} - -fn expand_tilde(path: PathBuf, home: &Path) -> PathBuf { - if path == Path::new("~") { - return home.to_path_buf(); - } - path.strip_prefix("~/") - .map(|rest| home.join(rest)) - .unwrap_or(path) -} - -pub fn current() -> (ProxyEndpoint, PathBuf) { - let home = dirs_home(); - let directory = config_directory(|key| std::env::var(key).ok(), &home); - let endpoint = resolve(|key| std::env::var(key).ok(), &home); - (endpoint, directory) -} - -fn dirs_home() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from(".")) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs::{create_dir_all, write}; - - #[test] - fn resolves_home_override_and_runtime_port() { - let root = std::env::temp_dir().join(format!("ocx-discovery-{}", std::process::id())); - let home = root.join("home"); - let custom = home.join("custom"); - create_dir_all(&custom).unwrap(); - write( - custom.join("runtime-port.json"), - r#"{"pid":1,"port":12345}"#, - ) - .unwrap(); - let endpoint = resolve(|key| (key == HOME_ENV).then(|| "~/custom".into()), &home); - assert_eq!( - endpoint, - ProxyEndpoint { - host: "127.0.0.1", - port: 12345 - } - ); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn any_failure_falls_back_to_default() { - let home = std::env::temp_dir().join("ocx-missing-home"); - let endpoint = resolve(|_| None, &home); - assert_eq!(endpoint.port, DEFAULT_PORT); - } - - #[test] - fn empty_override_and_invalid_port_use_default() { - let root = - std::env::temp_dir().join(format!("ocx-discovery-invalid-{}", std::process::id())); - let home = root.join("home"); - let directory = root.join("custom"); - create_dir_all(&directory).unwrap(); - write(directory.join("runtime-port.json"), r#"{"port":0}"#).unwrap(); - let endpoint = resolve(|key| (key == HOME_ENV).then(|| " ".into()), &home); - assert_eq!(endpoint.port, DEFAULT_PORT); - let _ = std::fs::remove_dir_all(root); - } -} diff --git a/desktop/src-tauri/src/endpoint.rs b/desktop/src-tauri/src/endpoint.rs new file mode 100644 index 00000000000..09a78e22fb7 --- /dev/null +++ b/desktop/src-tauri/src/endpoint.rs @@ -0,0 +1,33 @@ +//! The loopback endpoint the shell talks to. +//! +//! This file was `discovery.rs`, and it resolved the endpoint itself: it read `runtime-port.json`, +//! fell back to 10100 and let the shell start there, so a user with a configured `config.port` was +//! started on a port they had not chosen. Resolution belongs to the bundled CLI now — see +//! `resolve.rs` — and what is left here is the value it hands back. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyEndpoint { + pub host: &'static str, + pub port: u16, +} + +impl ProxyEndpoint { + pub fn url(&self, path: &str) -> String { + format!("http://{}:{}{}", self.host, self.port, path) + } +} + +#[cfg(test)] +mod tests { + use super::ProxyEndpoint; + + #[test] + fn the_endpoint_is_loopback_and_carries_its_port() { + let endpoint = ProxyEndpoint { + host: "127.0.0.1", + port: 12345, + }; + assert_eq!(endpoint.url("/healthz"), "http://127.0.0.1:12345/healthz"); + assert_eq!(endpoint.url(""), "http://127.0.0.1:12345"); + } +} diff --git a/desktop/src-tauri/src/resolve.rs b/desktop/src-tauri/src/resolve.rs new file mode 100644 index 00000000000..8172006d0ff --- /dev/null +++ b/desktop/src-tauri/src/resolve.rs @@ -0,0 +1,329 @@ +//! What the bundled CLI says about this machine's runtime. +//! +//! D5: the shell stops resolving the configuration home, the port and liveness itself. It used to, +//! in a file called `discovery.rs` that read `runtime-port.json`, fell back to 10100 and started on +//! that port — so a user with a configured `config.port` was started somewhere else. The tuned probe +//! budgets it should have been using exist because a shell-side reimplementation answered "nobody is +//! listening" twice and started duplicate proxies. This asks instead. +//! +//! Liveness has three answers and the third one is the point. `live` means attach. `absent-proven` +//! means every recorded and configured endpoint was definitively dead, and only that authorises +//! starting a runtime. Anything else is unknown, and the CLI exits 1 rather than putting absence on +//! the wire. Everything that can go wrong on this side — a missing binary, a timeout, output that +//! will not parse, a schema this shell does not know — folds into the same unknown, because the one +//! reading that must never happen is "the resolve failed, so nobody must be listening". + +use crate::endpoint::ProxyEndpoint; +use serde::Deserialize; +use std::path::PathBuf; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. A document announcing anything else is unknown. +pub const SCHEMA: &str = "ocx-resolve/1"; + +/// The CLI's liveness verdict. Only two reach the wire; the third exits 1. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Status { + Live, + AbsentProven, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Liveness { + pub status: Status, + pub pid: Option, + pub port: Option, + /// The bind address that answered. Absent on a proven absence, because nothing answered. + pub hostname: Option, + pub version: Option, + pub role: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Port { + /// The port a client should use: the live listener's, or the configured one. + pub effective: u16, + /// What a start would prefer. + pub configured: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Resolved { + pub schema: String, + pub cli_version: String, + pub config_home: String, + pub port: Port, + pub liveness: Liveness, +} + +impl Resolved { + pub fn endpoint(&self) -> ProxyEndpoint { + ProxyEndpoint { + host: "127.0.0.1", + port: self.port.effective, + } + } + + pub fn home(&self) -> PathBuf { + PathBuf::from(&self.config_home) + } +} + +/// What the shell got back. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolution { + /// The CLI produced a verdict this shell trusts. + Answered(Box), + /// It did not, for whatever reason. Never read as absence. + Unknown(String), +} + +impl Resolution { + pub fn resolved(&self) -> Option<&Resolved> { + match self { + Self::Answered(resolved) => Some(resolved.as_ref()), + Self::Unknown(_) => None, + } + } + + pub fn reason(&self) -> Option<&str> { + match self { + Self::Unknown(reason) => Some(reason), + Self::Answered(_) => None, + } + } +} + +/// Whether the shell may start a runtime of its own. +/// +/// Proven absence and nothing else. `live` means attach to what is there, and unknown means refuse: +/// a resolution that could not be trusted must never read as "nobody is listening", which is the +/// reading that puts a second proxy next to the one already running. +pub fn may_start(resolution: &Resolution) -> bool { + matches!( + resolution + .resolved() + .map(|resolved| resolved.liveness.status), + Some(Status::AbsentProven) + ) +} + +/// What the shell may do with a listener the CLI found alive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LiveVerdict { + /// Nothing is listening; this verdict does not apply. + NotLive, + /// It is a proxy, on an address this shell can reach. Attach as a guest. + Attach, + /// Something is listening and this shell cannot use it. Never a reason to start a second one. + Unusable(String), +} + +/// Whether the address the CLI reported is one this shell can reach on loopback. +/// +/// The shell speaks to loopback and nothing else — that is what makes sending the management token +/// to it safe. A proxy bound to either loopback spelling, or to every interface, is reachable at +/// 127.0.0.1. One bound to the IPv6 loopback or to a specific external address is not, and +/// addressing 127.0.0.1 anyway would turn a running proxy into a health wait that times out. +pub fn loopback_reachable(hostname: Option<&str>) -> bool { + matches!( + hostname, + None | Some("127.0.0.1") | Some("localhost") | Some("0.0.0.0") + ) +} + +/// Read a live verdict. +/// +/// Liveness answers "is something there", and core's predicate accepts a connected client's +/// listener on purpose so duplicate-start avoidance can see it. This shell needs the management +/// plane, so it has to discriminate on the role the CLI carried: a client listener serves machine +/// routes, not `/api/*`, and attaching to it would report Ready against an endpoint the dashboard +/// and the tray cannot use. +pub fn live_verdict(resolution: &Resolution) -> LiveVerdict { + let Some(resolved) = resolution.resolved() else { + return LiveVerdict::NotLive; + }; + if resolved.liveness.status != Status::Live { + return LiveVerdict::NotLive; + } + if resolved.liveness.role.as_deref() == Some("client") { + return LiveVerdict::Unusable( + "a connected client is listening on this port, not a proxy this app can manage".into(), + ); + } + if !loopback_reachable(resolved.liveness.hostname.as_deref()) { + return LiveVerdict::Unusable(format!( + "the runtime is bound to {} and this app only speaks to loopback", + resolved + .liveness + .hostname + .as_deref() + .unwrap_or("an unknown address") + )); + } + LiveVerdict::Attach +} + +/// Read one resolve document, refusing anything that is not exactly one. +/// +/// The CLI puts the document on stdout and its human output on stderr, so stdout is parsed whole. +/// A non-zero exit is the CLI's own refusal — including the exit 1 it uses for unknown liveness and +/// for a config it will not guess at — and is carried through rather than reinterpreted here. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> Resolution { + if exit_code != Some(0) { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return Resolution::Unknown(if detail.is_empty() { + format!("the bundled CLI could not resolve the runtime (exit {code})") + } else { + format!("the bundled CLI could not resolve the runtime (exit {code}): {detail}") + }); + } + let text = String::from_utf8_lossy(stdout); + let resolved: Resolved = match serde_json::from_str(text.trim()) { + Ok(resolved) => resolved, + Err(error) => { + return Resolution::Unknown(format!( + "the bundled CLI's resolve output could not be read ({error})" + )) + } + }; + if resolved.schema != SCHEMA { + return Resolution::Unknown(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + resolved.schema + )); + } + Resolution::Answered(Box::new(resolved)) +} + +/// Ask the bundled CLI, under the caller's deadline. +pub async fn run(app: &AppHandle, deadline: Instant) -> Resolution { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["resolve", "--json"]), + Err(error) => { + return Resolution::Unknown(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => { + Resolution::Unknown(format!("the bundled CLI could not be run ({error})")) + } + Err(_) => Resolution::Unknown( + "the bundled CLI did not answer before the startup deadline".to_owned(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{ + live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, SCHEMA, + }; + + const LIVE: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", + "port":{"effective":10100,"configured":10100,"source":"runtime-record"}, + "liveness":{"status":"live","pid":42,"port":10100,"source":"runtime-record","version":"2.61.0"}}"#; + const ABSENT: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", + "port":{"effective":10100,"configured":10100,"source":"config"}, + "liveness":{"status":"absent-proven","pid":null,"port":null,"source":null}}"#; + + #[test] + fn a_live_verdict_is_read_whole() { + let resolution = read(Some(0), LIVE.as_bytes(), b""); + let resolved = resolution.resolved().expect("a document"); + assert_eq!(resolved.schema, SCHEMA); + assert_eq!(resolved.liveness.status, Status::Live); + assert_eq!(resolved.liveness.pid, Some(42)); + assert_eq!(resolved.endpoint().port, 10100); + assert_eq!(resolved.home().display().to_string(), "/h"); + assert_eq!(live_verdict(&resolution), LiveVerdict::Attach); + assert!(!may_start(&resolution)); + } + + #[test] + fn only_a_proven_absence_authorises_a_start() { + let resolution = read(Some(0), ABSENT.as_bytes(), b""); + assert_eq!( + resolution.resolved().map(|r| r.liveness.status), + Some(Status::AbsentProven) + ); + assert!(may_start(&resolution)); + assert_eq!(live_verdict(&resolution), LiveVerdict::NotLive); + } + + #[test] + fn a_connected_client_is_live_but_not_a_runtime_to_attach_to() { + let client = LIVE.replace( + r#""version":"2.61.0""#, + r#""version":"2.61.0","role":"client""#, + ); + let resolution = read(Some(0), client.as_bytes(), b""); + assert!(matches!( + live_verdict(&resolution), + LiveVerdict::Unusable(_) + )); + // Live and unusable is still live: it is never a reason to start a second one. + assert!(!may_start(&resolution)); + } + + #[test] + fn only_a_loopback_bind_is_addressed_as_loopback() { + for reachable in [None, Some("127.0.0.1"), Some("localhost"), Some("0.0.0.0")] { + assert!(loopback_reachable(reachable), "{reachable:?}"); + } + for elsewhere in [Some("::1"), Some("192.168.1.10"), Some("example.internal")] { + assert!(!loopback_reachable(elsewhere), "{elsewhere:?}"); + } + let bound = LIVE.replace(r#""pid":42"#, r#""pid":42,"hostname":"::1""#); + let resolution = read(Some(0), bound.as_bytes(), b""); + assert!(matches!( + live_verdict(&resolution), + LiveVerdict::Unusable(_) + )); + assert!(!may_start(&resolution)); + } + + #[test] + fn the_clis_own_refusal_is_unknown_and_never_authorises_a_start() { + // Exit 1 is what the CLI uses for unknown liveness and for a config it will not guess at. + let resolution = read(Some(1), b"", b"resolve: liveness is unknown"); + assert!(matches!(resolution, Resolution::Unknown(_))); + assert!(resolution.reason().unwrap().contains("liveness is unknown")); + assert!(!may_start(&resolution)); + assert_eq!(live_verdict(&resolution), LiveVerdict::NotLive); + } + + #[test] + fn everything_that_can_go_wrong_here_folds_into_unknown() { + for (code, out) in [ + (Some(64), &b""[..]), + (None, &b""[..]), + (Some(0), &b"not json"[..]), + (Some(0), &b"{}"[..]), + ] { + let resolution = read(code, out, b""); + assert!(matches!(resolution, Resolution::Unknown(_)), "{code:?}"); + assert!(!may_start(&resolution)); + } + } + + #[test] + fn a_schema_this_app_does_not_know_is_unknown() { + let future = LIVE.replace("ocx-resolve/1", "ocx-resolve/2"); + let resolution = read(Some(0), future.as_bytes(), b""); + assert!(matches!(resolution, Resolution::Unknown(_))); + assert!(resolution.reason().unwrap().contains("ocx-resolve/2")); + assert!(!may_start(&resolution)); + } +} diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index fbace293256..bcc08ba2279 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -17,10 +17,11 @@ use crate::{ auth::Auth, - discovery::{self, ProxyEndpoint}, + endpoint::ProxyEndpoint, first_run::{self, StartAtLogin}, identity, ownership, - proxy::ProxyClient, + proxy::{ProxyClient, RuntimeIdentity}, + resolve, sidecar::{self, SidecarWatch}, tray_availability::{self, TrayAvailability}, AppState, @@ -48,12 +49,6 @@ pub const PHASE_EVENT: &str = "startup-phase"; /// timeout, because otherwise the last probe overruns the ceiling by the whole client timeout. pub const DEADLINE: Duration = Duration::from_secs(30); -/// How long an already-running runtime gets to answer before this app starts its own. -/// -/// The core liveness path carries a comment explaining why this number is not smaller: a single -/// unanswered 750ms probe was once enough to start a duplicate proxy on Windows. -const ATTACH_BUDGET: Duration = Duration::from_secs(2); - const POLL: Duration = Duration::from_millis(250); /// Where the launch came from. @@ -208,9 +203,9 @@ impl Progress { } } -/// What the sequence resolved, once it has. +/// Where the sequence is pointed, once the CLI has said. #[derive(Clone)] -struct Resolved { +struct Target { endpoint: ProxyEndpoint, home: PathBuf, } @@ -345,21 +340,40 @@ async fn run(app: &AppHandle) { ); report(app, started, Phase::Resolving, None); - // D5 hands this resolution to the bundled CLI, so a user on a custom config.port is not started - // on a different one. This is the call site that changes when lane A's resolve verb lands; it - // is already inside the sequence so the failure has a state, a diagnostic and a retry. - let (endpoint, home) = discovery::current(); - let resolved = Resolved { + // D5: the shell no longer resolves the home, the port or liveness. It asks the bundled CLI, + // which owns the tuned probe budgets that exist because a shell-side reimplementation answered + // "nobody is listening" twice and started duplicate proxies. The call is inside the sequence, so + // a CLI that is missing or slow has a state, a diagnostic and a retry rather than a guess. + let resolution = resolve::run(app, deadline).await; + let Some(answer) = resolution.resolved() else { + // Fail-closed. A resolution that could not be trusted is not an absence, and nothing below + // may read it as one. + fail( + app, + started, + None, + ®istration, + &watch, + Phase::Resolving, + resolution + .reason() + .unwrap_or("the runtime could not be resolved") + .to_owned(), + ); + return; + }; + let endpoint = answer.endpoint(); + let target = Target { endpoint, - home: home.clone(), + home: answer.home(), }; - let proxy = match ProxyClient::new(endpoint, Auth::new(home)) { + let proxy = match ProxyClient::new(endpoint, Auth::new(answer.home())) { Ok(proxy) => proxy, Err(error) => { fail( app, started, - Some(&resolved), + Some(&target), ®istration, &watch, Phase::Resolving, @@ -376,27 +390,75 @@ async fn run(app: &AppHandle) { started, Phase::Resolving, Some(format!( - "{} with a configuration home of {}", - resolved.endpoint.url(""), - resolved.home.display() + "{} with a configuration home of {}, resolved by the bundled CLI {}", + target.endpoint.url(""), + target.home.display(), + answer.cli_version )), ); - report(app, started, Phase::Probing, None); - // The budget for an existing runtime is counted from here, not from the start of the sequence. - // Counted from the start, the registration above can spend it — and a tray or session-bus - // registration that took its time would then present as "nothing is listening", which starts a - // second proxy next to the one that was already there. - let probing_from = Instant::now(); - if healthy_by(&proxy, (probing_from + ATTACH_BUDGET).min(deadline)).await { - report( + report( + app, + started, + Phase::Probing, + Some(match answer.liveness.status { + resolve::Status::Live => "a runtime is already listening".to_owned(), + resolve::Status::AbsentProven => { + "no runtime is listening, and that absence was proven".to_owned() + } + }), + ); + match resolve::live_verdict(&resolution) { + resolve::LiveVerdict::Attach => { + report( + app, + started, + Phase::Attaching, + Some("a runtime was already listening, so this app is a guest on it".to_owned()), + ); + if bind(app, &proxy, deadline).await.is_none() { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Attaching, + "the runtime answered but did not identify itself, so this app did not attach" + .to_owned(), + ); + return; + } + finish(app, started, endpoint); + return; + } + // Something holds the port and this app cannot manage it. That is not an absence, so it + // does not authorise starting a second runtime beside it either. + resolve::LiveVerdict::Unusable(reason) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Attaching, + reason, + ); + return; + } + resolve::LiveVerdict::NotLive => {} + } + if !resolve::may_start(&resolution) { + // Only a proven absence authorises a start. Nothing else may fall through to one. + fail( app, started, - Phase::Attaching, - Some("a runtime was already listening, so this app is a guest on it".to_owned()), + Some(&target), + ®istration, + &watch, + Phase::Probing, + "the runtime's liveness could not be established, so no runtime was started".to_owned(), ); - bind(app, &proxy, deadline).await; - finish(app, started, endpoint); return; } @@ -423,7 +485,7 @@ async fn run(app: &AppHandle) { fail( app, started, - Some(&resolved), + Some(&target), ®istration, &watch, Phase::Starting, @@ -439,7 +501,20 @@ async fn run(app: &AppHandle) { report(app, started, Phase::Waiting, None); while Instant::now() < deadline { if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { - bind(app, &proxy, deadline).await; + if bind(app, &proxy, deadline).await.is_none() { + // Healthy is not the same as identified: a 200 with a body that does not carry the + // marker is something else holding the port, and the token is never sent to it. + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + "the runtime reported healthy but did not identify itself".to_owned(), + ); + return; + } finish(app, started, endpoint); return; } @@ -449,7 +524,7 @@ async fn run(app: &AppHandle) { fail( app, started, - Some(&resolved), + Some(&target), ®istration, &watch, Phase::Waiting, @@ -462,7 +537,7 @@ async fn run(app: &AppHandle) { fail( app, started, - Some(&resolved), + Some(&target), ®istration, &watch, Phase::Waiting, @@ -594,18 +669,6 @@ fn spawn_runtime( Some(outcome) } -async fn healthy_by(proxy: &ProxyClient, deadline: Instant) -> bool { - loop { - if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { - return true; - } - if Instant::now() >= deadline { - return false; - } - sleep(POLL).await; - } -} - /// Establish which instance is answering, and whether it is the child this app started. /// /// The health body is unauthenticated and carries the marker, the pid and the port, so identity is @@ -613,15 +676,20 @@ async fn healthy_by(proxy: &ProxyClient, deadline: Instant) -> bool { /// a spawn records a pid, and this is what says that pid is the one holding the port. An answer /// that cannot be read leaves the app owning nothing, which is the safe way round — an owner's stop /// sent to a listener that is not ours is a stop sent to somebody else's runtime. -async fn bind(app: &AppHandle, proxy: &ProxyClient, deadline: Instant) { +/// +/// The answer is returned rather than swallowed, because a sequence that cannot identify what it is +/// talking to has not finished. Reporting Ready there would navigate the window to a dashboard the +/// shell cannot authenticate against, since the management token is only sent to a bound instance. +async fn bind(app: &AppHandle, proxy: &ProxyClient, deadline: Instant) -> Option { let identity = match tokio::time::timeout_at(deadline, proxy.identify()).await { Ok(Ok(identity)) => identity, - _ => return, + _ => return None, }; proxy.bind(identity); if let Some(state) = app.try_state::() { state.confirm_ownership(identity); } + Some(identity) } fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { @@ -646,7 +714,7 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { fn fail( app: &AppHandle, started: Instant, - resolved: Option<&Resolved>, + target: Option<&Target>, registration: &Registration, watch: &SidecarWatch, phase: Phase, @@ -655,7 +723,7 @@ fn fail( let elapsed_ms = elapsed(started); let mut progress = Progress::new(Phase::Failed, elapsed_ms); progress.diagnostic = Some(diagnostic( - resolved.map(|resolved| (resolved.endpoint, resolved.home.clone())), + target.map(|target| (target.endpoint, target.home.clone())), registration, watch, phase, @@ -673,7 +741,7 @@ fn fail( /// binary will not run on this CPU" from "the home is not the one the accounts are in", and none of /// them were reachable from the generic health failure this replaces. pub fn diagnostic( - resolved: Option<(ProxyEndpoint, PathBuf)>, + target: Option<(ProxyEndpoint, PathBuf)>, registration: &Registration, watch: &SidecarWatch, phase: Phase, @@ -690,7 +758,7 @@ pub fn diagnostic( format!("reason: {reason}"), format!("elapsed: {elapsed_ms}ms"), ]; - match resolved { + match target { Some((endpoint, home)) => { lines.push(format!("endpoint: {}", endpoint.url(""))); lines.push(format!("home: {}", home.display())); @@ -732,9 +800,7 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { - use super::{ - shows_window, LaunchOrigin, Phase, ATTACH_BUDGET, AUTOSTART_FLAG, DEADLINE, PHASES, - }; + use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES, POLL}; use crate::tray_availability::TrayAvailability; use tokio::time::Duration; @@ -801,10 +867,10 @@ mod tests { #[test] fn the_whole_sequence_is_bounded_well_under_the_minute_it_used_to_take() { - let budgets = [DEADLINE, ATTACH_BUDGET]; + let budgets = [DEADLINE, POLL]; assert!(budgets .iter() .all(|budget| *budget <= Duration::from_secs(45))); - assert!(ATTACH_BUDGET < DEADLINE); + assert!(POLL < DEADLINE); } } From 56cf8de944264af5f3fcfc44d7875bda336c5ab1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:27:47 +0900 Subject: [PATCH 18/24] fix(desktop): stop the runtime with the bundled ocx stop, and read what it said D4. The shell was ending the runtime with a management call from inside the process it was ending. That cannot own its own teardown: launchd and systemd can terminate the request handler during self-unload, and the Windows respawn window can only be verified after the process exits. ocx stop --json runs the real teardown - the receipt, the drain, the respawn verification, the client-config restore - and the shell reads the ocx-stop/1 summary instead of inferring an outcome from an HTTP response. A stop counts only when five facts hold together. The process exited 0 and the document says so, through both ok and exitCode, so 1, 79 and 80 are refusals however the rest reads - taking the summary's word for its own exit status is taking a claim as its own evidence. runtimeDown has to be true, because a service that failed while the proxy happened to stop is exactly the case that may respawn it. And the document has to agree with itself: only a stopped outcome beside a stopped or orphaned proxy, or not-running beside not-running, is a runtime that is down. An outcome or proxy state this shell does not know fails to parse, which is the same answer as a stop that did not happen. --- desktop/src-tauri/src/exit.rs | 30 ++- desktop/src-tauri/src/lib.rs | 4 +- desktop/src-tauri/src/proxy.rs | 11 +- desktop/src-tauri/src/runtime_stop.rs | 305 ++++++++++++++++++++++++++ desktop/src-tauri/src/sidecar.rs | 105 +-------- 5 files changed, 336 insertions(+), 119 deletions(-) create mode 100644 desktop/src-tauri/src/runtime_stop.rs diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs index 4a86950ebbc..aba0aab4b39 100644 --- a/desktop/src-tauri/src/exit.rs +++ b/desktop/src-tauri/src/exit.rs @@ -21,9 +21,13 @@ //! may not: coming back onto a runtime that was never stopped puts the user on the old version //! while they believe they are on the new one. -use crate::{proxy::ProxyClient, sidecar, tray_availability::TrayAvailability, window, AppState}; +use crate::{ + proxy::ProxyClient, runtime_stop, sidecar, tray_availability::TrayAvailability, window, + AppState, +}; use std::sync::{Mutex, MutexGuard, PoisonError}; use tauri::{AppHandle, ExitRequestApi, Manager}; +use tokio::time::Instant; /// Why the process has been asked to end. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -464,9 +468,12 @@ pub fn complete_restart(app: &AppHandle) -> ! { /// to somebody else's runtime, so the pid is checked first and a listener that cannot be identified /// is left alone. /// -/// Nothing kills the child. The old path did, with `CommandChild::kill()`, and that is a SIGKILL on -/// Unix: it cut off the in-flight requests, the client-configuration restore and the state-file -/// clearing that the CLI's own stop performs. +/// The stop itself is the bundled `ocx stop` (D4), not a management call from inside this process. +/// The CLI's stop owns the receipt-backed teardown, the drain, the Windows respawn verification and +/// the client-configuration restore, and an in-process endpoint cannot own its own teardown: launchd +/// and systemd can terminate the request handler during self-unload. Nothing kills the child either +/// — the original path did, with `CommandChild::kill()`, a SIGKILL on Unix that cut off exactly the +/// work the CLI's stop exists to finish. pub async fn drain_current(app: &AppHandle) -> DrainVerdict { let Some((proxy, child_pid, watch)) = app .try_state::() @@ -486,13 +493,18 @@ pub async fn drain_current(app: &AppHandle) -> DrainVerdict { Ownership::Gone => DrainVerdict::Drained, Ownership::Foreign => DrainVerdict::Drained, Ownership::Unknown => DrainVerdict::OwnershipUnknown, - Ownership::Ours => match sidecar::drain(&proxy, true, &watch).await.failure() { - None => DrainVerdict::Drained, - Some(error) => { - crate::logging::log_once("graceful stop did not complete", &error); + Ownership::Ours => { + let deadline = Instant::now() + runtime_stop::DEADLINE; + let result = runtime_stop::run(app, deadline).await; + if result.is_stopped() { + DrainVerdict::Drained + } else { + // The CLI's own outcome and exit code, carried rather than reinterpreted. A stop + // that did not end in exit 0 with the runtime down is a stop that did not happen. + crate::logging::log_once("the bundled stop did not complete", &result.describe()); DrainVerdict::Failed } - }, + } } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ca803b39a0e..d24fefba790 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ mod auth; -mod discovery; +mod endpoint; mod exit; mod first_run; mod formatting; @@ -11,6 +11,8 @@ mod logging; mod menu; mod ownership; mod proxy; +mod resolve; +mod runtime_stop; mod sidecar; mod startup; mod tray; diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index 5b522db745e..fc45d820256 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -1,4 +1,4 @@ -use crate::{auth::Auth, discovery::ProxyEndpoint}; +use crate::{auth::Auth, endpoint::ProxyEndpoint}; use reqwest::{redirect, Client, Method, StatusCode}; use serde_json::Value; use std::{ @@ -150,11 +150,6 @@ impl ProxyClient { timeout_at(deadline, self.is_alive()).await.ok() } - /// A stop request bounded the same way. - pub async fn stop_within(&self, deadline: Instant) -> Option> { - timeout_at(deadline, self.stop()).await.ok() - } - pub async fn companion_settings(&self) -> Result { self.get("/api/companion/settings").await } @@ -179,10 +174,6 @@ impl ProxyClient { self.get(&format!("/api/usage/timeline?{query}")).await } - pub async fn stop(&self) -> Result { - self.request(Method::POST, "/api/stop").await - } - async fn get(&self, path: &str) -> Result { self.request(Method::GET, path).await } diff --git a/desktop/src-tauri/src/runtime_stop.rs b/desktop/src-tauri/src/runtime_stop.rs new file mode 100644 index 00000000000..520bc632ff8 --- /dev/null +++ b/desktop/src-tauri/src/runtime_stop.rs @@ -0,0 +1,305 @@ +//! Stopping a runtime through the bundled CLI. +//! +//! D4: the shell drives the real `ocx stop` as a child process, so the receipt-backed teardown, the +//! drain, the Windows respawn verification and the client-configuration restore all run exactly as +//! they do from a terminal. An in-process management call cannot own that teardown — launchd and +//! systemd can terminate the request handler during self-unload, and the Windows respawn window can +//! only be verified after the process exits — so the shell reads the run's result instead of +//! performing it. +//! +//! The result is a document, not a guess. `ocx stop --json` puts one summary on stdout and its +//! human output on stderr, and this consumes the outcome and the exit code rather than inferring +//! either. A stop that did not end in exit 0 with the runtime down is a stop that did not happen. + +use serde::Deserialize; +use std::time::Duration; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. +pub const SCHEMA: &str = "ocx-stop/1"; + +/// How long the stop may take. +/// +/// The CLI's stop drains in-flight requests, restores client configuration and verifies the Windows +/// respawn window, so this is generous on purpose: it bounds a hang, it does not pace a healthy +/// stop. Overrunning it is a failure, not a stop, because the caller's next step is to end the app +/// or replace the files the runtime is serving out of. +pub const DEADLINE: Duration = Duration::from_secs(30); + +/// The outcomes the CLI can report. An outcome this shell does not know fails to parse, which is +/// the same answer as a stop that did not happen. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Outcome { + Stopped, + NotRunning, + HistoryIncomplete, + HistoryDeferred, + Failed, +} + +impl Outcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::NotRunning => "not-running", + Self::HistoryIncomplete => "history-incomplete", + Self::HistoryDeferred => "history-deferred", + Self::Failed => "failed", + } + } +} + +/// How the proxy half of the stop ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Proxy { + Stopped, + StoppedOrphan, + NotRunning, + StopFailed, + OwnershipRefused, + UnresolvablePid, + Respawned, + Unknown, +} + +impl Proxy { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::StoppedOrphan => "stopped-orphan", + Self::NotRunning => "not-running", + Self::StopFailed => "stop-failed", + Self::OwnershipRefused => "ownership-refused", + Self::UnresolvablePid => "unresolvable-pid", + Self::Respawned => "respawned", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopSummary { + pub schema: String, + /// Strict exit-code view: true only for exit 0. + pub ok: bool, + pub outcome: Outcome, + pub exit_code: i32, + /// True when this stop left no proxy of this home running by its own paths. + pub runtime_down: bool, + pub proxy: Proxy, + pub message: String, +} + +/// What the shell concluded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StopResult { + /// The CLI reported a clean stop and a runtime that is down. + Stopped(StopSummary), + /// It reported anything else, or the run could not be read at all. + Failed(String), +} + +impl StopResult { + pub fn is_stopped(&self) -> bool { + matches!(self, Self::Stopped(_)) + } + + pub fn describe(&self) -> String { + match self { + Self::Stopped(summary) => summary.message.clone(), + Self::Failed(reason) => reason.clone(), + } + } +} + +/// Read one stop summary. +/// +/// Five facts have to hold together, and no four of them are enough. +/// +/// The process has to have exited 0, and the document has to say so too: `ok` is the strict +/// exit-code view and `exitCode` is the number behind it, so 1, 79 and 80 are refusals however the +/// rest of the document reads. Reading only the document would take a run's word for its own exit +/// status; reading only the status would accept a summary that disagrees with it. And `runtimeDown` +/// is the CLI's own statement that no proxy of this home is left running — a service that failed +/// while the proxy happened to stop satisfies that and not the others, and it is exactly the case +/// that may respawn the runtime a moment later. +/// +/// The fifth is that the document agrees with itself. The CLI's own summarizer cannot emit a +/// `failed` outcome beside a `stopped` proxy, but a reader that assumes that is trusting a +/// document to be self-consistent rather than checking. Only the two shapes that mean a runtime is +/// down are accepted, and an outcome or a proxy state this shell does not know fails to parse — +/// which is the same answer as a stop that did not happen. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult { + let text = String::from_utf8_lossy(stdout); + let summary: StopSummary = match serde_json::from_str(text.trim()) { + Ok(summary) => summary, + Err(error) => { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return StopResult::Failed(if detail.is_empty() { + format!("the bundled CLI's stop output could not be read (exit {code}: {error})") + } else { + format!("the bundled CLI's stop output could not be read (exit {code}): {detail}") + }); + } + }; + if summary.schema != SCHEMA { + return StopResult::Failed(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + summary.schema + )); + } + let agrees = matches!( + (summary.outcome, summary.proxy), + (Outcome::Stopped, Proxy::Stopped) + | (Outcome::Stopped, Proxy::StoppedOrphan) + | (Outcome::NotRunning, Proxy::NotRunning) + ); + if exit_code != Some(0) + || !summary.ok + || summary.exit_code != 0 + || !summary.runtime_down + || !agrees + { + return StopResult::Failed(format!( + "{} (outcome {}, proxy {}, exit {}, process exit {})", + summary.message, + summary.outcome.as_str(), + summary.proxy.as_str(), + summary.exit_code, + exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_owned()) + )); + } + StopResult::Stopped(summary) +} + +/// Run the bundled `ocx stop --json`, under the caller's deadline. +pub async fn run(app: &AppHandle, deadline: Instant) -> StopResult { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["stop", "--json"]), + Err(error) => { + return StopResult::Failed(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => StopResult::Failed(format!("the bundled CLI could not be run ({error})")), + Err(_) => { + StopResult::Failed("the bundled CLI did not finish stopping before the deadline".into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::{read, StopResult, SCHEMA}; + + fn document(ok: bool, outcome: &str, exit: i32, down: bool, proxy: &str) -> String { + format!( + r#"{{"schema":"ocx-stop/1","ok":{ok},"outcome":"{outcome}","exitCode":{exit}, + "runtimeDown":{down},"service":"absent","proxy":"{proxy}", + "sharedTeardown":"restored","message":"a message"}}"# + ) + } + + #[test] + fn a_clean_stop_with_the_runtime_down_is_the_only_success() { + let ok = document(true, "stopped", 0, true, "stopped"); + let result = read(Some(0), ok.as_bytes(), b""); + assert!(result.is_stopped()); + match result { + StopResult::Stopped(summary) => { + assert_eq!(summary.schema, SCHEMA); + assert_eq!(summary.outcome, "stopped"); + assert!(summary.runtime_down); + } + StopResult::Failed(reason) => panic!("{reason}"), + } + // Nothing was running is equally a runtime that is down. + assert!(read( + Some(0), + document(true, "not-running", 0, true, "not-running").as_bytes(), + b"" + ) + .is_stopped()); + } + + #[test] + fn a_non_zero_exit_is_never_folded_into_success() { + // 79 and 80 report a proxy that went down with an obligation still owed. The runtime may + // be down, but the run did not succeed, and an update must not install over it. + for (outcome, exit) in [ + ("history-incomplete", 79), + ("history-deferred", 80), + ("failed", 1), + ] { + let document = document(false, outcome, exit, true, "stopped"); + let result = read(Some(exit), document.as_bytes(), b""); + assert!(!result.is_stopped(), "{outcome}"); + assert!(result.describe().contains(outcome)); + } + } + + #[test] + fn the_process_status_and_the_document_have_to_agree() { + let clean = document(true, "stopped", 0, true, "stopped"); + // A run that exited non-zero is a refusal even when its summary reads clean: taking the + // document's word for its own exit status is taking one claim as evidence of itself. + assert!(!read(Some(1), clean.as_bytes(), b"").is_stopped()); + assert!(!read(None, clean.as_bytes(), b"").is_stopped()); + // And a summary that contradicts its own exit code is not a stop either. + let contradictory = document(true, "stopped", 1, true, "stopped"); + assert!(!read(Some(0), contradictory.as_bytes(), b"").is_stopped()); + } + + #[test] + fn a_document_that_contradicts_itself_is_not_a_stop() { + // The CLI's summarizer cannot emit this, and the reader does not assume that. + let mixed = document(true, "failed", 0, true, "respawned"); + assert!(!read(Some(0), mixed.as_bytes(), b"").is_stopped()); + let orphan = document(true, "stopped", 0, true, "stopped-orphan"); + assert!(read(Some(0), orphan.as_bytes(), b"").is_stopped()); + // An outcome or a proxy state this shell does not know is not read at all. + let future = document(true, "stopped", 0, true, "stopped") + .replace("\"proxy\":\"stopped\"", "\"proxy\":\"parked\""); + assert!(!read(Some(0), future.as_bytes(), b"").is_stopped()); + } + + #[test] + fn a_runtime_still_up_is_a_failure_however_the_exit_reads() { + for proxy in [ + "respawned", + "stop-failed", + "ownership-refused", + "unresolvable-pid", + ] { + let document = document(true, "stopped", 0, false, proxy); + assert!( + !read(Some(0), document.as_bytes(), b"").is_stopped(), + "{proxy}" + ); + } + } + + #[test] + fn output_that_cannot_be_read_is_a_failure_not_a_stop() { + assert!(!read(Some(0), b"", b"boom").is_stopped()); + assert!(!read(Some(0), b"not json", b"").is_stopped()); + assert!(!read(None, b"", b"").is_stopped()); + let future = + document(true, "stopped", 0, true, "stopped").replace("ocx-stop/1", "ocx-stop/2"); + let result = read(Some(0), future.as_bytes(), b""); + assert!(!result.is_stopped()); + assert!(result.describe().contains("ocx-stop/2")); + } +} diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 96f5e963fea..2a1da6e90c7 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -1,12 +1,15 @@ -//! Starting, watching and draining the runtime this app owns. +//! Starting and watching the runtime this app owns. //! //! The spawn event stream used to be discarded into `_events`, which is why a sidecar that exited //! immediately — a binary built for a CPU instruction set this machine does not have, a port //! already taken, a corrupt install — presented as the same generic health failure as a slow start. //! The child's exit code and its last output were both available and both thrown away. They are //! consumed here instead, and they are what the startup diagnostic is made of. +//! +//! Stopping it is not here. D4 gives that to the bundled `ocx stop`, which owns the receipt-backed +//! teardown this process cannot perform on itself; see `runtime_stop.rs`. -use crate::{discovery::ProxyEndpoint, proxy::ProxyClient}; +use crate::endpoint::ProxyEndpoint; use std::{ collections::VecDeque, sync::{Arc, Mutex}, @@ -16,17 +19,10 @@ use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, ShellExt, }; -use tokio::time::{sleep, Duration, Instant}; - /// How much sidecar output the diagnostic keeps. Enough to carry a stack trace or a startup /// refusal, bounded so a chatty runtime cannot grow the buffer for the life of the process. const MAX_LINES: usize = 40; -/// How long a graceful stop may take before it is reported as incomplete. The runtime's own stop -/// restores client configuration and lets in-flight requests finish, so this is generous on -/// purpose; it bounds a hang, it does not pace a healthy stop. -pub const DRAIN_DEADLINE: Duration = Duration::from_secs(15); - /// How the sidecar process ended. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct SidecarExit { @@ -161,87 +157,9 @@ pub fn start( Ok(child) } -/// What a graceful stop did. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum DrainOutcome { - /// This app did not start the runtime, so it does not stop it. Someone else's proxy outlives - /// this app's quit, which is the whole point of only ever draining what we own. - NotOwned, - /// The endpoint stopped answering within the deadline. - Stopped, - /// The stop was accepted and the endpoint still answers. - StillRunning, - /// The stop request itself failed. - Refused(String), -} - -impl DrainOutcome { - pub fn failure(&self) -> Option { - match self { - Self::NotOwned | Self::Stopped => None, - Self::StillRunning => { - Some("the runtime still answers after the graceful stop deadline".to_owned()) - } - Self::Refused(error) => Some(error.clone()), - } - } -} - -/// Stop the runtime this app owns and wait until it is actually gone. -/// -/// Only an app-owned runtime reaches here, which is why the management stop is the right -/// instrument: its documented refusals are about launchd, systemd and the Windows respawn window, -/// none of which apply to a child this process spawned. Taking over somebody else's *managed* -/// runtime is a different act and needs the bundled `ocx stop` — that is D4, and it is lane A's -/// contract, not this path. -pub async fn drain(proxy: &ProxyClient, owned: bool, watch: &SidecarWatch) -> DrainOutcome { - if !owned { - return DrainOutcome::NotOwned; - } - let deadline = Instant::now() + DRAIN_DEADLINE; - let refusal = match proxy.stop_within(deadline).await { - Some(Ok(_)) => None, - Some(Err(error)) => { - // A runtime that has already gone is a drained runtime, not a failed stop. - if gone(proxy, watch, deadline).await { - return DrainOutcome::Stopped; - } - Some(format!("{error:?}")) - } - None => Some("the stop request did not answer before the deadline".to_owned()), - }; - while Instant::now() < deadline { - if gone(proxy, watch, deadline).await { - return DrainOutcome::Stopped; - } - sleep(Duration::from_millis(200)).await; - } - match refusal { - Some(error) => DrainOutcome::Refused(error), - None => DrainOutcome::StillRunning, - } -} - -/// Whether the runtime is actually gone, rather than merely not answering the way we hoped. -/// -/// Two facts count, and no others. The child reporting its own termination through the spawn event -/// stream is conclusive. Failing that, the endpoint *refusing a connection* says the listener has -/// released the port. A timeout, an unauthorized reply or a body that will not parse are none of -/// those: they mean something answered, or might still be there. Reading any error as proof is how -/// a stop that never happened gets reported as a completed drain. -async fn gone(proxy: &ProxyClient, watch: &SidecarWatch, deadline: Instant) -> bool { - if watch.exit().is_some() { - return true; - } - matches!( - proxy.alive_within(deadline).await, - Some(Err(error)) if error.is_unreachable() - ) -} - #[cfg(test)] mod tests { - use super::{DrainOutcome, SidecarEvent, SidecarExit, SidecarWatch, MAX_LINES}; + use super::{SidecarEvent, SidecarExit, SidecarWatch, MAX_LINES}; #[test] fn the_exit_code_survives_the_event_stream() { @@ -305,15 +223,4 @@ mod tests { "exited without reporting a code" ); } - - #[test] - fn only_an_incomplete_drain_reports_a_failure() { - assert!(DrainOutcome::NotOwned.failure().is_none()); - assert!(DrainOutcome::Stopped.failure().is_none()); - assert!(DrainOutcome::StillRunning.failure().is_some()); - assert_eq!( - DrainOutcome::Refused("Unreachable".into()).failure(), - Some("Unreachable".to_owned()) - ); - } } From af2d42eafbf8633afe0516399bb784fd06a05beb Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:27:47 +0900 Subject: [PATCH 19/24] test(desktop): read both CLI contracts against the CLI that defines them The schema strings, the status and outcome vocabularies, the three-valued liveness rule and the stop's accept-set are asserted on both sides in one file, so a change to either is found here rather than on a user's machine. The liveness test pins what must never happen as firmly as what must: no path reaches a spawn without a proven absence, and a live listener this app cannot manage is neither attached to nor started beside. --- scripts/test-layout/layout.json | 1 + tests/clients/desktop-cli-contracts.test.ts | 133 ++++++++++++++++++ tests/clients/desktop-exit-ownership.test.ts | 31 ++-- .../clients/desktop-runtime-identity.test.ts | 6 +- tests/clients/desktop-startup-surface.test.ts | 21 ++- tests/fixtures/test-layout-expected.json | 1 + 6 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 tests/clients/desktop-cli-contracts.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cc3333d4d4f..41daa0cd488 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -720,6 +720,7 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", diff --git a/tests/clients/desktop-cli-contracts.test.ts b/tests/clients/desktop-cli-contracts.test.ts new file mode 100644 index 00000000000..d4ae062c866 --- /dev/null +++ b/tests/clients/desktop-cli-contracts.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The two CLI surfaces the shell drives, read against the CLI that defines them. + * + * D5 and D4: the shell stops resolving the home, the port and liveness itself, and stops performing + * the teardown itself. `src/cli/resolve.ts` and `src/cli/stop-report.ts` own those answers; the Rust + * side is a reader. Both halves are asserted here together so a schema, a status value or an + * outcome name cannot change on one side and be discovered on a user's machine. + * + * The rule that matters most is the one a reader can get wrong quietly: liveness has three answers, + * and only a proven absence authorises starting a runtime. Everything that can go wrong on the + * shell side has to fold into the third one, because the reading that must never happen is "the + * resolve failed, so nobody must be listening". + */ +const SHELL = "desktop/src-tauri/src"; +const RESOLVE_RS = repoPath(`${SHELL}/resolve.rs`); +const STOP_RS = repoPath(`${SHELL}/runtime_stop.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const RESOLVE_TS = repoPath("src/cli/resolve.ts"); +const STOP_TS = repoPath("src/cli/stop-report.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop CLI contracts", () => { + const resolveRs = code(RESOLVE_RS); + const stopRs = code(STOP_RS); + const resolveTs = code(RESOLVE_TS); + const stopTs = code(STOP_TS); + + test("both sides name the same wire versions", () => { + expect(resolveTs).toContain('RESOLVE_SCHEMA = "ocx-resolve/1"'); + expect(resolveRs).toContain('pub const SCHEMA: &str = "ocx-resolve/1"'); + expect(stopTs).toContain('STOP_SUMMARY_SCHEMA = "ocx-stop/1"'); + expect(stopRs).toContain('pub const SCHEMA: &str = "ocx-stop/1"'); + // A document announcing anything else is not understood rather than half-read. + expect(resolveRs).toContain("resolved.schema != SCHEMA"); + expect(stopRs).toContain("summary.schema != SCHEMA"); + }); + + test("liveness keeps its three answers, and only one of them authorises a start", () => { + // Two reach the wire; the third exits 1 before the document is built. + expect(resolveTs).toContain('status: "live" | "absent-proven"'); + expect(resolveRs).toContain('#[serde(rename_all = "kebab-case")]'); + expect(resolveRs).toContain(" Live,"); + expect(resolveRs).toContain(" AbsentProven,"); + const rule = resolveRs.slice(resolveRs.indexOf("pub fn may_start(")); + const body = rule.slice(0, rule.indexOf("\n}")); + expect(body).toContain("Some(Status::AbsentProven)"); + expect(body).not.toContain("Status::Live"); + }); + + test("a live listener this app cannot manage is neither attached to nor started beside", () => { + // Core's liveness predicate accepts a connected client's listener on purpose, so + // duplicate-start avoidance can see it; a caller that needs the management plane has to + // discriminate on the role rather than narrow that predicate. + expect(resolveTs).toContain("role?: string"); + const verdict = resolveRs.slice(resolveRs.indexOf("pub fn live_verdict(")); + const body = verdict.slice(0, verdict.indexOf("\n}")); + expect(body).toContain('role.as_deref() == Some("client")'); + expect(body).toContain("LiveVerdict::Unusable"); + // And an address this shell cannot reach on loopback is the same kind of answer. + expect(body).toContain("loopback_reachable(resolved.liveness.hostname.as_deref())"); + const startup = code(STARTUP); + const unusable = startup.indexOf("resolve::LiveVerdict::Unusable(reason) =>"); + const spawn = startup.indexOf("spawn_runtime(app, endpoint, &watch)"); + expect(unusable).toBeGreaterThan(-1); + expect(startup.slice(unusable, spawn)).toContain("return;"); + }); + + test("everything that can go wrong on this side folds into unknown", () => { + const reader = resolveRs.slice(resolveRs.indexOf("pub fn read("), resolveRs.indexOf("pub async fn run(")); + // A non-zero exit is the CLI's own refusal, including the exit 1 it uses for unknown liveness. + expect(reader).toContain("if exit_code != Some(0) {"); + expect(reader).toContain("Resolution::Unknown"); + const runner = resolveRs.slice(resolveRs.indexOf("pub async fn run(")); + const body = runner.slice(0, runner.indexOf("\n}")); + // A missing binary, a failed spawn and a deadline all answer the same way. + expect(body.match(/Resolution::Unknown/g) || []).toHaveLength(3); + expect(body).toContain("timeout_at(deadline, command.output())"); + }); + + test("the startup sequence refuses to start on anything but a proven absence", () => { + const startup = code(STARTUP); + const run = startup.slice(startup.indexOf("async fn run(app: &AppHandle)")); + const unknown = run.indexOf("let Some(answer) = resolution.resolved() else {"); + const attach = run.indexOf("match resolve::live_verdict(&resolution) {"); + const guard = run.indexOf("if !resolve::may_start(&resolution) {"); + const spawn = run.indexOf("spawn_runtime(app, endpoint, &watch)"); + expect(unknown).toBeGreaterThan(-1); + expect(attach).toBeGreaterThan(unknown); + expect(guard).toBeGreaterThan(attach); + expect(spawn).toBeGreaterThan(guard); + // The unresolved branch fails the state; it does not fall through to a start. + expect(run.slice(unknown, attach)).toContain("Phase::Resolving,"); + expect(run.slice(unknown, attach)).toContain("return;"); + }); + + test("a stop is a success only when the CLI said so twice", () => { + // The process status and the document have to agree, and the document has to say the runtime + // is down: taking the summary's word for its own exit status is taking a claim as its own + // evidence. + expect(stopTs).toContain("ok: signals.exitCode === 0"); + expect(stopTs).toContain("runtimeDown: record.proxy ==="); + const reader = stopRs.slice(stopRs.indexOf("pub fn read("), stopRs.indexOf("pub async fn run(")); + expect(reader).toContain("if exit_code != Some(0)"); + expect(reader).toContain("|| !summary.ok"); + expect(reader).toContain("|| summary.exit_code != 0"); + expect(reader).toContain("|| !summary.runtime_down"); + // And the document has to agree with itself rather than be trusted to. + expect(reader).toContain("|| !agrees"); + expect(reader).toContain("(Outcome::Stopped, Proxy::Stopped)"); + expect(reader).toContain("(Outcome::NotRunning, Proxy::NotRunning)"); + expect(reader).toContain("StopResult::Failed"); + const stopped = reader.indexOf("StopResult::Stopped(summary)"); + expect(stopped).toBeGreaterThan(reader.indexOf("if exit_code != Some(0)")); + }); + + test("the outcomes the shell can be handed are the outcomes the CLI can emit", () => { + expect(stopTs).toContain( + 'outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed"', + ); + // The shell does not re-derive the outcome; it carries the CLI's own words into its diagnostic. + expect(stopRs).toContain("summary.outcome"); + expect(stopRs).toContain("summary.exit_code"); + expect(stopRs).toContain("summary.message"); + expect(stopRs).not.toContain('== "stopped"'); + }); +}); diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index ed88b89abd8..27cc075f3fa 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -112,7 +112,9 @@ describe("desktop exit ownership", () => { expect(body).toContain("claim_drain(ExitReason::CoordinatedRestart)"); expect(body).toContain("drain_current(app).await"); expect(body).toContain("coordinator.finish_drain(verdict)"); - expect(exit).toContain("sidecar::drain(&proxy, true, &watch)"); + // D4: the stop is the bundled CLI's, which owns the receipt-backed teardown this process + // cannot perform on itself. + expect(exit).toContain("runtime_stop::run(app, deadline).await"); }); test("a failed drain is not recorded as a drain, and a restart refuses it", () => { @@ -247,24 +249,27 @@ describe("desktop exit ownership", () => { }); test("a drain never runs against a runtime this app did not start", () => { - const sidecar = code(repoPath(`${SRC}/sidecar.rs`)); - const drain = sidecar.slice( - sidecar.indexOf("pub async fn drain("), - sidecar.indexOf("async fn gone("), - ); - expect(drain).toContain("if !owned {"); - expect(drain).toContain("return DrainOutcome::NotOwned;"); - expect(drain.indexOf("if !owned {")).toBeLessThan(drain.indexOf("proxy.stop_within(")); + const exit = code(EXIT); + const drain = exit.slice(exit.indexOf("pub async fn drain_current(")); + const body = drain.slice(0, drain.indexOf("\nenum Ownership")); + // Only a confirmed-ours runtime is stopped. A foreign one is left alone, and one that cannot + // be identified stops nothing at all. + expect(body).toContain("Ownership::Foreign => DrainVerdict::Drained"); + expect(body).toContain("Ownership::Unknown => DrainVerdict::OwnershipUnknown"); + expect(body.indexOf("Ownership::Ours =>")).toBeLessThan(body.indexOf("runtime_stop::run")); + // Nothing in the shell performs the stop itself any more. + expect(code(repoPath(`${SRC}/sidecar.rs`))).not.toContain("fn drain("); + expect(code(repoPath(`${SRC}/proxy.rs`))).not.toContain("Method::POST"); }); test("only an observed exit or a refused connection proves the runtime stopped", () => { - const sidecar = code(repoPath(`${SRC}/sidecar.rs`)); - const start = sidecar.indexOf("async fn gone("); + const exit = code(EXIT); + const start = exit.indexOf("async fn confirm("); expect(start).toBeGreaterThan(-1); - const body = sidecar.slice(start, sidecar.indexOf("\n}", start)); + const body = exit.slice(start, exit.indexOf("fn hide_windows(", start)); expect(body).toContain("watch.exit().is_some()"); expect(body).toContain("error.is_unreachable()"); // Any-error-means-gone is the shape this replaces. - expect(sidecar).not.toContain("proxy.is_alive().await.is_err()"); + expect(exit).not.toContain("proxy.is_alive().await.is_err()"); }); }); diff --git a/tests/clients/desktop-runtime-identity.test.ts b/tests/clients/desktop-runtime-identity.test.ts index 94ff93b2263..3ef944fa4b0 100644 --- a/tests/clients/desktop-runtime-identity.test.ts +++ b/tests/clients/desktop-runtime-identity.test.ts @@ -115,7 +115,11 @@ describe("desktop runtime identity", () => { expect(body).toContain("proxy.bind(identity)"); expect(body).toContain("state.confirm_ownership(identity)"); // An answer that cannot be read leaves the app owning nothing. - expect(body).toContain("_ => return,"); + expect(body).toContain("_ => return None,"); + // And the sequence does not report Ready against an instance it could not identify: the + // management token is only ever sent to a bound one, so a dashboard there would not load. + const callers = startup.match(/bind\(app, &proxy, deadline\)\.await\.is_none\(\)/g) || []; + expect(callers).toHaveLength(2); }); test("the Windows app origin is allowed, and nothing wider", () => { diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 4f6ecbca416..781416398c5 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -48,7 +48,7 @@ describe("desktop startup surface", () => { for (const call of [ "block_on", "ensure_proxy", - "discovery::current()", + "resolve::run", "ProxyClient::new", "tray_availability::detect()", "tray::install", @@ -61,7 +61,8 @@ describe("desktop startup surface", () => { test("resolving and registering are states that own their work", () => { expect(startup).toContain("Phase::Resolving"); - expect(startup).toContain("discovery::current()"); + // D5: the shell asks the bundled CLI rather than reading a port record and guessing. + expect(startup).toContain("resolve::run(app, deadline).await"); expect(startup).toContain("ProxyClient::new(endpoint"); expect(startup).toContain("Phase::Registering"); expect(startup).toContain("tray_availability::detect"); @@ -89,7 +90,7 @@ describe("desktop startup surface", () => { test("the child's exit code is what ends the wait early", () => { const wait = startup.indexOf("Phase::Waiting, None);"); expect(wait).toBeGreaterThan(-1); - const loop = startup.slice(wait, startup.indexOf("async fn healthy_by", wait)); + const loop = startup.slice(wait, startup.indexOf("async fn register(", wait)); expect(loop).toContain("watch.exit()"); expect(loop).toContain("exit.describe()"); }); @@ -97,9 +98,12 @@ describe("desktop startup surface", () => { test("one deadline covers the whole sequence and bounds every probe under it", () => { expect(startup).toContain("pub const DEADLINE: Duration"); expect(startup).toContain("let deadline = started + DEADLINE;"); - expect(startup).toContain("let probing_from = Instant::now();"); - expect(startup).toContain("(probing_from + ATTACH_BUDGET).min(deadline)"); - expect(startup).not.toContain("(started + ATTACH_BUDGET)"); + // The budget for finding an existing runtime is the CLI's now, not a second one here: the + // tuned probe budgets exist because a shell-side reimplementation answered "nobody is + // listening" twice and started duplicate proxies. + expect(startup).not.toContain("ATTACH_BUDGET"); + expect(startup).not.toContain("fn healthy_by"); + expect(startup).toContain("resolve::run(app, deadline).await"); // A probe bounded only by the client's own timeout overruns whatever budget it was started // under, which is how a stated ceiling becomes an unstated one. expect(startup).not.toContain("proxy.is_alive()"); @@ -110,7 +114,10 @@ describe("desktop startup surface", () => { expect(startup).toContain("tokio::time::timeout_at(deadline, receiver)"); const proxy = code(PROXY); expect(proxy).toContain("timeout_at(deadline, self.is_alive())"); - expect(proxy).toContain("timeout_at(deadline, self.stop())"); + // The stop is the bundled CLI's now, under its own deadline. + const stop = code(repoPath("desktop/src-tauri/src/runtime_stop.rs")); + expect(stop).toContain("timeout_at(deadline, command.output())"); + expect(stop).toContain("pub const DEADLINE: Duration"); }); test("a retry waits on the child it already started rather than starting a second one", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c7a5ad57a5d..a7a7fcee72f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -551,6 +551,7 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", From a54092a8bc26d6854862f772e6978e8917c34351 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:27:47 +0900 Subject: [PATCH 20/24] docs(structure): record that the shell resolves and stops through the CLI What the three liveness answers mean, which one authorises a start, and why a stop is accepted only on exit 0 with the runtime reported down. --- structure/desktop-shell.md | 29 ++++++++++++++++++++++------- structure/overview.md | 3 ++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 3d8fe97fb3c..29408205c78 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -22,17 +22,25 @@ declares no `remote` entry, and Tauri checks the ACL for any invoke from a non-l The window is created and shown before anything is registered, resolved, probed or started, and `desktop/src-tauri/src/startup.rs` runs the whole sequence inside it as named states — registering, resolving, probing, attaching or starting, waiting, then ready or failed — under one -30-second deadline. Every probe beneath that deadline is bounded by the time left rather than by the -HTTP client's own timeout, so the ceiling is the ceiling, and the budget for finding an existing -runtime is counted from when probing starts rather than from process start — counted from the start, -a slow tray or session-bus registration would spend it and then present as nothing listening, which -starts a second proxy beside the one already there. The failure state carries a retry, the +30-second deadline. Every call beneath that deadline is bounded by the time left rather than by its +own timeout, so the ceiling is the ceiling. The failure state carries a retry, the child's exit code and a copyable diagnostic naming the state, the endpoint, the configuration home and the runtime's last output; `desktop/src-tauri/src/sidecar.rs` consumes the spawn event stream into that record instead of discarding it, which is what makes an immediate sidecar exit distinguishable from a slow start. The page asks for the state list and the run's progress rather than reconstructing either, because the early states finish faster than a listener can attach. +The shell resolves nothing itself. Resolving runs the bundled `ocx resolve --json` and reads one +`ocx-resolve/1` document: the configuration home, the effective port, and a liveness verdict with +three answers rather than two. `live` means attach as a guest; `absent-proven` means every +recorded and configured endpoint was definitively dead, and **only that authorises starting a +runtime**. Everything else is unknown — a non-zero exit, a timeout, output that will not parse, a +schema this shell does not know, a missing binary — and unknown fails the state with a diagnostic +and a retry. It is never read as absence, because that reading is what put a second proxy next to +the one already running. This replaces a file that read `runtime-port.json`, fell back to 10100 and +started there, so a user with a configured `config.port` was started on a port they had not +chosen; the probe budgets that decision needs live in the CLI, where they were tuned. + Registering runs first, before the runtime is touched. A login launch starts hidden, so a tray installed only after a successful start would leave a failed start with no window and no icon. The login item is registered in that state too, before the tray, so its Start at Login checkbox reads @@ -58,8 +66,15 @@ an owner's stop sent to that listener is a stop sent to somebody else's runtime. cannot be identified is left alone. A runtime counts as gone only when the child reports its own exit or the endpoint refuses a -connection; a timeout or an unauthorized reply is not proof. Nothing kills the child; the CLI's stop -restores client configuration and lets in-flight requests finish. +connection; a timeout or an unauthorized reply is not proof. The stop itself is the bundled +`ocx stop --json`, not a management call from inside this process: the CLI's stop owns the +receipt-backed teardown, the drain, the Windows respawn verification and the client-configuration +restore, and an in-process endpoint cannot own its own teardown because launchd and systemd can +terminate the request handler during self-unload. The shell reads that run's `ocx-stop/1` summary +rather than inferring it, and treats a stop as done only when the CLI reported exit 0 **and** that +no proxy of this home is left running. A service that failed while the proxy happened to stop +satisfies the second and not the first, and it is exactly the case that may respawn the runtime a +moment later. Nothing kills the child. A drain that does not complete within `DRAIN_DEADLINE` is **not** recorded as a drain. It becomes `DrainFailed`, and an unidentifiable runtime becomes `OwnershipUnknown`. A user's quit still diff --git a/structure/overview.md b/structure/overview.md index a53748864c9..5c226f2dfc0 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -175,7 +175,8 @@ still cover the rule, which is a judgement only review makes. predefined Quit replaced because it raises no cancellable event. Every ending drains first — the tray's Quit, an update's coordinated restart, and a window close on a session with no tray all hold the exit, stop the app-owned runtime through the management stop, and treat only an observed - child exit or a refused connection as proof it stopped. Ownership is re-established from the pid + child exit or a refused connection as proof it stopped. The stop is the bundled `ocx stop --json`, + accepted only on exit 0 with the runtime reported down. Ownership is re-established from the pid the endpoint reports rather than carried in a flag, a drain that does not complete is recorded as failed rather than drained — which a quit tolerates and a coordinated restart refuses — and an update installs only after the runtime it is replacing is confirmed stopped. No shell file kills From 7c5f69e7ad48984ffdbfc3861294f5b669953d6c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 12:36:25 +0900 Subject: [PATCH 21/24] fix(desktop): follow the stop outcome into its own type One assertion still compared the summary's outcome to a string after it became a closed enum, and clippy --all-targets compiles the test target, so the Rust tests were skipped behind it rather than run. My static pass checked the code paths and the source oracles and did not re-read the crate's own unit tests after the type changed; the sweep now looks for any comparison of either typed field against a string literal, and finds none. --- desktop/src-tauri/src/runtime_stop.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/runtime_stop.rs b/desktop/src-tauri/src/runtime_stop.rs index 520bc632ff8..c4099b8304b 100644 --- a/desktop/src-tauri/src/runtime_stop.rs +++ b/desktop/src-tauri/src/runtime_stop.rs @@ -202,7 +202,7 @@ pub async fn run(app: &AppHandle, deadline: Instant) -> StopResult { #[cfg(test)] mod tests { - use super::{read, StopResult, SCHEMA}; + use super::{read, Outcome, Proxy, StopResult, SCHEMA}; fn document(ok: bool, outcome: &str, exit: i32, down: bool, proxy: &str) -> String { format!( @@ -220,7 +220,8 @@ mod tests { match result { StopResult::Stopped(summary) => { assert_eq!(summary.schema, SCHEMA); - assert_eq!(summary.outcome, "stopped"); + assert_eq!(summary.outcome, Outcome::Stopped); + assert_eq!(summary.proxy, Proxy::Stopped); assert!(summary.runtime_down); } StopResult::Failed(reason) => panic!("{reason}"), From 2b81596fc267a8a82d68e02e52b180bf5e9606ac Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 13:09:54 +0900 Subject: [PATCH 22/24] fix(desktop): merge the app-origin rule rather than pick a side of it #5399 made the window load its own origin and hid the Windows console, and it landed on the three files this lane owns. The console attribute in main.rs and the Stop-settle intent in tray.rs carry through unchanged - the second is now the coordinator's job, which confirms the stop through the bundled CLI instead of polling /healthz and reports a stuck one rather than printing to a console that is no longer there. The app origin is one function now instead of the two the auto-merge left side by side. It keeps #5399's contract - the custom scheme everywhere, the http spelling WebView2 needs, https refused because that is not what the pinned Tauri serves the app over, and not gated on the platform - and adds this lane's tightening: no port, because a port means something else is answering rather than the app. #5399's test asserted through navigation_allowed, which now takes an AppHandle and cannot be built in a unit test, so its cases moved onto the helper directly and its loopback-endpoint case is covered by the source oracle. --- desktop/src-tauri/src/window.rs | 4 +++- tests/clients/desktop-runtime-identity.test.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index a641f7b0314..81969f7189e 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -118,7 +118,9 @@ mod tests { fn the_app_origin_is_allowed_by_both_spellings_on_every_platform() { // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. The // second is not gated on the platform: the origin is the app's wherever it is served. - assert!(is_app_origin(&url("tauri://localhost/index.html?port=10100"))); + assert!(is_app_origin(&url( + "tauri://localhost/index.html?port=10100" + ))); assert!(is_app_origin(&url( "http://tauri.localhost/index.html?port=10100" ))); diff --git a/tests/clients/desktop-runtime-identity.test.ts b/tests/clients/desktop-runtime-identity.test.ts index 3ef944fa4b0..8ce8f2de491 100644 --- a/tests/clients/desktop-runtime-identity.test.ts +++ b/tests/clients/desktop-runtime-identity.test.ts @@ -122,14 +122,18 @@ describe("desktop runtime identity", () => { expect(callers).toHaveLength(2); }); - test("the Windows app origin is allowed, and nothing wider", () => { + test("the app's own origin is allowed by both spellings, and nothing wider", () => { const window = code(WINDOW); - const rule = window.slice(window.indexOf("pub fn app_origin_allowed(")); + const rule = window.slice(window.indexOf("fn is_app_origin(")); const body = rule.slice(0, rule.indexOf("\n}")); + // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. + expect(body).toContain('"tauri" => true'); expect(body).toContain('url.host_str() == Some("tauri.localhost")'); + // Not https, which is not the scheme the pinned Tauri serves the app over, and not a port, + // which would mean something else is answering. expect(body).toContain("url.port().is_none()"); - expect(body).toContain("windows"); - expect(window).toContain('app_origin_allowed(url, cfg!(target_os = "windows"))'); + expect(body).not.toContain('"https"'); + expect(window).toContain("if is_app_origin(url) {"); // Not localhost generally, and not a remote IPC widening. expect(window).not.toContain('Some("localhost")'); expect(readFileSync(repoPath("desktop/src-tauri/capabilities/default.json"), "utf8")).not.toContain( From 68d6b0ae5c3b9b7bfe506efb8fc8b6e7afb00117 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 13:44:27 +0900 Subject: [PATCH 23/24] fix(desktop): give a foreign runtime its own widget state `ProxyError::Foreign` was added without updating the widget's match on it. `widget.rs` compiles only on macOS, so the Linux `desktop shell` job never sees it and the non-exhaustive match surfaced as a lone E0004 in `macos widget + bundle`, which then failed the aggregate `ci`. The new arm is explicit rather than a catch-all. A runtime this app did not start is a different event from a fault: folding it into `degraded` or `unreachable` would tell a user whose own npm or CLI runtime holds the port that something is broken. It gets its own `foreign` state, which the widget renders in the neutral secondary colour because `tone` does not know the string. Keeping the match exhaustive also means the next variant added to `ProxyError` is a compile error here again rather than a silent mislabel. --- desktop/src-tauri/src/widget.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs index 4db3005f034..a2e38cfc78e 100644 --- a/desktop/src-tauri/src/widget.rs +++ b/desktop/src-tauri/src/widget.rs @@ -68,6 +68,8 @@ mod macos { Unauthorized, Http, Decode, + /// The port answered, but as something other than the runtime this shell is bound to. + Foreign, } fn state_for_error( @@ -85,6 +87,18 @@ mod macos { "Needs API key", Some("This proxy requires an API key.".into()), ), + // A runtime this app did not start is a different event from a fault, so it does not + // borrow the vocabulary of one. "degraded" would claim the proxy is misbehaving and + // "unreachable" would claim nothing is there; a user who started the runtime from npm + // or the CLI themselves would read either as a defect in a setup that is working. + // The widget has no red for this: `tone` in `app/Sources/OpenCodexWidget/Views.swift` + // maps a state it does not know to the neutral secondary colour, which is the right + // signal for "serving, just not ours". + ErrorKind::Foreign => ( + "foreign", + "External runtime", + Some("This port is served by a runtime this app did not start.".into()), + ), ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail), } } @@ -95,6 +109,7 @@ mod macos { ProxyError::Unauthorized => (ErrorKind::Unauthorized, None), ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))), ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())), + ProxyError::Foreign => (ErrorKind::Foreign, None), } } @@ -434,7 +449,7 @@ mod macos { } #[test] - fn error_state_mapping_covers_four_kinds() { + fn error_state_mapping_covers_every_kind() { assert_eq!( state_for_error(ErrorKind::Unreachable, None).0, "unreachable" @@ -451,6 +466,19 @@ mod macos { state_for_error(ErrorKind::Decode, Some("bad".into())).0, "degraded" ); + assert_eq!(state_for_error(ErrorKind::Foreign, None).0, "foreign"); + } + + #[test] + fn a_foreign_runtime_is_not_reported_as_a_failure() { + // The mapping is the whole point of the variant. Folding it into either neighbour + // tells a user whose own CLI or npm runtime holds the port that something is broken, + // and the widget is the one surface where that claim is read without any context. + assert_eq!(proxy_error(&ProxyError::Foreign).0, ErrorKind::Foreign); + let (state, title, detail) = state_for_error(ErrorKind::Foreign, None); + assert_eq!(state, "foreign"); + assert_eq!(title, "External runtime"); + assert!(detail.unwrap().contains("did not start")); } #[test] From 576899f381c9e5e88d54101e1e23ba37352b78c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 13:50:36 +0900 Subject: [PATCH 24/24] test(clients): follow the owner check into the install-state contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5400 moved the runtime validation of an ownership claim out of `src/service/state.ts` into `src/service/install-state-contract.mjs`, and the receiver changed from `ownership` to `value`. The oracle asserted the old literal, so it went red on the merge with `dev` while both sides were green alone — each of the two reads its own half and neither compiles the other. The assertion now reads both halves of core's answer: the runtime rejection a record on disk actually meets, and the exported `ServiceOwner` type every caller is compiled against. Splitting them matters here, because a parse that accepted a third owner and a type that forbade it would disagree exactly where a takeover happens, which is the case this file exists to catch. --- .../clients/desktop-install-identity.test.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index 256fdc8100e..797b2192e1b 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -5,12 +5,13 @@ import { repoPath } from "../helpers/repo-root"; /** * The desktop app's half of the runtime-ownership claim. * - * The claim lives in the shared service install state and `src/service/state.ts` owns it: the - * owner values, the field names, the three answers a read can give, and `ownershipGrantedTo`, which - * is the comparison an installation applies to its own locally stored install id. The shell holds - * the other half — an id of its own to compare against — and mirrors the rule rather than inventing - * one, because a weaker version of a question core already answers is how the shell ended up - * guessing a port it should have been told. + * The claim lives in the shared service install state, which core owns across two files: the + * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an + * installation applies to its own locally stored install id — are in `src/service/state.ts`. The + * shell holds the other half, an id of its own to compare against, and mirrors the rule rather + * than inventing one, because a weaker version of a question core already answers is how the + * shell ended up guessing a port it should have been told. * * Both halves are read here together, so a change on either side breaks this rather than leaving * the two to disagree in a place only a takeover would reveal. @@ -20,6 +21,7 @@ const IDENTITY = repoPath(`${SHELL}/identity.rs`); const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); +const CONTRACT = repoPath("src/service/install-state-contract.mjs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -29,6 +31,7 @@ describe("desktop install identity", () => { const identity = code(IDENTITY); const ownership = code(OWNERSHIP); const state = code(STATE); + const contract = code(CONTRACT); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -52,7 +55,11 @@ describe("desktop install identity", () => { }); test("the owner values are the ones the record accepts", () => { - expect(state).toContain('ownership.owner !== "cli" && ownership.owner !== "desktop"'); + // Both halves of core's answer are read. The runtime rejection is what a record on disk meets, + // and the exported type is what every caller is compiled against; a parse that accepted a + // third owner and a type that forbade it would disagree exactly where a takeover happens. + expect(contract).toContain('value.owner !== "cli" && value.owner !== "desktop"'); + expect(state).toContain('export type ServiceOwner = "cli" | "desktop"'); expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); expect(ownership).toContain(" Cli,"); expect(ownership).toContain(" Desktop,");