Skip to content

Commit 4a078ec

Browse files
recchiaclaude
andcommitted
fix(hook): bound wlr frontmost dispatch with deadlines (review)
The three unbounded `EventQueue::roundtrip` calls are replaced by two deadline-aware primitives: - `timed_roundtrip` (init path): sends `wl_display.sync`, then loops `flush β†’ poll(2) β†’ read β†’ dispatch_pending` until the `WlCallback::Done` fires or `INIT_TIMEOUT = 5 s` is reached. Symmetric to `gnome_shell::METHOD_TIMEOUT`; both guard the `FRONTMOST_SOURCE` `LazyLock` initializer so a stalled compositor socket makes the candidate fall through instead of blocking every thread that touches frontmost. - `drain_events` (poll path): the protocol is event-driven so no sync barrier is needed. Flushes outgoing writes, then does a non-blocking `prepare_read β†’ poll(2, 25 ms cap) β†’ read β†’ dispatch_pending`. If nothing arrives within the cap the last known state is returned β€” millisecond-stale frontmost data is acceptable by design. Both paths use `poll(2)` via the existing `libc` dependency with `Instant`-based remaining-time accounting per iteration and `EINTR` retry. A read error marks the session finished, consistent with the existing reconnect behavior. A small `millis_until` helper converts an `Instant` deadline to a `poll(2)` timeout; two unit tests cover the boundary cases. Compositor death and reconnect behavior are unchanged from the prior commit. Runtime validation on a wlroots compositor is still pending (this machine runs GNOME/Mutter, which doesn't advertise the protocol). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6c857a3 commit 4a078ec

1 file changed

Lines changed: 202 additions & 23 deletions

File tree

β€Žcrates/openlogi-hook/src/linux/wlr_foreign_toplevel.rsβ€Ž

Lines changed: 202 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,34 @@
2020
//! [`connect`](WlrForeignToplevelSource::connect) returns `None` there and the
2121
//! caller falls through to the next backend candidate.
2222
//!
23+
//! ## Dispatch model
24+
//!
2325
//! The protocol is event-driven, but the [`super::FrontmostSource`] contract is
24-
//! a synchronous poll (~1 Hz from `openlogi-gui::app_watcher`). To bridge that,
25-
//! the connection, event queue, and accumulated state are kept alive in the
26-
//! backend and a `roundtrip` is performed on each poll to drain pending events
27-
//! (focus changes, new / closed windows) before reading the active toplevel.
26+
//! a synchronous poll (~1 Hz from `openlogi-gui::app_watcher`). Two primitives
27+
//! bridge that gap:
28+
//!
29+
//! - **`drain_events`** (poll path) β€” flushes pending writes, then attempts a
30+
//! non-blocking `prepare_read` + `read` with a short 25 ms `poll(2)` cap.
31+
//! If nothing arrives in time the last known state is returned unchanged;
32+
//! millisecond-stale frontmost data is acceptable by design.
33+
//!
34+
//! - **`timed_roundtrip`** (init path) β€” sends `wl_display.sync`, then loops
35+
//! `flush` β†’ `poll(2)` β†’ `read` β†’ `dispatch_pending` until the sync callback
36+
//! fires or `INIT_TIMEOUT` (5 s) expires. If the deadline is hit the candidate
37+
//! returns `None` so backend selection falls through β€” the same contract as
38+
//! every other backend.
39+
//!
40+
//! Both helpers use `poll(2)` via the `libc` crate (already a Linux dependency)
41+
//! with `Instant`-based remaining-time accounting and `EINTR` retry.
2842
2943
use std::collections::HashMap;
44+
use std::os::unix::io::AsRawFd;
3045
use std::sync::Mutex;
46+
use std::time::{Duration, Instant};
3147

3248
use tracing::{debug, info, warn};
3349
use wayland_client::backend::ObjectId;
50+
use wayland_client::protocol::wl_callback;
3451
use wayland_client::protocol::wl_registry::{self, WlRegistry};
3552
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, event_created_child};
3653
use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{
@@ -47,6 +64,15 @@ use super::FrontmostSource;
4764
/// here to stay within what `wayland-protocols-wlr` generates.
4865
const MANAGER_MAX_VERSION: u32 = 3;
4966

67+
/// Deadline for the two `wl_display.sync` round-trips in `Session::open`.
68+
/// Mirrors `gnome_shell::METHOD_TIMEOUT`: both guard the `FRONTMOST_SOURCE`
69+
/// `LazyLock` initializer against a stalled compositor socket.
70+
const INIT_TIMEOUT: Duration = Duration::from_secs(5);
71+
72+
/// Maximum time the poll-path drain will wait for new Wayland events. Stale
73+
/// frontmost data within this window is acceptable by design.
74+
const POLL_CAP_MS: u64 = 25;
75+
5076
/// Accumulated per-toplevel data. wlr sends individual property events and then
5177
/// a `done` marking a consistent snapshot, so updates are staged in `pending_*`
5278
/// and committed on `done`.
@@ -66,6 +92,9 @@ struct State {
6692
/// Set when the compositor sends `finished`; triggers a reconnect on the
6793
/// next poll instead of permanently disabling the backend.
6894
finished: bool,
95+
/// Flipped to `true` by the `wl_callback::Done` handler; used by
96+
/// `timed_roundtrip` to detect that the sync echo arrived.
97+
sync_done: bool,
6998
}
7099

71100
impl Dispatch<WlRegistry, ()> for State {
@@ -175,6 +204,21 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for State {
175204
}
176205
}
177206

207+
impl Dispatch<wl_callback::WlCallback, ()> for State {
208+
fn event(
209+
state: &mut Self,
210+
_: &wl_callback::WlCallback,
211+
event: wl_callback::Event,
212+
(): &(),
213+
_: &Connection,
214+
_: &QueueHandle<Self>,
215+
) {
216+
if let wl_callback::Event::Done { .. } = event {
217+
state.sync_done = true;
218+
}
219+
}
220+
}
221+
178222
/// The `state` event carries a `wl_array` of native-endian `u32` state values.
179223
/// A toplevel is frontmost iff the `activated` value is present in that set.
180224
fn is_activated(states: &[u8]) -> bool {
@@ -186,6 +230,116 @@ fn is_activated(states: &[u8]) -> bool {
186230
})
187231
}
188232

233+
/// Returns the milliseconds remaining until `deadline`, clamped to `[0, i32::MAX]`
234+
/// for use as a `libc::poll` timeout. Returns 0 when the deadline has passed.
235+
fn millis_until(deadline: Instant) -> i32 {
236+
i32::try_from(
237+
deadline
238+
.saturating_duration_since(Instant::now())
239+
.as_millis()
240+
.min(i32::MAX as u128),
241+
)
242+
.unwrap_or(i32::MAX)
243+
}
244+
245+
/// Calls `poll(2)` on `fd` (waiting for `POLLIN | POLLERR`) with a deadline.
246+
/// Retries on `EINTR` with the remaining time. Returns `true` if the fd became
247+
/// readable, `false` on timeout or error.
248+
fn poll_fd(fd: libc::c_int, deadline: Instant) -> bool {
249+
let mut pfd = libc::pollfd {
250+
fd,
251+
events: libc::POLLIN | libc::POLLERR,
252+
revents: 0,
253+
};
254+
loop {
255+
let timeout_ms = millis_until(deadline);
256+
if timeout_ms == 0 {
257+
return false;
258+
}
259+
let r = unsafe { libc::poll(&raw mut pfd, 1, timeout_ms) };
260+
if r > 0 {
261+
return true;
262+
}
263+
if r == 0 {
264+
return false;
265+
}
266+
// r < 0 β€” check errno
267+
let e = unsafe { *libc::__errno_location() };
268+
if e != libc::EINTR {
269+
return false;
270+
}
271+
// EINTR: retry with remaining deadline
272+
}
273+
}
274+
275+
/// Sends `wl_display.sync` and spins `flush β†’ poll β†’ read β†’ dispatch_pending`
276+
/// until the sync callback fires or `deadline` is reached. Returns `true` on
277+
/// success, `false` on timeout or connection error.
278+
fn timed_roundtrip(
279+
conn: &Connection,
280+
queue: &mut EventQueue<State>,
281+
state: &mut State,
282+
deadline: Instant,
283+
) -> bool {
284+
state.sync_done = false;
285+
let qh = queue.handle();
286+
conn.display().sync(&qh, ());
287+
288+
loop {
289+
if queue.flush().is_err() {
290+
return false;
291+
}
292+
if queue.dispatch_pending(state).is_err() {
293+
return false;
294+
}
295+
if state.sync_done {
296+
return true;
297+
}
298+
if millis_until(deadline) == 0 {
299+
return false;
300+
}
301+
302+
match queue.prepare_read() {
303+
None => {
304+
// Events are already buffered; loop back to dispatch.
305+
}
306+
Some(guard) => {
307+
let fd = guard.connection_fd().as_raw_fd();
308+
if !poll_fd(fd, deadline) {
309+
// Timed out or error β€” candidate falls through.
310+
return false;
311+
}
312+
if guard.read().is_err() {
313+
return false;
314+
}
315+
}
316+
}
317+
}
318+
}
319+
320+
/// Drains pending compositor events without blocking longer than `POLL_CAP_MS`.
321+
/// Used on every frontmost poll. Stale data within the cap is acceptable by
322+
/// design; errors are silently ignored so the last known state is returned.
323+
fn drain_events(queue: &mut EventQueue<State>, state: &mut State) {
324+
let _ = queue.flush();
325+
let _ = queue.dispatch_pending(state);
326+
327+
let deadline = Instant::now() + Duration::from_millis(POLL_CAP_MS);
328+
match queue.prepare_read() {
329+
None => {
330+
// Already had buffered events; dispatch_pending above handled them.
331+
}
332+
Some(guard) => {
333+
let fd = guard.connection_fd().as_raw_fd();
334+
if poll_fd(fd, deadline) {
335+
let _ = guard.read();
336+
let _ = queue.dispatch_pending(state);
337+
}
338+
// If poll timed out, guard is dropped here and we return stale state.
339+
}
340+
}
341+
}
342+
189343
/// One live Wayland session: connection + event queue + dispatch state.
190344
///
191345
/// Grouping all three behind a single mutex means the whole session can be
@@ -200,35 +354,36 @@ struct Session {
200354

201355
impl Session {
202356
/// Open a fresh connection, bind the manager, and do the initial two
203-
/// round-trips to populate the toplevel list. Returns `None` when the
204-
/// compositor doesn't advertise the protocol or the connection fails.
357+
/// timed round-trips to populate the toplevel list. Returns `None` when
358+
/// the compositor doesn't advertise the protocol, the connection fails,
359+
/// or either round-trip exceeds `INIT_TIMEOUT`.
205360
fn open() -> Option<Self> {
206361
let conn = Connection::connect_to_env()
207362
.map_err(|e| debug!("wlr-foreign-toplevel: no Wayland connection: {e}"))
208363
.ok()?;
209364
let mut queue = conn.new_event_queue();
210365
let qh = queue.handle();
211366

212-
// Registering the registry triggers `global` events on the next
213-
// round-trip, where the manager is bound if the compositor advertises
214-
// it. The registry handle is only needed for that round-trip.
367+
// Registering the registry triggers `global` events on the first
368+
// round-trip, where the manager is bound if the compositor advertises it.
215369
let _registry = conn.display().get_registry(&qh, ());
216370
let mut state = State::default();
217-
queue
218-
.roundtrip(&mut state)
219-
.map_err(|e| debug!("wlr-foreign-toplevel: registry roundtrip failed: {e}"))
220-
.ok()?;
371+
372+
if !timed_roundtrip(&conn, &mut queue, &mut state, Instant::now() + INIT_TIMEOUT) {
373+
debug!("wlr-foreign-toplevel: registry round-trip timed out or failed");
374+
return None;
375+
}
221376
if state.manager.is_none() {
222377
debug!("wlr-foreign-toplevel: compositor does not advertise the protocol");
223378
return None;
224379
}
225380

226381
// Second round-trip: receive the initial toplevel list and properties,
227382
// so the first poll already has the active window.
228-
queue
229-
.roundtrip(&mut state)
230-
.map_err(|e| debug!("wlr-foreign-toplevel: initial roundtrip failed: {e}"))
231-
.ok()?;
383+
if !timed_roundtrip(&conn, &mut queue, &mut state, Instant::now() + INIT_TIMEOUT) {
384+
debug!("wlr-foreign-toplevel: initial toplevel round-trip timed out or failed");
385+
return None;
386+
}
232387

233388
Some(Self {
234389
_conn: conn,
@@ -261,19 +416,20 @@ impl FrontmostSource for WlrForeignToplevelSource {
261416

262417
// Reconnect when the compositor sent `Finished` (compositor reload /
263418
// restart) or when a prior reconnect attempt failed.
264-
let needs_reconnect = guard.as_ref().map_or(true, |s| s.state.finished);
419+
let needs_reconnect = guard.as_ref().is_none_or(|s| s.state.finished);
265420
if needs_reconnect {
266421
*guard = Session::open();
267-
match &*guard {
268-
Some(_) => info!("wlr-foreign-toplevel: reconnected"),
269-
None => debug!("wlr-foreign-toplevel: reconnect pending, retrying next poll"),
422+
if guard.is_some() {
423+
info!("wlr-foreign-toplevel: reconnected");
424+
} else {
425+
debug!("wlr-foreign-toplevel: reconnect pending, retrying next poll");
270426
}
271427
}
272428

273429
let Session { queue, state, .. } = guard.as_mut()?;
274-
queue.roundtrip(state).ok()?;
430+
drain_events(queue, state);
275431
if state.finished {
276-
// `Finished` arrived during this poll; reconnect on the next call.
432+
// `Finished` arrived during this drain; reconnect on the next call.
277433
return None;
278434
}
279435

@@ -293,3 +449,26 @@ impl FrontmostSource for WlrForeignToplevelSource {
293449
pub(super) fn candidate() -> Option<Box<dyn FrontmostSource>> {
294450
WlrForeignToplevelSource::connect().map(|s| Box::new(s) as Box<dyn FrontmostSource>)
295451
}
452+
453+
#[cfg(test)]
454+
mod tests {
455+
use std::time::{Duration, Instant};
456+
457+
use super::millis_until;
458+
459+
#[test]
460+
fn millis_until_elapsed_deadline_is_zero() {
461+
// `deadline` is captured before the call; by the time `millis_until`
462+
// reads `Instant::now()` the deadline is at or before now, so
463+
// `saturating_duration_since` returns `Duration::ZERO` β†’ 0 ms.
464+
let deadline = Instant::now();
465+
assert_eq!(millis_until(deadline), 0);
466+
}
467+
468+
#[test]
469+
fn millis_until_future_deadline_is_positive() {
470+
let future = Instant::now() + Duration::from_secs(10);
471+
let ms = millis_until(future);
472+
assert!(ms > 0 && ms <= 10_000);
473+
}
474+
}

0 commit comments

Comments
Β (0)