From e77e81f1e24373ab6e540e9e4f336fbf4f3988bc Mon Sep 17 00:00:00 2001 From: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:27:32 +0000 Subject: [PATCH 1/3] fix(macos): drain native fullscreen before close behavior Hiding or exiting the main window while it still occupies a macOS fullscreen Space (or is animating out of one) leaves a black blank plus leftover toolbar chrome. Exit fullscreen first, wait for the Space to go, then apply ask / minimize-to-tray / exit. Closes #507 --- src-tauri/src/commands/windows.rs | 336 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 31 +++ 2 files changed, 367 insertions(+) diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index c27f6dd2d5..5f01ff8ffb 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -2032,6 +2032,239 @@ pub fn can_hide_to_tray() -> bool { TRAY_AVAILABLE.load(AtomicOrdering::Relaxed) } +// ─── macOS native-fullscreen drain on close (issue #507) ─────────────── +// +// Native fullscreen on macOS is a separate Space. Intercepting +// `CloseRequested` and then hiding / prompting / exiting while that Space +// is still up — or still animating out — leaves the Space behind as a +// black blank with leftover toolbar chrome. +// +// tao reports `is_fullscreen() == false` from `windowWillExitFullScreen`, +// which is the *start* of AppKit's animation, not the end. Occupancy +// therefore has three states, and close must wait until Windowed. + +/// Occupancy of the macOS fullscreen Space for the main window. +/// +/// Not the same as `Window::is_fullscreen`: that flag drops at the start +/// of the exit animation, while the Space is still on screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MacosFullscreenOccupancy { + Windowed, + Fullscreen, + Transitioning, +} + +impl MacosFullscreenOccupancy { + const WINDOWED: u8 = 0; + const FULLSCREEN: u8 = 1; + const TRANSITIONING: u8 = 2; + + fn from_code(code: u8) -> Self { + match code { + Self::FULLSCREEN => Self::Fullscreen, + Self::TRANSITIONING => Self::Transitioning, + _ => Self::Windowed, + } + } + + fn code(self) -> u8 { + match self { + Self::Windowed => Self::WINDOWED, + Self::Fullscreen => Self::FULLSCREEN, + Self::Transitioning => Self::TRANSITIONING, + } + } +} + +/// Advance occupancy from one `is_fullscreen` sample. +/// +/// A falling edge (Fullscreen → not fullscreen) is Transitioning, not +/// Windowed: tao has dropped its flag but AppKit has not finished tearing +/// the Space down. +pub(crate) fn occupancy_after_observation( + current: MacosFullscreenOccupancy, + is_fullscreen: bool, +) -> MacosFullscreenOccupancy { + if is_fullscreen { + MacosFullscreenOccupancy::Fullscreen + } else if current == MacosFullscreenOccupancy::Fullscreen { + MacosFullscreenOccupancy::Transitioning + } else { + current + } +} + +/// Whether close-button handling must drain native fullscreen first. +/// +/// Other platforms treat fullscreen as a maximized window and hide/close +/// tear it down correctly, so this is a no-op there. +pub(crate) fn should_drain_macos_fullscreen_before_close( + is_macos: bool, + is_fullscreen: bool, + occupancy: MacosFullscreenOccupancy, +) -> bool { + is_macos && (is_fullscreen || occupancy != MacosFullscreenOccupancy::Windowed) +} + +/// How long AppKit's Space teardown keeps running after tao reports +/// `is_fullscreen() == false`. +/// +/// ~0.5s is the system animation; 700ms is that plus slack so hide/exit +/// does not race the last frames (issue #507; tauri-apps/tauri#10580, +/// #12056). +#[cfg(target_os = "macos")] +const MACOS_FULLSCREEN_EXIT_SETTLE: std::time::Duration = std::time::Duration::from_millis(700); + +#[cfg(target_os = "macos")] +const MACOS_FULLSCREEN_EXIT_POLL: std::time::Duration = std::time::Duration::from_millis(50); + +#[cfg(target_os = "macos")] +const MACOS_FULLSCREEN_EXIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +#[cfg(target_os = "macos")] +static MACOS_FULLSCREEN_OCCUPANCY: AtomicU8 = AtomicU8::new(MacosFullscreenOccupancy::WINDOWED); + +#[cfg(target_os = "macos")] +static MACOS_FULLSCREEN_DRAIN_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +/// Keep occupancy in sync with the live window. Called from the main +/// window's `on_window_event` so a green-button exit (no `CloseRequested`) +/// still marks the animation as Transitioning. +pub(crate) fn observe_macos_fullscreen_state(window: &tauri::Window) { + #[cfg(target_os = "macos")] + { + let is_fs = window.is_fullscreen().unwrap_or(false); + let current = MacosFullscreenOccupancy::from_code( + MACOS_FULLSCREEN_OCCUPANCY.load(AtomicOrdering::Relaxed), + ); + let next = occupancy_after_observation(current, is_fs); + if next == current { + return; + } + MACOS_FULLSCREEN_OCCUPANCY.store(next.code(), AtomicOrdering::Release); + if next != MacosFullscreenOccupancy::Transitioning { + return; + } + // Drop Transitioning back to Windowed once the animation has had + // time to finish, unless a close-drain owns the wait. + if std::thread::Builder::new() + .name("macos-fs-occupancy-settle".into()) + .spawn(|| { + std::thread::sleep(MACOS_FULLSCREEN_EXIT_SETTLE); + if MACOS_FULLSCREEN_DRAIN_IN_FLIGHT.load(AtomicOrdering::Acquire) { + return; + } + let _ = MACOS_FULLSCREEN_OCCUPANCY.compare_exchange( + MacosFullscreenOccupancy::TRANSITIONING, + MacosFullscreenOccupancy::WINDOWED, + AtomicOrdering::AcqRel, + AtomicOrdering::Relaxed, + ); + }) + .is_err() + { + MACOS_FULLSCREEN_OCCUPANCY + .store(MacosFullscreenOccupancy::WINDOWED, AtomicOrdering::Release); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = window; + // The occupancy helpers are macOS-only at runtime. Reference them + // here so `cargo build` / clippy without `--all-targets` on Linux + // does not report them as dead — the unit tests still exercise + // the real cases. + let _ = should_drain_macos_fullscreen_before_close( + false, + false, + occupancy_after_observation(MacosFullscreenOccupancy::Windowed, false), + ); + let _ = MacosFullscreenOccupancy::from_code(MacosFullscreenOccupancy::Windowed.code()); + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn macos_fullscreen_should_drain(window: &tauri::Window) -> bool { + should_drain_macos_fullscreen_before_close( + true, + window.is_fullscreen().unwrap_or(false), + MacosFullscreenOccupancy::from_code( + MACOS_FULLSCREEN_OCCUPANCY.load(AtomicOrdering::Relaxed), + ), + ) +} + +/// Exit native fullscreen and wait until the Space is gone, then run `then` +/// on the main thread. `then` receives the same window so the configured +/// close behavior (hide / exit / ask) can run against a windowed window. +/// +/// A second call while a drain is in flight is a no-op: the in-flight +/// callback is the one close press that will be answered. +#[cfg(target_os = "macos")] +pub(crate) fn drain_macos_fullscreen_then( + window: tauri::Window, + then: impl FnOnce(tauri::Window) + Send + 'static, +) { + if MACOS_FULLSCREEN_DRAIN_IN_FLIGHT + .compare_exchange( + false, + true, + AtomicOrdering::AcqRel, + AtomicOrdering::Acquire, + ) + .is_err() + { + return; + } + + if window.is_fullscreen().unwrap_or(false) { + let _ = window.set_fullscreen(false); + } + MACOS_FULLSCREEN_OCCUPANCY.store( + MacosFullscreenOccupancy::TRANSITIONING, + AtomicOrdering::Release, + ); + + tracing::info!("[close] draining macOS native fullscreen before close behavior"); + + let app = window.app_handle().clone(); + if let Err(err) = std::thread::Builder::new() + .name("macos-fs-close-drain".into()) + .spawn(move || { + wait_for_macos_fullscreen_space_release(&window); + MACOS_FULLSCREEN_OCCUPANCY + .store(MacosFullscreenOccupancy::WINDOWED, AtomicOrdering::Release); + MACOS_FULLSCREEN_DRAIN_IN_FLIGHT.store(false, AtomicOrdering::Release); + if let Err(err) = app.run_on_main_thread(move || then(window)) { + tracing::warn!( + "[close] failed to hop back to main thread after fullscreen drain: {err}" + ); + } + }) + { + tracing::warn!("[close] failed to spawn fullscreen drain: {err}"); + MACOS_FULLSCREEN_DRAIN_IN_FLIGHT.store(false, AtomicOrdering::Release); + MACOS_FULLSCREEN_OCCUPANCY + .store(MacosFullscreenOccupancy::WINDOWED, AtomicOrdering::Release); + } +} + +#[cfg(target_os = "macos")] +fn wait_for_macos_fullscreen_space_release(window: &tauri::Window) { + let deadline = std::time::Instant::now() + MACOS_FULLSCREEN_EXIT_TIMEOUT; + while window.is_fullscreen().unwrap_or(false) { + if std::time::Instant::now() >= deadline { + tracing::warn!( + "[close] timed out waiting for macOS fullscreen to drop; applying close behavior anyway" + ); + break; + } + std::thread::sleep(MACOS_FULLSCREEN_EXIT_POLL); + } + // Flag drop is windowWillExitFullScreen. The Space is still up. + std::thread::sleep(MACOS_FULLSCREEN_EXIT_SETTLE); +} + /// Bring the hidden / minimized main workspace window back to the /// foreground. Used by: /// * single-instance plugin (second launch) @@ -2422,3 +2655,106 @@ mod settings_route_tests { assert_eq!(resolve_settings_route(None), "settings/appearance"); } } + +#[cfg(test)] +mod macos_fullscreen_close_tests { + use super::{ + occupancy_after_observation, should_drain_macos_fullscreen_before_close, + MacosFullscreenOccupancy, + }; + + /// Linux (and Windows) fullscreen is not a separate Space. Close must + /// not wait on it, or a maximized window would stall the hide/exit path. + #[test] + fn non_macos_never_drains_fullscreen() { + for occupancy in [ + MacosFullscreenOccupancy::Windowed, + MacosFullscreenOccupancy::Fullscreen, + MacosFullscreenOccupancy::Transitioning, + ] { + assert!( + !should_drain_macos_fullscreen_before_close(false, true, occupancy), + "{occupancy:?} must not drain off macOS" + ); + } + } + + #[test] + fn macos_windowed_does_not_drain() { + assert!(!should_drain_macos_fullscreen_before_close( + true, + false, + MacosFullscreenOccupancy::Windowed, + )); + } + + /// The red close button while native-fullscreen is the reported bug: + /// hide/exit without leaving the Space first. + #[test] + fn macos_live_fullscreen_drains() { + assert!(should_drain_macos_fullscreen_before_close( + true, + true, + MacosFullscreenOccupancy::Windowed, + )); + assert!(should_drain_macos_fullscreen_before_close( + true, + true, + MacosFullscreenOccupancy::Fullscreen, + )); + } + + /// tao drops `is_fullscreen` at windowWillExitFullScreen, which is + /// the start of the animation. Occupancy still blocks close so we + /// do not hide into the leftover Space. + #[test] + fn macos_transitioning_drains_even_when_flag_already_dropped() { + assert!(should_drain_macos_fullscreen_before_close( + true, + false, + MacosFullscreenOccupancy::Fullscreen, + )); + assert!(should_drain_macos_fullscreen_before_close( + true, + false, + MacosFullscreenOccupancy::Transitioning, + )); + } + + #[test] + fn occupancy_codes_roundtrip() { + use MacosFullscreenOccupancy::*; + for occupancy in [Windowed, Fullscreen, Transitioning] { + assert_eq!( + MacosFullscreenOccupancy::from_code(occupancy.code()), + occupancy + ); + } + assert_eq!( + MacosFullscreenOccupancy::from_code(255), + Windowed, + "unknown codes must degrade to windowed" + ); + } + + #[test] + fn occupancy_tracks_enter_and_falling_edge() { + use MacosFullscreenOccupancy::*; + + assert_eq!(occupancy_after_observation(Windowed, false), Windowed); + assert_eq!(occupancy_after_observation(Windowed, true), Fullscreen); + assert_eq!(occupancy_after_observation(Fullscreen, true), Fullscreen); + assert_eq!( + occupancy_after_observation(Fullscreen, false), + Transitioning + ); + // Still animating: further !fullscreen samples must not jump to + // Windowed (that clear is time-based, after the Space is gone). + assert_eq!( + occupancy_after_observation(Transitioning, false), + Transitioning + ); + // Re-entered before the settle timer fired. + assert_eq!(occupancy_after_observation(Transitioning, true), Fullscreen); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd37c3b498..46dd5ee85f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,6 +112,11 @@ mod tauri_app { /// swallow the press: if no dialog can answer it, each falls back to /// acting on its own. /// + /// On macOS, a window that still occupies a native-fullscreen Space + /// (or is animating out of one) is drained first: hiding or exiting + /// while that Space is up leaves a black blank plus leftover toolbar + /// chrome (issue #507). + /// /// Two things stand behind that, because nothing here can observe whether a /// dialog actually appeared. `main` is built visible and the dialog only /// starts listening once React has mounted in it, so @@ -121,10 +126,35 @@ mod tauri_app { /// prompt that WAS sent goes unanswered, which is the only defence against /// everything readiness cannot see. fn handle_main_close_request(window: &tauri::Window, label: &str) { + handle_main_close_request_inner(window, label, true); + } + + /// `drain_fullscreen` is true for a fresh close press and false after + /// the macOS Space drain has already run — otherwise a timeout that + /// left `is_fullscreen` stuck would loop forever instead of applying + /// the user's close preference. + fn handle_main_close_request_inner( + window: &tauri::Window, + label: &str, + drain_fullscreen: bool, + ) { use crate::commands::system_settings; use crate::models::CloseWindowBehavior; use tauri::Emitter; + #[cfg(target_os = "macos")] + if drain_fullscreen && windows::macos_fullscreen_should_drain(window) { + let window = window.clone(); + let label = label.to_string(); + windows::drain_macos_fullscreen_then(window, move |window| { + handle_main_close_request_inner(&window, &label, false); + }); + return; + } + + #[cfg(not(target_os = "macos"))] + let _ = drain_fullscreen; + let app = window.app_handle().clone(); let behavior = if windows::can_hide_to_tray() { system_settings::cached_close_behavior() @@ -1365,6 +1395,7 @@ mod tauri_app { } if label == "main" { + windows::observe_macos_fullscreen_state(window); if let tauri::WindowEvent::CloseRequested { api, .. } = event { // What the close button does is the user's choice // (`ask` / `minimize` / `exit`), with one platform From 941c1a9c2c2e6649b769091b48fa37bb5f44e1d7 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 19 Sep 2026 07:00:35 +0800 Subject: [PATCH 2/3] fix(macos): key the fullscreen drain on the flag tao actually sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drain landed on a premise tao does not hold: `windowWillExitFullScreen` does not clear `shared_state.fullscreen`. Only `restore_state_from_fullscreen` (from `windowDidExitFullScreen`, the END of the animation) and `set_fullscreen` itself do — and nothing here calls `set_fullscreen` outside the drain. So the `Transitioning` state could only ever be entered once AppKit had already torn the Space down: it bought a spurious 700ms detour on any close within 700ms of a fullscreen exit, an `is_fullscreen()` round-trip on every main-window event on every platform, a thread per exit, and a CAS race across a fast re-enter. Dropped; the flag alone is the right predicate, and because it stays up for the whole user-driven animation it already covers a close pressed mid-animation. Drain only where the window is actually dismissed. `Ask` is the default, and draining ahead of the prompt cost the user their fullscreen even when they answered "cancel" — the dialog is a webview overlay and reads fine inside the Space. Same for the `confirm_terminals` prompt. Cover `resolve_close_request` too. It hides / exits on its own, the press that raised the dialog may have arrived windowed, and the user can go fullscreen while the dialog is up, so the answer cannot assume the close path drained. Never swallow the press: a failed thread spawn dropped the callback entirely, leaving a `prevent_close`d window nothing could dismiss. And make the in-flight guard a timestamped claim — the drain polls `is_fullscreen()` off the main thread, which blocks on the event loop with no timeout, so a wedge must not kill the close button for the rest of the session. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/commands/system_settings.rs | 14 +- src-tauri/src/commands/windows.rs | 440 +++++++++------------- src-tauri/src/lib.rs | 55 +-- 3 files changed, 218 insertions(+), 291 deletions(-) diff --git a/src-tauri/src/commands/system_settings.rs b/src-tauri/src/commands/system_settings.rs index aff18bc004..ed9bb5875d 100644 --- a/src-tauri/src/commands/system_settings.rs +++ b/src-tauri/src/commands/system_settings.rs @@ -580,16 +580,26 @@ pub async fn resolve_close_request( save_system_close_behavior_settings(&db.conn, behavior).await?; } + // `orderOut:` and process exit both leave a macOS native-fullscreen + // Space standing (issue #507), and this is a second entry point into + // both: the press that raised the dialog may have arrived windowed and + // the user can go fullscreen while it is up, so the answer cannot + // assume the close path already drained. match behavior { CloseWindowBehavior::Minimize => { if let Some(window) = tauri::Manager::get_webview_window(&app, "main") { - let _ = window.hide(); + crate::commands::windows::with_macos_fullscreen_drained(&app, move || { + let _ = window.hide(); + }); } } // Reuses the tray-quit path: `exit` triggers `ExitRequested`, which // sets `APP_QUITTING` and runs the ACP-disconnect / terminal-reclaim // cleanup already wired there. - CloseWindowBehavior::Exit => tauri::Manager::app_handle(&app).exit(0), + CloseWindowBehavior::Exit => { + let quit = tauri::Manager::app_handle(&app).clone(); + crate::commands::windows::with_macos_fullscreen_drained(&app, move || quit.exit(0)); + } CloseWindowBehavior::Ask => {} } diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 5f01ff8ffb..e74bdd588b 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -2034,84 +2034,51 @@ pub fn can_hide_to_tray() -> bool { // ─── macOS native-fullscreen drain on close (issue #507) ─────────────── // -// Native fullscreen on macOS is a separate Space. Intercepting -// `CloseRequested` and then hiding / prompting / exiting while that Space -// is still up — or still animating out — leaves the Space behind as a -// black blank with leftover toolbar chrome. +// Native fullscreen on macOS is a separate Space. `orderOut:` (what +// `Window::hide` does) and process exit both leave that Space standing, as +// a black blank with leftover toolbar chrome — so every close action that +// hides or exits leaves fullscreen first and waits for AppKit to tear the +// Space down. // -// tao reports `is_fullscreen() == false` from `windowWillExitFullScreen`, -// which is the *start* of AppKit's animation, not the end. Occupancy -// therefore has three states, and close must wait until Windowed. - -/// Occupancy of the macOS fullscreen Space for the main window. -/// -/// Not the same as `Window::is_fullscreen`: that flag drops at the start -/// of the exit animation, while the Space is still on screen. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MacosFullscreenOccupancy { - Windowed, - Fullscreen, - Transitioning, -} - -impl MacosFullscreenOccupancy { - const WINDOWED: u8 = 0; - const FULLSCREEN: u8 = 1; - const TRANSITIONING: u8 = 2; - - fn from_code(code: u8) -> Self { - match code { - Self::FULLSCREEN => Self::Fullscreen, - Self::TRANSITIONING => Self::Transitioning, - _ => Self::Windowed, - } - } - - fn code(self) -> u8 { - match self { - Self::Windowed => Self::WINDOWED, - Self::Fullscreen => Self::FULLSCREEN, - Self::Transitioning => Self::TRANSITIONING, - } - } -} - -/// Advance occupancy from one `is_fullscreen` sample. -/// -/// A falling edge (Fullscreen → not fullscreen) is Transitioning, not -/// Windowed: tao has dropped its flag but AppKit has not finished tearing -/// the Space down. -pub(crate) fn occupancy_after_observation( - current: MacosFullscreenOccupancy, - is_fullscreen: bool, -) -> MacosFullscreenOccupancy { - if is_fullscreen { - MacosFullscreenOccupancy::Fullscreen - } else if current == MacosFullscreenOccupancy::Fullscreen { - MacosFullscreenOccupancy::Transitioning - } else { - current - } -} +// Two tao facts (0.34, macOS) set the shape of that wait. `is_fullscreen()` +// reads `shared_state.fullscreen`, and exactly two things clear it: +// +// * `restore_state_from_fullscreen`, called from `windowDidExitFullScreen` +// — the END of the animation. It is the only thing that clears the flag +// for an exit the *user* started (green button, ⌃⌘F, the View menu), so +// while their animation runs the flag is still up. Polling it is what +// covers a close pressed mid-animation, including the mid-transition +// case where tao parks our `set_fullscreen` in `target_fullscreen` and +// replays it at `windowDid{Enter,Exit}FullScreen`. +// * `set_fullscreen` itself, synchronously, BEFORE it dispatches +// `toggleFullScreen:` to the main queue. So on the path this code +// drives the flag is already down before the animation starts, and says +// nothing about the Space. +// +// Nothing in tao's public surface reports `windowDidExitFullScreen` for the +// second case, so what follows the flag drop is a timer. Making it exact +// would take an `NSWindowDidExitFullScreenNotification` observer via objc2. -/// Whether close-button handling must drain native fullscreen first. +/// Whether a close action must drain native fullscreen first. /// /// Other platforms treat fullscreen as a maximized window and hide/close /// tear it down correctly, so this is a no-op there. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub(crate) fn should_drain_macos_fullscreen_before_close( is_macos: bool, is_fullscreen: bool, - occupancy: MacosFullscreenOccupancy, ) -> bool { - is_macos && (is_fullscreen || occupancy != MacosFullscreenOccupancy::Windowed) + is_macos && is_fullscreen } -/// How long AppKit's Space teardown keeps running after tao reports -/// `is_fullscreen() == false`. +/// How long to give AppKit's Space teardown once tao's fullscreen flag is +/// down. /// -/// ~0.5s is the system animation; 700ms is that plus slack so hide/exit -/// does not race the last frames (issue #507; tauri-apps/tauri#10580, -/// #12056). +/// On the path this code drives the flag falls before `toggleFullScreen:` +/// has even been dispatched, so this is measured from the start of the +/// animation, not its end: ~0.5s is the system transition, 700ms is that +/// plus slack so hide/exit does not race the last frames (issue #507; +/// tauri-apps/tauri#10580, #12056). #[cfg(target_os = "macos")] const MACOS_FULLSCREEN_EXIT_SETTLE: std::time::Duration = std::time::Duration::from_millis(700); @@ -2121,136 +2088,149 @@ const MACOS_FULLSCREEN_EXIT_POLL: std::time::Duration = std::time::Duration::fro #[cfg(target_os = "macos")] const MACOS_FULLSCREEN_EXIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +/// How long an in-flight drain keeps suppressing later close presses. +/// +/// Comfortably past the longest honest drain (poll timeout + settle), and +/// short enough that a wedged one costs a few seconds rather than the rest +/// of the session — same bargain as `CLOSE_PROMPT_GRACE`. #[cfg(target_os = "macos")] -static MACOS_FULLSCREEN_OCCUPANCY: AtomicU8 = AtomicU8::new(MacosFullscreenOccupancy::WINDOWED); +const MACOS_FULLSCREEN_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); +/// When the in-flight drain started, or `None` when none is running. +/// +/// A second press while a drain is in flight is dropped: the in-flight +/// callback is the one press that will be answered. It is a timestamp and +/// not a flag because the drain CAN wedge — `Window::is_fullscreen` off the +/// main thread blocks on the event loop with no timeout — and a wedged +/// drain must not leave the close button dead for good. #[cfg(target_os = "macos")] -static MACOS_FULLSCREEN_DRAIN_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +static MACOS_FULLSCREEN_DRAIN_STARTED_AT: Mutex> = Mutex::new(None); -/// Keep occupancy in sync with the live window. Called from the main -/// window's `on_window_event` so a green-button exit (no `CloseRequested`) -/// still marks the animation as Transitioning. -pub(crate) fn observe_macos_fullscreen_state(window: &tauri::Window) { - #[cfg(target_os = "macos")] - { - let is_fs = window.is_fullscreen().unwrap_or(false); - let current = MacosFullscreenOccupancy::from_code( - MACOS_FULLSCREEN_OCCUPANCY.load(AtomicOrdering::Relaxed), - ); - let next = occupancy_after_observation(current, is_fs); - if next == current { - return; - } - MACOS_FULLSCREEN_OCCUPANCY.store(next.code(), AtomicOrdering::Release); - if next != MacosFullscreenOccupancy::Transitioning { - return; - } - // Drop Transitioning back to Windowed once the animation has had - // time to finish, unless a close-drain owns the wait. - if std::thread::Builder::new() - .name("macos-fs-occupancy-settle".into()) - .spawn(|| { - std::thread::sleep(MACOS_FULLSCREEN_EXIT_SETTLE); - if MACOS_FULLSCREEN_DRAIN_IN_FLIGHT.load(AtomicOrdering::Acquire) { - return; - } - let _ = MACOS_FULLSCREEN_OCCUPANCY.compare_exchange( - MacosFullscreenOccupancy::TRANSITIONING, - MacosFullscreenOccupancy::WINDOWED, - AtomicOrdering::AcqRel, - AtomicOrdering::Relaxed, - ); - }) - .is_err() - { - MACOS_FULLSCREEN_OCCUPANCY - .store(MacosFullscreenOccupancy::WINDOWED, AtomicOrdering::Release); +#[cfg(target_os = "macos")] +fn claim_macos_fullscreen_drain() -> bool { + let now = std::time::Instant::now(); + let mut started_at = MACOS_FULLSCREEN_DRAIN_STARTED_AT + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(started) = *started_at { + if now.duration_since(started) < MACOS_FULLSCREEN_DRAIN_GRACE { + return false; } + tracing::warn!("[close] previous fullscreen drain never finished; taking the press over"); } - #[cfg(not(target_os = "macos"))] - { - let _ = window; - // The occupancy helpers are macOS-only at runtime. Reference them - // here so `cargo build` / clippy without `--all-targets` on Linux - // does not report them as dead — the unit tests still exercise - // the real cases. - let _ = should_drain_macos_fullscreen_before_close( - false, - false, - occupancy_after_observation(MacosFullscreenOccupancy::Windowed, false), - ); - let _ = MacosFullscreenOccupancy::from_code(MacosFullscreenOccupancy::Windowed.code()); - } + *started_at = Some(now); + true } #[cfg(target_os = "macos")] -pub(crate) fn macos_fullscreen_should_drain(window: &tauri::Window) -> bool { - should_drain_macos_fullscreen_before_close( - true, - window.is_fullscreen().unwrap_or(false), - MacosFullscreenOccupancy::from_code( - MACOS_FULLSCREEN_OCCUPANCY.load(AtomicOrdering::Relaxed), - ), - ) +fn release_macos_fullscreen_drain() { + *MACOS_FULLSCREEN_DRAIN_STARTED_AT + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; } -/// Exit native fullscreen and wait until the Space is gone, then run `then` -/// on the main thread. `then` receives the same window so the configured -/// close behavior (hide / exit / ask) can run against a windowed window. -/// -/// A second call while a drain is in flight is a no-op: the in-flight -/// callback is the one close press that will be answered. #[cfg(target_os = "macos")] -pub(crate) fn drain_macos_fullscreen_then( - window: tauri::Window, - then: impl FnOnce(tauri::Window) + Send + 'static, +fn macos_fullscreen_drain_in_flight() -> bool { + let now = std::time::Instant::now(); + let started_at = *MACOS_FULLSCREEN_DRAIN_STARTED_AT + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + started_at.is_some_and(|started| now.duration_since(started) < MACOS_FULLSCREEN_DRAIN_GRACE) +} + +/// Run `action` once the main window no longer owns a macOS +/// native-fullscreen Space. +/// +/// Off macOS, and on a window that is not fullscreen, `action` runs inline +/// on the calling thread; otherwise it runs on the main thread once the +/// Space is gone. Every close path that hides or exits goes through here. +/// +/// Keyed off the `AppHandle` rather than a `Window` because the answers to +/// the close dialog arrive on a command that has no window handle, and +/// `Manager::get_window` is behind tauri's `unstable` feature — the webview +/// window is the flavour every caller can reach. +pub(crate) fn with_macos_fullscreen_drained( + app: &tauri::AppHandle, + action: impl FnOnce() + Send + 'static, ) { - if MACOS_FULLSCREEN_DRAIN_IN_FLIGHT - .compare_exchange( - false, - true, - AtomicOrdering::AcqRel, - AtomicOrdering::Acquire, - ) - .is_err() + #[cfg(target_os = "macos")] { - return; + // The in-flight check is not redundant with the flag: `set_fullscreen` + // clears tao's flag before the animation even starts, so a second + // close press arriving mid-drain reads as windowed while the Space is + // still going. Hand it to the drain, which drops it as a duplicate of + // the press already being answered. + if let Some(window) = app.get_webview_window("main") { + if should_drain_macos_fullscreen_before_close( + true, + window.is_fullscreen().unwrap_or(false), + ) || macos_fullscreen_drain_in_flight() + { + drain_macos_fullscreen_then(window, action); + return; + } + } } + #[cfg(not(target_os = "macos"))] + let _ = app; + action(); +} - if window.is_fullscreen().unwrap_or(false) { - let _ = window.set_fullscreen(false); +/// Exit native fullscreen and wait until the Space is gone, then run `then` +/// on the main thread. +#[cfg(target_os = "macos")] +fn drain_macos_fullscreen_then( + window: tauri::WebviewWindow, + then: impl FnOnce() + Send + 'static, +) { + if !claim_macos_fullscreen_drain() { + return; } - MACOS_FULLSCREEN_OCCUPANCY.store( - MacosFullscreenOccupancy::TRANSITIONING, - AtomicOrdering::Release, - ); + let _ = window.set_fullscreen(false); tracing::info!("[close] draining macOS native fullscreen before close behavior"); + // Shared so the spawn-failure path below can still reach the action: a + // close the user pressed and that this path then swallowed leaves a + // window nothing can dismiss, which is worse than hiding into a Space + // that has not finished going. + type DrainAction = Mutex>>; + let action: std::sync::Arc = + std::sync::Arc::new(Mutex::new(Some(Box::new(then)))); + let take = |slot: &DrainAction| { + slot.lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + }; + let app = window.app_handle().clone(); - if let Err(err) = std::thread::Builder::new() + let spawned = std::thread::Builder::new() .name("macos-fs-close-drain".into()) - .spawn(move || { - wait_for_macos_fullscreen_space_release(&window); - MACOS_FULLSCREEN_OCCUPANCY - .store(MacosFullscreenOccupancy::WINDOWED, AtomicOrdering::Release); - MACOS_FULLSCREEN_DRAIN_IN_FLIGHT.store(false, AtomicOrdering::Release); - if let Err(err) = app.run_on_main_thread(move || then(window)) { - tracing::warn!( - "[close] failed to hop back to main thread after fullscreen drain: {err}" - ); + .spawn({ + let action = action.clone(); + move || { + wait_for_macos_fullscreen_space_release(&window); + release_macos_fullscreen_drain(); + let Some(action) = take(&action) else { return }; + if let Err(err) = app.run_on_main_thread(action) { + tracing::warn!( + "[close] failed to hop back to main thread after fullscreen drain: {err}" + ); + } } - }) - { + }); + + if let Err(err) = spawned { tracing::warn!("[close] failed to spawn fullscreen drain: {err}"); - MACOS_FULLSCREEN_DRAIN_IN_FLIGHT.store(false, AtomicOrdering::Release); - MACOS_FULLSCREEN_OCCUPANCY - .store(MacosFullscreenOccupancy::WINDOWED, AtomicOrdering::Release); + release_macos_fullscreen_drain(); + if let Some(action) = take(&action) { + action(); + } } } #[cfg(target_os = "macos")] -fn wait_for_macos_fullscreen_space_release(window: &tauri::Window) { +fn wait_for_macos_fullscreen_space_release(window: &tauri::WebviewWindow) { let deadline = std::time::Instant::now() + MACOS_FULLSCREEN_EXIT_TIMEOUT; while window.is_fullscreen().unwrap_or(false) { if std::time::Instant::now() >= deadline { @@ -2261,7 +2241,8 @@ fn wait_for_macos_fullscreen_space_release(window: &tauri::Window) { } std::thread::sleep(MACOS_FULLSCREEN_EXIT_POLL); } - // Flag drop is windowWillExitFullScreen. The Space is still up. + // The flag is not the Space — see the module note above. Give AppKit + // the animation before anything calls `orderOut:` or exits. std::thread::sleep(MACOS_FULLSCREEN_EXIT_SETTLE); } @@ -2658,103 +2639,56 @@ mod settings_route_tests { #[cfg(test)] mod macos_fullscreen_close_tests { - use super::{ - occupancy_after_observation, should_drain_macos_fullscreen_before_close, - MacosFullscreenOccupancy, - }; + use super::should_drain_macos_fullscreen_before_close; /// Linux (and Windows) fullscreen is not a separate Space. Close must - /// not wait on it, or a maximized window would stall the hide/exit path. + /// not wait on it, or a maximized window would take the settle delay on + /// every hide/exit for nothing. #[test] fn non_macos_never_drains_fullscreen() { - for occupancy in [ - MacosFullscreenOccupancy::Windowed, - MacosFullscreenOccupancy::Fullscreen, - MacosFullscreenOccupancy::Transitioning, - ] { - assert!( - !should_drain_macos_fullscreen_before_close(false, true, occupancy), - "{occupancy:?} must not drain off macOS" - ); - } + assert!(!should_drain_macos_fullscreen_before_close(false, true)); + assert!(!should_drain_macos_fullscreen_before_close(false, false)); } + /// The flag is up for the whole of a user-driven exit animation (tao + /// only clears it in `windowDidExitFullScreen`), so it is also what + /// covers a close pressed mid-animation. Windowed must not drain: that + /// would put the settle delay on every ordinary close. #[test] - fn macos_windowed_does_not_drain() { - assert!(!should_drain_macos_fullscreen_before_close( - true, - false, - MacosFullscreenOccupancy::Windowed, - )); + fn macos_drains_exactly_while_the_fullscreen_flag_is_up() { + assert!(should_drain_macos_fullscreen_before_close(true, true)); + assert!(!should_drain_macos_fullscreen_before_close(true, false)); } - /// The red close button while native-fullscreen is the reported bug: - /// hide/exit without leaving the Space first. - #[test] - fn macos_live_fullscreen_drains() { - assert!(should_drain_macos_fullscreen_before_close( - true, - true, - MacosFullscreenOccupancy::Windowed, - )); - assert!(should_drain_macos_fullscreen_before_close( - true, - true, - MacosFullscreenOccupancy::Fullscreen, - )); - } - - /// tao drops `is_fullscreen` at windowWillExitFullScreen, which is - /// the start of the animation. Occupancy still blocks close so we - /// do not hide into the leftover Space. + /// The drain is claimed once and answered once: a second press while one + /// is in flight is dropped rather than queueing a second hide. And while + /// it is in flight it stays observable, because tao's fullscreen flag is + /// already down by then and would otherwise read as "nothing to wait for". + #[cfg(target_os = "macos")] #[test] - fn macos_transitioning_drains_even_when_flag_already_dropped() { - assert!(should_drain_macos_fullscreen_before_close( - true, - false, - MacosFullscreenOccupancy::Fullscreen, - )); - assert!(should_drain_macos_fullscreen_before_close( - true, - false, - MacosFullscreenOccupancy::Transitioning, - )); - } + fn a_drain_claim_is_exclusive_and_observable_until_released() { + use super::{ + claim_macos_fullscreen_drain, macos_fullscreen_drain_in_flight, + release_macos_fullscreen_drain, + }; - #[test] - fn occupancy_codes_roundtrip() { - use MacosFullscreenOccupancy::*; - for occupancy in [Windowed, Fullscreen, Transitioning] { - assert_eq!( - MacosFullscreenOccupancy::from_code(occupancy.code()), - occupancy - ); - } - assert_eq!( - MacosFullscreenOccupancy::from_code(255), - Windowed, - "unknown codes must degrade to windowed" + release_macos_fullscreen_drain(); + assert!(!macos_fullscreen_drain_in_flight()); + assert!(claim_macos_fullscreen_drain()); + assert!( + macos_fullscreen_drain_in_flight(), + "a second press must be able to see the drain it should defer to" ); - } - - #[test] - fn occupancy_tracks_enter_and_falling_edge() { - use MacosFullscreenOccupancy::*; - - assert_eq!(occupancy_after_observation(Windowed, false), Windowed); - assert_eq!(occupancy_after_observation(Windowed, true), Fullscreen); - assert_eq!(occupancy_after_observation(Fullscreen, true), Fullscreen); - assert_eq!( - occupancy_after_observation(Fullscreen, false), - Transitioning + assert!( + !claim_macos_fullscreen_drain(), + "a press arriving mid-drain must not start a second one" ); - // Still animating: further !fullscreen samples must not jump to - // Windowed (that clear is time-based, after the Space is gone). - assert_eq!( - occupancy_after_observation(Transitioning, false), - Transitioning + release_macos_fullscreen_drain(); + assert!(!macos_fullscreen_drain_in_flight()); + assert!( + claim_macos_fullscreen_drain(), + "the next press must be answerable once the drain is done" ); - // Re-entered before the settle timer fired. - assert_eq!(occupancy_after_observation(Transitioning, true), Fullscreen); + release_macos_fullscreen_drain(); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 46dd5ee85f..498d92e858 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,11 +112,6 @@ mod tauri_app { /// swallow the press: if no dialog can answer it, each falls back to /// acting on its own. /// - /// On macOS, a window that still occupies a native-fullscreen Space - /// (or is animating out of one) is drained first: hiding or exiting - /// while that Space is up leaves a black blank plus leftover toolbar - /// chrome (issue #507). - /// /// Two things stand behind that, because nothing here can observe whether a /// dialog actually appeared. `main` is built visible and the dialog only /// starts listening once React has mounted in it, so @@ -125,36 +120,19 @@ mod tauri_app { /// [`system_settings::ClosePromptClaim::Expired`] hands the press back if a /// prompt that WAS sent goes unanswered, which is the only defence against /// everything readiness cannot see. + /// + /// The two branches that actually dismiss the window go through + /// [`windows::with_macos_fullscreen_drained`], because hiding or exiting + /// while the window still owns a macOS native-fullscreen Space leaves a + /// black blank plus leftover toolbar chrome (issue #507). Only those + /// branches: draining ahead of the prompt would cost the user their + /// fullscreen even when they answer "cancel". The dialog is a webview + /// overlay, so it is perfectly readable inside the Space. fn handle_main_close_request(window: &tauri::Window, label: &str) { - handle_main_close_request_inner(window, label, true); - } - - /// `drain_fullscreen` is true for a fresh close press and false after - /// the macOS Space drain has already run — otherwise a timeout that - /// left `is_fullscreen` stuck would loop forever instead of applying - /// the user's close preference. - fn handle_main_close_request_inner( - window: &tauri::Window, - label: &str, - drain_fullscreen: bool, - ) { use crate::commands::system_settings; use crate::models::CloseWindowBehavior; use tauri::Emitter; - #[cfg(target_os = "macos")] - if drain_fullscreen && windows::macos_fullscreen_should_drain(window) { - let window = window.clone(); - let label = label.to_string(); - windows::drain_macos_fullscreen_then(window, move |window| { - handle_main_close_request_inner(&window, &label, false); - }); - return; - } - - #[cfg(not(target_os = "macos"))] - let _ = drain_fullscreen; - let app = window.app_handle().clone(); let behavior = if windows::can_hide_to_tray() { system_settings::cached_close_behavior() @@ -215,16 +193,22 @@ mod tauri_app { } }; - match behavior { - CloseWindowBehavior::Minimize => { + let hide = || { + let window = window.clone(); + windows::with_macos_fullscreen_drained(&app, move || { let _ = window.hide(); - } + }); + }; + + match behavior { + CloseWindowBehavior::Minimize => hide(), CloseWindowBehavior::Exit => { let count = running_terminals(&app); // Nothing to lose, or the confirmation could not be shown — // either way the pinned choice stands. if count == 0 || !prompt("confirm_terminals", count) { - app.exit(0); + let quit = app.clone(); + windows::with_macos_fullscreen_drained(&app, move || quit.exit(0)); } } CloseWindowBehavior::Ask => { @@ -233,7 +217,7 @@ mod tauri_app { // Fall back to the behavior codeg has always had. Exiting // on a press the user never got to answer would discard // work; hiding discards nothing. - let _ = window.hide(); + hide(); } } } @@ -1395,7 +1379,6 @@ mod tauri_app { } if label == "main" { - windows::observe_macos_fullscreen_state(window); if let tauri::WindowEvent::CloseRequested { api, .. } = event { // What the close button does is the user's choice // (`ask` / `minimize` / `exit`), with one platform From 49efe7cf9609d3e8629ea0e4063901f181364f6a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 19 Sep 2026 08:09:36 +0800 Subject: [PATCH 3/3] fix(macos): bound the drain's fullscreen reads and hold the claim across the action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a review pass turned up in the drain. `is_fullscreen()` off the main thread posts to the event loop and then blocks on a channel with no timeout, so the poll deadline bounded the loop but not the call inside it — and the same read sat on the entry check, which the close dialog's answer reaches on a tokio worker. Both now go through a probe that hops the read to the main thread (where the runtime answers it inline) and waits with its own deadline. An unanswered sample drains: wrong that way costs a delay, wrong the other way is the bug this module exists for. The claim was handed back before the action ran, leaving a gap where a second press saw a free drain and could act ahead of the press already being answered. It now spans the action. And `claim` logged its takeover warning with the state lock held. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/commands/windows.rs | 201 ++++++++++++++++++++++-------- 1 file changed, 148 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index e74bdd588b..818db13c9a 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; #[cfg(target_os = "macos")] use std::sync::atomic::AtomicU32; +#[cfg(target_os = "macos")] +use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering as AtomicOrdering}; use std::sync::Mutex; @@ -2088,54 +2090,109 @@ const MACOS_FULLSCREEN_EXIT_POLL: std::time::Duration = std::time::Duration::fro #[cfg(target_os = "macos")] const MACOS_FULLSCREEN_EXIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +/// How long one `is_fullscreen()` sample may take before the drain gives up +/// on it and lets its own deadline decide. Generous next to a main thread +/// that is merely busy, short next to one that is never coming back. +#[cfg(target_os = "macos")] +const MACOS_FULLSCREEN_EXIT_PROBE: std::time::Duration = std::time::Duration::from_millis(250); + /// How long an in-flight drain keeps suppressing later close presses. /// /// Comfortably past the longest honest drain (poll timeout + settle), and -/// short enough that a wedged one costs a few seconds rather than the rest +/// short enough that a stalled one costs a few seconds rather than the rest /// of the session — same bargain as `CLOSE_PROMPT_GRACE`. #[cfg(target_os = "macos")] const MACOS_FULLSCREEN_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(5); -/// When the in-flight drain started, or `None` when none is running. +/// The in-flight drain: which press owns it, and when it started. /// -/// A second press while a drain is in flight is dropped: the in-flight -/// callback is the one press that will be answered. It is a timestamp and -/// not a flag because the drain CAN wedge — `Window::is_fullscreen` off the -/// main thread blocks on the event loop with no timeout — and a wedged -/// drain must not leave the close button dead for good. +/// A second press while one is in flight is dropped — the in-flight callback +/// is the press that will be answered. Two details earn their keep: +/// +/// * It is a timestamp, not a flag. The drain is bounded (see +/// `wait_for_macos_fullscreen_space_release`), but a starved or panicked +/// drain thread must still not leave the close button dead for good. +/// * It carries a generation, because a stale claim is exactly when a +/// later press takes over — and then the stale thread finishes and +/// releases. Without the generation it would clear its successor's +/// claim, and the press after THAT would act on a window whose Space is +/// still going. +#[cfg(target_os = "macos")] +static MACOS_FULLSCREEN_DRAIN: Mutex> = Mutex::new(None); + #[cfg(target_os = "macos")] -static MACOS_FULLSCREEN_DRAIN_STARTED_AT: Mutex> = Mutex::new(None); +static MACOS_FULLSCREEN_DRAIN_SEQ: AtomicU64 = AtomicU64::new(0); +/// `Some(generation)` when this press owns the drain, `None` when another one +/// already does. #[cfg(target_os = "macos")] -fn claim_macos_fullscreen_drain() -> bool { +fn claim_macos_fullscreen_drain() -> Option { let now = std::time::Instant::now(); - let mut started_at = MACOS_FULLSCREEN_DRAIN_STARTED_AT + let took_over; + let generation; + { + let mut drain = MACOS_FULLSCREEN_DRAIN + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + took_over = match *drain { + Some((_, started)) if now.duration_since(started) < MACOS_FULLSCREEN_DRAIN_GRACE => { + return None + } + Some(_) => true, + None => false, + }; + generation = MACOS_FULLSCREEN_DRAIN_SEQ.fetch_add(1, AtomicOrdering::Relaxed); + *drain = Some((generation, now)); + } + if took_over { + tracing::warn!("[close] previous fullscreen drain never finished; taking the press over"); + } + Some(generation) +} + +/// No-op unless `generation` still owns the drain, so a thread whose claim +/// expired cannot clear the claim that replaced it. +#[cfg(target_os = "macos")] +fn release_macos_fullscreen_drain(generation: u64) { + let mut drain = MACOS_FULLSCREEN_DRAIN .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(started) = *started_at { - if now.duration_since(started) < MACOS_FULLSCREEN_DRAIN_GRACE { - return false; - } - tracing::warn!("[close] previous fullscreen drain never finished; taking the press over"); + if matches!(*drain, Some((owner, _)) if owner == generation) { + *drain = None; } - *started_at = Some(now); - true } #[cfg(target_os = "macos")] -fn release_macos_fullscreen_drain() { - *MACOS_FULLSCREEN_DRAIN_STARTED_AT +type MacosDrainAction = Mutex>>; + +/// Take the close action out of the slot, run it, and only then hand the +/// claim back. +/// +/// The claim spans the action, not just the wait: handing it back first +/// leaves a gap in which a second press sees a free drain and acts ahead of +/// the press already being answered. `app.exit(0)` never returns, so the +/// release is best-effort — which is what the grace on the claim is for. +/// Every exit from a drain goes through here, and the `Option` is what makes +/// "at most once" hold when two of those exits are reached. +#[cfg(target_os = "macos")] +fn finish_macos_fullscreen_drain(generation: u64, action: &std::sync::Arc) { + let action = action .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(action) = action { + action(); + } + release_macos_fullscreen_drain(generation); } #[cfg(target_os = "macos")] fn macos_fullscreen_drain_in_flight() -> bool { let now = std::time::Instant::now(); - let started_at = *MACOS_FULLSCREEN_DRAIN_STARTED_AT + let drain = *MACOS_FULLSCREEN_DRAIN .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - started_at.is_some_and(|started| now.duration_since(started) < MACOS_FULLSCREEN_DRAIN_GRACE) + drain.is_some_and(|(_, started)| now.duration_since(started) < MACOS_FULLSCREEN_DRAIN_GRACE) } /// Run `action` once the main window no longer owns a macOS @@ -2160,10 +2217,16 @@ pub(crate) fn with_macos_fullscreen_drained( // close press arriving mid-drain reads as windowed while the Space is // still going. Hand it to the drain, which drops it as a duplicate of // the press already being answered. + // + // The sample is the bounded one because the dialog's answer reaches + // here on a tokio worker, where the plain getter would park a runtime + // thread on the event loop indefinitely. An unanswered sample drains: + // being wrong that way costs a delay, being wrong the other way is + // the bug this whole module exists for. if let Some(window) = app.get_webview_window("main") { if should_drain_macos_fullscreen_before_close( true, - window.is_fullscreen().unwrap_or(false), + sample_macos_fullscreen(&window) != Some(false), ) || macos_fullscreen_drain_in_flight() { drain_macos_fullscreen_then(window, action); @@ -2183,25 +2246,19 @@ fn drain_macos_fullscreen_then( window: tauri::WebviewWindow, then: impl FnOnce() + Send + 'static, ) { - if !claim_macos_fullscreen_drain() { + let Some(generation) = claim_macos_fullscreen_drain() else { return; - } + }; let _ = window.set_fullscreen(false); tracing::info!("[close] draining macOS native fullscreen before close behavior"); - // Shared so the spawn-failure path below can still reach the action: a - // close the user pressed and that this path then swallowed leaves a - // window nothing can dismiss, which is worse than hiding into a Space - // that has not finished going. - type DrainAction = Mutex>>; - let action: std::sync::Arc = - std::sync::Arc::new(Mutex::new(Some(Box::new(then)))); - let take = |slot: &DrainAction| { - slot.lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - }; + // Shared, and taken exactly once, so every way this can go wrong still + // answers the press: a close the user pressed and that this path then + // swallowed leaves a window nothing can dismiss, which is worse than + // acting on a Space that has not quite finished going. + let action: std::sync::Arc = + std::sync::Arc::new(Mutex::new(Some(Box::new(then) as Box))); let app = window.app_handle().clone(); let spawned = std::thread::Builder::new() @@ -2210,29 +2267,56 @@ fn drain_macos_fullscreen_then( let action = action.clone(); move || { wait_for_macos_fullscreen_space_release(&window); - release_macos_fullscreen_drain(); - let Some(action) = take(&action) else { return }; - if let Err(err) = app.run_on_main_thread(action) { + let hop = { + let action = action.clone(); + move || finish_macos_fullscreen_drain(generation, &action) + }; + // `run_on_main_thread` consumes the closure either way, which + // is why the action lives behind the shared `Option` rather + // than being moved in: a rejected hop must not eat the press. + if let Err(err) = app.run_on_main_thread(hop) { tracing::warn!( "[close] failed to hop back to main thread after fullscreen drain: {err}" ); + finish_macos_fullscreen_drain(generation, &action); } } }); if let Err(err) = spawned { tracing::warn!("[close] failed to spawn fullscreen drain: {err}"); - release_macos_fullscreen_drain(); - if let Some(action) = take(&action) { - action(); - } + finish_macos_fullscreen_drain(generation, &action); } } +/// One `is_fullscreen()` sample that cannot outlive `MACOS_FULLSCREEN_EXIT_PROBE`. +/// +/// `WebviewWindow::is_fullscreen` off the main thread posts to the event loop +/// and then blocks on a channel with NO timeout, so a stalled or closing loop +/// would hang the drain thread outright — a deadline around the loop cannot +/// bound a call that never returns. Hopping the read onto the main thread +/// (where the runtime answers it inline) and waiting on our own channel with +/// a deadline is what makes the whole drain finite. +/// +/// `None` is "no answer this round", never "not fullscreen": the caller's own +/// deadline decides when to stop asking. +#[cfg(target_os = "macos")] +fn sample_macos_fullscreen(window: &tauri::WebviewWindow) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + let probe = { + let window = window.clone(); + move || { + let _ = tx.send(window.is_fullscreen().unwrap_or(false)); + } + }; + window.app_handle().run_on_main_thread(probe).ok()?; + rx.recv_timeout(MACOS_FULLSCREEN_EXIT_PROBE).ok() +} + #[cfg(target_os = "macos")] fn wait_for_macos_fullscreen_space_release(window: &tauri::WebviewWindow) { let deadline = std::time::Instant::now() + MACOS_FULLSCREEN_EXIT_TIMEOUT; - while window.is_fullscreen().unwrap_or(false) { + while sample_macos_fullscreen(window) != Some(false) { if std::time::Instant::now() >= deadline { tracing::warn!( "[close] timed out waiting for macOS fullscreen to drop; applying close behavior anyway" @@ -2669,26 +2753,37 @@ mod macos_fullscreen_close_tests { fn a_drain_claim_is_exclusive_and_observable_until_released() { use super::{ claim_macos_fullscreen_drain, macos_fullscreen_drain_in_flight, - release_macos_fullscreen_drain, + release_macos_fullscreen_drain, MACOS_FULLSCREEN_DRAIN, }; - release_macos_fullscreen_drain(); + *MACOS_FULLSCREEN_DRAIN.lock().unwrap() = None; assert!(!macos_fullscreen_drain_in_flight()); - assert!(claim_macos_fullscreen_drain()); + + let first = claim_macos_fullscreen_drain().expect("first press claims the drain"); assert!( macos_fullscreen_drain_in_flight(), "a second press must be able to see the drain it should defer to" ); assert!( - !claim_macos_fullscreen_drain(), + claim_macos_fullscreen_drain().is_none(), "a press arriving mid-drain must not start a second one" ); - release_macos_fullscreen_drain(); + + release_macos_fullscreen_drain(first); assert!(!macos_fullscreen_drain_in_flight()); + let second = + claim_macos_fullscreen_drain().expect("the next press is answerable once done"); + assert_ne!(first, second); + + // The wedge case the generation exists for: the thread that lost its + // claim to a takeover must not clear the claim that replaced it. + release_macos_fullscreen_drain(first); assert!( - claim_macos_fullscreen_drain(), - "the next press must be answerable once the drain is done" + macos_fullscreen_drain_in_flight(), + "a stale release must leave the incumbent drain owning the press" ); - release_macos_fullscreen_drain(); + + release_macos_fullscreen_drain(second); + assert!(!macos_fullscreen_drain_in_flight()); } }