diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 54dfc949..9eb760b7 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -36,7 +36,7 @@ pub use replay::{Record, replay}; use serde::{Deserialize, Serialize}; use serde_json; use serde_with::serde_as; -use tracing::{debug, instrument, trace, warn}; +use tracing::{debug, info, instrument, trace, warn}; use transaction_manager::TransactionId; use super::event_tap; @@ -508,6 +508,8 @@ impl Reactor { fullscreen_by_space: HashMap::default(), changing_screens: HashSet::default(), screen_space_by_id: HashMap::default(), + pending_wake_from_sleep: false, + wake_completed_at: None, }, main_window_tracker_manager: managers::MainWindowTrackerManager { main_window_tracker: MainWindowTracker::default(), @@ -740,7 +742,9 @@ impl Reactor { SystemEventHandler::handle_register_wm_sender(self, sender) } Event::WindowsDiscovered { pid, new, known_visible } => { - AppEventHandler::handle_windows_discovered(self, pid, new, known_visible); + if !self.space_manager.pending_wake_from_sleep { + AppEventHandler::handle_windows_discovered(self, pid, new, known_visible); + } } Event::WindowCreated(wid, window, ws_info, mouse_state) => { WindowEventHandler::handle_window_created(self, wid, window, ws_info, mouse_state); @@ -1085,6 +1089,12 @@ impl Reactor { if allow_remap { if let Some(previous_space) = last_space { if previous_space != *space { + info!( + ?previous_space, + new_space = ?space, + display = ?screen.display_uuid, + "remapping space for display" + ); self.layout_manager.layout_engine.remap_space(previous_space, *space); } } @@ -1131,7 +1141,9 @@ impl Reactor { } } self.update_complete_window_server_info(ws_info); - self.check_for_new_windows(); + if !self.space_manager.pending_wake_from_sleep { + self.check_for_new_windows(); + } if let Some(space) = spaces.iter().copied().flatten().next() { if let Some(workspace_id) = self.layout_manager.layout_engine.active_workspace(space) { @@ -2608,6 +2620,20 @@ impl Reactor { is_resize: bool, is_workspace_switch: bool, ) -> Result { + // Block layout updates during wake and stabilization period to prevent corrupting + // layout state while macOS is still changing space IDs and reconnecting displays + if self.space_manager.pending_wake_from_sleep { + return Ok(false); + } + + if let Some(completed_at) = self.space_manager.wake_completed_at { + if completed_at.elapsed() < Duration::from_secs(5) { + return Ok(false); + } + self.space_manager.wake_completed_at = None; + info!("wake stabilization period complete, resuming normal operations"); + } + LayoutManager::update_layout(self, is_resize, is_workspace_switch) } } diff --git a/src/actor/reactor/events/space.rs b/src/actor/reactor/events/space.rs index dce5471e..a82a8239 100644 --- a/src/actor/reactor/events/space.rs +++ b/src/actor/reactor/events/space.rs @@ -22,6 +22,19 @@ impl SpaceEventHandler { wsid: WindowServerId, sid: SpaceId, ) { + if reactor.space_manager.pending_wake_from_sleep { + return; + } + + let active_spaces: HashSet = + reactor.space_manager.screens.iter().filter_map(|s| s.space).collect(); + + // Filter out destruction events for inactive spaces to prevent processing stale events + // during wake-from-sleep when macOS sends spurious destruction events + if !active_spaces.is_empty() && !active_spaces.contains(&sid) { + return; + } + if crate::sys::window_server::space_is_fullscreen(sid.get()) { let entry = match reactor.space_manager.fullscreen_by_space.entry(sid.get()) { Entry::Occupied(o) => o.into_mut(), @@ -197,15 +210,34 @@ impl SpaceEventHandler { ) { let previous_displays: HashSet = reactor.space_manager.screens.iter().map(|s| s.display_uuid.clone()).collect(); - let new_displays: HashSet = - screens.iter().map(|s| s.display_uuid.clone()).collect(); - let displays_changed = previous_displays != new_displays; - if displays_changed { - let active_list: Vec = new_displays.iter().cloned().collect(); - reactor.layout_manager.layout_engine.prune_display_state(&active_list); + + let was_waking_from_sleep = reactor.space_manager.pending_wake_from_sleep; + // During wake, ignore events until all displays have reconnected to avoid processing + // intermediate states where macOS is still reconnecting external displays + if was_waking_from_sleep && screens.len() < previous_displays.len() { + return; } let spaces: Vec> = screens.iter().map(|s| s.space).collect(); + let has_any_valid_space = spaces.iter().any(|s| s.is_some()); + + // Detect wake completion: all displays reconnected and spaces have been assigned. + // We then enter a stabilization period during which layout updates are blocked, + // allowing macOS to finish internal space ID changes. + if was_waking_from_sleep { + let all_displays_reconnected = screens.len() == previous_displays.len(); + + if all_displays_reconnected && has_any_valid_space { + reactor.space_manager.pending_wake_from_sleep = false; + reactor.space_manager.wake_completed_at = Some(std::time::Instant::now()); + info!( + displays = screens.len(), + ?spaces, + "wake from sleep completed, entering 5s stabilization period" + ); + } + } + let spaces_all_none = spaces.iter().all(|space| space.is_none()); reactor.refocus_manager.stale_cleanup_state = if spaces_all_none { StaleCleanupState::Suppressed @@ -235,21 +267,22 @@ impl SpaceEventHandler { // Do not remap layout state across reconnects; new space ids can churn and // remapping has caused windows to oscillate. Keep existing state and only // update the screen→space mapping. - reactor.reconcile_spaces_with_display_history(&spaces, false); - if let Some(info) = ws_info_opt.take() { - reactor.finalize_space_change(&spaces, info); + // Exception: during wake-from-sleep, allow remapping to preserve layouts when + // macOS changes space IDs. + let should_remap = was_waking_from_sleep && has_any_valid_space; + reactor.reconcile_spaces_with_display_history(&spaces, should_remap); + + // Skip finalization during wake to prevent premature space changes + if !was_waking_from_sleep { + if let Some(info) = ws_info_opt.take() { + reactor.finalize_space_change(&spaces, info); + } } } if let Some(info) = ws_info_opt.take() { reactor.update_complete_window_server_info(info); } reactor.try_apply_pending_space_change(); - - // Mark that we should perform a one-shot relayout after spaces are applied, - // so windows return to their prior displays post-topology change. - if displays_changed { - reactor.pending_space_change_manager.topology_relayout_pending = true; - } } pub fn handle_space_changed( @@ -312,18 +345,26 @@ impl SpaceEventHandler { ); return; } - reactor.reconcile_spaces_with_display_history(&spaces, false); + // Allow space remapping during wake to preserve layouts when macOS changes space IDs + let should_remap = reactor.space_manager.pending_wake_from_sleep; + reactor.reconcile_spaces_with_display_history(&spaces, should_remap); info!("space changed"); reactor.set_screen_spaces(&spaces); reactor.finalize_space_change(&spaces, ws_info); - // If a topology change was detected earlier, perform a one-shot refresh/layout - // now that we have a consistent space vector matching the screens. - if reactor.pending_space_change_manager.topology_relayout_pending { - reactor.pending_space_change_manager.topology_relayout_pending = false; - reactor.force_refresh_all_windows(); - if let Err(e) = reactor.update_layout(false, false) { - warn!(error = ?e, "Layout update failed after topology change"); + // Alternative wake completion path: sometimes space changes complete before screen params + if reactor.space_manager.pending_wake_from_sleep { + let has_any_valid_space = spaces.iter().any(|s| s.is_some()); + let all_screens_have_spaces = spaces.len() == reactor.space_manager.screens.len(); + + if has_any_valid_space && all_screens_have_spaces { + reactor.space_manager.pending_wake_from_sleep = false; + reactor.space_manager.wake_completed_at = Some(std::time::Instant::now()); + info!( + screens = reactor.space_manager.screens.len(), + ?spaces, + "wake from sleep completed via space change, entering 5s stabilization period" + ); } } } diff --git a/src/actor/reactor/events/system.rs b/src/actor/reactor/events/system.rs index dd34af3b..57671fb8 100644 --- a/src/actor/reactor/events/system.rs +++ b/src/actor/reactor/events/system.rs @@ -1,4 +1,4 @@ -use tracing::debug; +use tracing::{debug, info}; use crate::actor::app::WindowId; use crate::actor::raise_manager; @@ -35,10 +35,13 @@ impl SystemEventHandler { } pub fn handle_system_woke(reactor: &mut Reactor) { + info!("system woke from sleep"); let ids: Vec = reactor.window_manager.window_ids.keys().map(|wsid| wsid.as_u32()).collect(); crate::sys::window_notify::update_window_notifications(&ids); reactor.notification_manager.last_sls_notification_ids = ids; + + reactor.space_manager.pending_wake_from_sleep = true; } pub fn handle_raise_completed(reactor: &mut Reactor, window_id: WindowId, sequence_id: u64) { diff --git a/src/actor/reactor/managers.rs b/src/actor/reactor/managers.rs index 2c6dfdfe..0f78e7c5 100644 --- a/src/actor/reactor/managers.rs +++ b/src/actor/reactor/managers.rs @@ -80,9 +80,11 @@ impl AppManager { /// Manages space and screen state pub struct SpaceManager { pub screens: Vec, + pub screen_space_by_id: HashMap, pub fullscreen_by_space: HashMap, pub changing_screens: HashSet, - pub screen_space_by_id: HashMap, + pub pending_wake_from_sleep: bool, + pub wake_completed_at: Option, } impl SpaceManager { diff --git a/src/actor/wm_controller.rs b/src/actor/wm_controller.rs index 9033efe6..b7c84e63 100644 --- a/src/actor/wm_controller.rs +++ b/src/actor/wm_controller.rs @@ -331,30 +331,44 @@ impl WmController { let new_display_uuids: HashSet = screens.iter().map(|s| s.display_uuid.clone()).collect(); let displays_changed = prev_display_uuids != new_display_uuids; + let returning_displays: Vec = + new_display_uuids.intersection(&prev_display_uuids).cloned().collect(); info!( default_disable, prev_displays = ?prev_display_uuids, new_displays = ?new_display_uuids, displays_changed, + returning_displays = ?returning_displays, screen_count = screens.len(), "ScreenParametersChanged received" ); + // During wake/sleep when displays are disconnecting, ignore intermediate events + // to avoid processing incomplete display topology states + if displays_changed + && new_display_uuids.len() < prev_display_uuids.len() + && !prev_display_uuids.is_empty() + { + return; + } + if displays_changed && !default_disable { - // When displays change in default-enable mode, drop any remembered - // disabled display/space state so surviving displays default to enabled. - self.disabled_spaces.clear(); - self.disabled_displays.clear(); - info!( - "Cleared disabled state due to display set change (default_disable=false)" - ); + let removed_displays: Vec<_> = + prev_display_uuids.difference(&new_display_uuids).collect(); + if !removed_displays.is_empty() { + info!( + removed_displays = ?removed_displays, + "clearing disabled state for removed displays" + ); + self.disabled_displays.retain(|uuid| new_display_uuids.contains(uuid)); + self.disabled_spaces.clear(); + } } - if !default_disable && screens.len() == 1 { - // After sleep/resume with a single display, ensure we default to enabled. + if !default_disable && screens.len() == 1 && prev_display_uuids.len() > 1 { self.disabled_spaces.clear(); self.disabled_displays.clear(); - info!("Cleared disabled state for single-display default-enable scenario"); + info!("Cleared disabled state for single-display transition"); } self.screen_params_received = true; @@ -633,19 +647,25 @@ impl WmController { fn handle_space_changed(&mut self, spaces: Vec>) { let previous_spaces = self.cur_space.clone(); + let previous_enabled_displays = self.enabled_displays.clone(); + let previous_disabled_displays = self.disabled_displays.clone(); self.cur_space = spaces; - let active_spaces: HashSet = - self.cur_space.iter().copied().flatten().collect::>(); - let active_displays: HashSet = - self.cur_display_uuid.iter().cloned().collect::>(); - let active_screen_ids: HashSet = - self.cur_screen_id.iter().copied().collect::>(); - - self.disabled_spaces.retain(|space| active_spaces.contains(space)); - self.enabled_spaces.retain(|space| active_spaces.contains(space)); - self.disabled_displays.retain(|uuid| active_displays.contains(uuid)); - self.enabled_displays.retain(|uuid| active_displays.contains(uuid)); + let active_spaces: HashSet = self.cur_space.iter().copied().flatten().collect(); + let active_screen_ids: HashSet = self.cur_screen_id.iter().copied().collect(); + + let default_disable = self.config.config.settings.default_disable; + let preserve_activation_state = if default_disable { + !self.enabled_displays.is_empty() && active_spaces.is_empty() + } else { + !self.disabled_displays.is_empty() && active_spaces.is_empty() + }; + + if !preserve_activation_state { + self.disabled_spaces.retain(|space| active_spaces.contains(space)); + self.enabled_spaces.retain(|space| active_spaces.contains(space)); + } + self.last_known_space_by_screen .retain(|screen, _| active_screen_ids.contains(screen)); @@ -695,7 +715,6 @@ impl WmController { } } - let default_disable = self.config.config.settings.default_disable; for (idx, space_opt) in self.cur_space.iter().enumerate() { let Some(space) = space_opt else { continue }; let Some(display_uuid) = self.cur_display_uuid.get(idx) else { @@ -703,23 +722,17 @@ impl WmController { }; if default_disable { - if self.enabled_displays.contains(display_uuid) { - if self.enabled_spaces.insert(*space) { - debug!( - "synced space {:?} to enabled_spaces from display {:?}", - space, display_uuid - ); - } - } - } else { - if self.disabled_displays.contains(display_uuid) { - if self.disabled_spaces.insert(*space) { - debug!( - "synced space {:?} to disabled_spaces from display {:?}", - space, display_uuid - ); - } + if self.enabled_displays.contains(display_uuid) + || previous_enabled_displays.contains(display_uuid) + { + self.enabled_displays.insert(display_uuid.clone()); + self.enabled_spaces.insert(*space); } + } else if self.disabled_displays.contains(display_uuid) + || previous_disabled_displays.contains(display_uuid) + { + self.disabled_displays.insert(display_uuid.clone()); + self.disabled_spaces.insert(*space); } }