Skip to content

Commit 0a05375

Browse files
authored
Merge pull request #252 from devmobasa/fix/251-overlay-double-toggle
fix: debounce duplicate daemon toggle bursts
2 parents 96ccdbd + 89f2c73 commit 0a05375

1 file changed

Lines changed: 163 additions & 1 deletion

File tree

src/daemon/core.rs

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::sync::Mutex;
1010
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering};
1111
use std::thread;
1212
use std::thread::JoinHandle;
13-
use std::time::Duration;
13+
use std::time::{Duration, Instant};
1414

1515
#[cfg(test)]
1616
use crate::SESSION_OVERRIDE_FOLLOW_CONFIG;
@@ -32,6 +32,12 @@ use super::tray::start_system_tray;
3232
use super::types::TrayStatusShared;
3333
use super::types::{AlreadyRunningError, BackendRunner, OverlayState};
3434

35+
// Some desktop custom shortcut runners, observed on KDE, can launch the same
36+
// plain `--daemon-toggle` command twice about 400-600ms apart from one key press.
37+
// Suppress only duplicate plain toggles after a successful toggle completes, so
38+
// typed requests still run.
39+
const DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW: Duration = Duration::from_millis(700);
40+
3541
pub struct Daemon {
3642
pub(super) overlay_state: OverlayState,
3743
pub(super) should_quit: Arc<AtomicBool>,
@@ -56,6 +62,7 @@ pub struct Daemon {
5662
pub(super) overlay_spawn_failures: u32,
5763
pub(super) overlay_spawn_next_retry: Option<std::time::Instant>,
5864
pub(super) overlay_spawn_backoff_logged: bool,
65+
pub(super) last_plain_visibility_toggle_completed_at: Option<Instant>,
5966
#[cfg(feature = "tray")]
6067
pub(super) tray_status: Arc<TrayStatusShared>,
6168
}
@@ -94,6 +101,7 @@ impl Daemon {
94101
overlay_spawn_failures: 0,
95102
overlay_spawn_next_retry: None,
96103
overlay_spawn_backoff_logged: false,
104+
last_plain_visibility_toggle_completed_at: None,
97105
#[cfg(feature = "tray")]
98106
tray_status: Arc::new(TrayStatusShared::new()),
99107
}
@@ -129,6 +137,7 @@ impl Daemon {
129137
overlay_spawn_failures: 0,
130138
overlay_spawn_next_retry: None,
131139
overlay_spawn_backoff_logged: false,
140+
last_plain_visibility_toggle_completed_at: None,
132141
#[cfg(feature = "tray")]
133142
tray_status: Arc::new(TrayStatusShared::new()),
134143
}
@@ -189,6 +198,20 @@ impl Daemon {
189198
})
190199
.transpose()?;
191200
self.ensure_visible_overlay_can_accept_request(request.as_ref())?;
201+
let plain_visibility_toggle_requested =
202+
request.as_ref().is_none_or(DaemonToggleRequest::is_empty);
203+
if plain_visibility_toggle_requested {
204+
let now = Instant::now();
205+
if self
206+
.last_plain_visibility_toggle_completed_at
207+
.is_some_and(|previous| {
208+
now.saturating_duration_since(previous) < DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW
209+
})
210+
{
211+
info!("Ignoring duplicate plain daemon visibility toggle");
212+
return Ok(false);
213+
}
214+
}
192215
if let Some(action) = request.as_ref().and_then(|request| request.overlay_action) {
193216
self.pending_activation_token = activation_token;
194217
self.pending_toggle_request = request.filter(|request| !request.is_empty());
@@ -218,6 +241,9 @@ impl Daemon {
218241
self.pending_toggle_request = None;
219242
return Err(err);
220243
}
244+
if plain_visibility_toggle_requested {
245+
self.last_plain_visibility_toggle_completed_at = Some(Instant::now());
246+
}
221247
Ok(false)
222248
}
223249

@@ -682,4 +708,140 @@ mod tests {
682708
assert_eq!(called.load(AtomicOrdering::SeqCst), 0);
683709
assert_eq!(daemon.test_state(), OverlayState::Hidden);
684710
}
711+
712+
#[test]
713+
fn duplicate_plain_toggle_requests_are_debounced() {
714+
let called = Arc::new(AtomicUsize::new(0));
715+
let called_clone = Arc::clone(&called);
716+
let runner: Arc<BackendRunner> = Arc::new(move |_| {
717+
called_clone.fetch_add(1, AtomicOrdering::SeqCst);
718+
Ok(())
719+
});
720+
let mut daemon = Daemon::with_backend_runner(None, runner);
721+
722+
daemon
723+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
724+
.unwrap();
725+
daemon
726+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
727+
.unwrap();
728+
729+
assert_eq!(called.load(AtomicOrdering::SeqCst), 1);
730+
assert_eq!(daemon.test_state(), OverlayState::Hidden);
731+
}
732+
733+
#[test]
734+
fn typed_visibility_toggle_request_is_not_debounced() {
735+
let modes = Arc::new(std::sync::Mutex::new(Vec::new()));
736+
let modes_clone = Arc::clone(&modes);
737+
let runner: Arc<BackendRunner> = Arc::new(move |mode| {
738+
modes_clone
739+
.lock()
740+
.unwrap_or_else(|poisoned| poisoned.into_inner())
741+
.push(mode);
742+
Ok(())
743+
});
744+
let mut daemon = Daemon::with_backend_runner(None, runner);
745+
746+
daemon
747+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
748+
.unwrap();
749+
daemon
750+
.process_single_toggle(
751+
Some(DaemonToggleRequest {
752+
mode: Some("whiteboard".to_string()),
753+
..Default::default()
754+
}),
755+
None,
756+
false,
757+
)
758+
.unwrap();
759+
760+
let modes = modes
761+
.lock()
762+
.unwrap_or_else(|poisoned| poisoned.into_inner());
763+
assert_eq!(modes.as_slice(), &[None, Some("whiteboard".to_string())]);
764+
assert_eq!(daemon.test_state(), OverlayState::Hidden);
765+
}
766+
767+
#[cfg(unix)]
768+
#[test]
769+
fn duplicate_plain_toggle_after_slow_hide_is_debounced() {
770+
let mut daemon = Daemon::new(None, false, None, None);
771+
let child = std::process::Command::new("sleep")
772+
.arg("10")
773+
.spawn()
774+
.expect("spawn slow-terminating test process");
775+
let child_pid = child.id();
776+
assert_eq!(unsafe { libc::kill(child_pid as i32, libc::SIGSTOP) }, 0);
777+
let mut stopped = false;
778+
for _ in 0..20 {
779+
let mut status = 0;
780+
let result = unsafe {
781+
libc::waitpid(
782+
child_pid as i32,
783+
&mut status,
784+
libc::WNOHANG | libc::WUNTRACED,
785+
)
786+
};
787+
if result == child_pid as i32 && libc::WIFSTOPPED(status) {
788+
stopped = true;
789+
break;
790+
}
791+
thread::sleep(Duration::from_millis(10));
792+
}
793+
assert!(stopped, "test child should stop before hide starts");
794+
daemon
795+
.overlay_pid
796+
.store(child.id(), std::sync::atomic::Ordering::Release);
797+
daemon.overlay_child = Some(child);
798+
daemon.overlay_state = OverlayState::Visible;
799+
800+
let hide_started = Instant::now();
801+
daemon
802+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
803+
.unwrap();
804+
assert!(
805+
hide_started.elapsed() >= DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW,
806+
"test setup should keep hide slow enough to cross the debounce window"
807+
);
808+
assert_eq!(daemon.test_state(), OverlayState::Hidden);
809+
810+
let called = Arc::new(AtomicUsize::new(0));
811+
let called_clone = Arc::clone(&called);
812+
daemon.backend_runner = Some(Arc::new(move |_| {
813+
called_clone.fetch_add(1, AtomicOrdering::SeqCst);
814+
Ok(())
815+
}));
816+
817+
daemon
818+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
819+
.unwrap();
820+
821+
assert_eq!(called.load(AtomicOrdering::SeqCst), 0);
822+
assert_eq!(daemon.test_state(), OverlayState::Hidden);
823+
}
824+
825+
#[test]
826+
fn plain_toggle_after_debounce_window_is_processed() {
827+
let called = Arc::new(AtomicUsize::new(0));
828+
let called_clone = Arc::clone(&called);
829+
let runner: Arc<BackendRunner> = Arc::new(move |_| {
830+
called_clone.fetch_add(1, AtomicOrdering::SeqCst);
831+
Ok(())
832+
});
833+
let mut daemon = Daemon::with_backend_runner(None, runner);
834+
835+
daemon
836+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
837+
.unwrap();
838+
daemon.last_plain_visibility_toggle_completed_at =
839+
Some(Instant::now() - DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW - Duration::from_millis(1));
840+
daemon
841+
.process_single_toggle(Some(DaemonToggleRequest::default()), None, false)
842+
.unwrap();
843+
844+
assert_eq!(called.load(AtomicOrdering::SeqCst), 2);
845+
assert_eq!(daemon.test_state(), OverlayState::Hidden);
846+
}
685847
}

0 commit comments

Comments
 (0)