entracte_lib/display.rs
1//! Display-server plumbing: process-wide Xlib setup, and the one place
2//! that marshals a display read onto the windowing system's main thread.
3//!
4//! ## Why Xlib threading has to be initialised (#333)
5//!
6//! Entracte is a multi-threaded X client whether it wants to be or not:
7//! GTK/WebKit own the main thread, while the scheduler run loop — a
8//! `tauri::async_runtime` task, i.e. a tokio worker — polls
9//! [`crate::scheduler::idle`] once a second, and on X11 `user_idle` answers
10//! that by calling `XOpenDisplay` / `XScreenSaverQueryInfo` /
11//! `XCloseDisplay` directly on the calling thread.
12//!
13//! libX11's internal locking is inert until `XInitThreads()` has been
14//! called, and the call has to come before any `Display` is opened to be
15//! effective. Without it, two threads touching Xlib corrupt the request
16//! queue and libxcb aborts the whole process:
17//!
18//! ```text
19//! [xcb] Unknown request in queue while dequeuing
20//! [xcb] Most likely this is a multi-threaded client and XInitThreads has not been called
21//! [xcb] Aborting, sorry about that.
22//! ```
23//!
24//! That abort killed the `smoke (ubuntu-22.04)` e2e job intermittently, and
25//! it is not a CI artefact — the same two threads race in a real X11
26//! session. The abort firing is itself the proof that Xlib locking was not
27//! enabled in the process: with it enabled, concurrent same-display use is
28//! Xlib's job to serialise.
29//!
30//! `XInitThreads()` is the documented prerequisite here, not a workaround
31//! for a symptom. The alternative — marshalling every X call onto the main
32//! thread — cannot cover the idle probe: that connection belongs to a
33//! third-party crate, and parking a blocking 1 Hz X round-trip on the GTK
34//! main thread would be worse than the problem.
35//!
36//! ## Why the main-thread hop still exists
37//!
38//! Xlib locking makes concurrent access *safe*, not *correct*. A handful of
39//! `tauri` APIs reach into the event loop's `window_target` with no
40//! dispatch at all, and the `unsafe impl Send` that lets that type cross
41//! threads states its own precondition: "we ensure this type is only used
42//! on the main thread". [`on_main_thread`] is how every such read upholds
43//! it, in one place.
44//!
45//! Both of those mechanisms report themselves, because a silent fix for an
46//! intermittent abort is one you get to debug twice: the locking state lands
47//! in the startup banner via [`threading_state`], and a hop that gives up
48//! warns with the name of the read it abandoned.
49
50use tauri::{AppHandle, Runtime};
51
52/// How long to wait for the main thread to answer a display read before
53/// giving up. Generous enough to absorb a busy event loop, short enough
54/// that a wedged main thread degrades to "no answer" rather than stalling
55/// the caller indefinitely.
56const MAIN_THREAD_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
57
58/// Whether libX11's internal locking is on for this process. Only meaningful
59/// on Linux/X11; callers off Linux report "n/a" themselves, the way the
60/// banner already handles [`crate::window::WaylandFix`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub(crate) enum XlibLocking {
63 On,
64 Off,
65 /// [`init_display_threading`] has not run yet.
66 Unknown,
67}
68
69impl XlibLocking {
70 /// Short stable token for logs and the startup banner, so a bug report
71 /// shows whether #333's prerequisite actually took effect.
72 pub(crate) fn as_str(self) -> &'static str {
73 match self {
74 Self::On => "on",
75 Self::Off => "off",
76 Self::Unknown => "unknown",
77 }
78 }
79}
80
81/// Recorded by [`init_display_threading`] so the startup banner can report
82/// it. It has to be recorded rather than logged on the spot: that function
83/// runs as the very first statement of `run()`, before `tauri_plugin_log` is
84/// installed, so a `log::warn!` there would go nowhere.
85static THREADING: std::sync::OnceLock<XlibLocking> = std::sync::OnceLock::new();
86
87/// Enable libX11's internal locking for this process and record the outcome
88/// for [`threading_state`]. A no-op off Linux, which has no Xlib.
89///
90/// Must run before the windowing system opens its `Display` — see the module
91/// docs for why, and the call site in `run()` for why that placement is
92/// sufficient.
93pub(crate) fn init_display_threading() {
94 #[cfg(target_os = "linux")]
95 {
96 // SAFETY: `XInitThreads` takes no arguments, returns a plain `int`,
97 // and dereferences nothing of ours — it only flips libX11's
98 // process-global locking state — so the call has no soundness
99 // precondition beyond libX11 being linked, which the `x11` crate
100 // guarantees on this target. Calling it late is documented as
101 // ineffective, not unsound, so the ordering rule is correctness and
102 // lives at the call site.
103 let _ = THREADING.set(threading_init_result(unsafe { x11::xlib::XInitThreads() }));
104 }
105}
106
107/// Map `XInitThreads`' return code (non-zero on success). Pure so both
108/// outcomes are unit-testable on every OS without an X server.
109#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
110fn threading_init_result(code: std::os::raw::c_int) -> XlibLocking {
111 if code == 0 {
112 XlibLocking::Off
113 } else {
114 XlibLocking::On
115 }
116}
117
118/// This process' Xlib locking state, for the startup banner.
119pub(crate) fn threading_state() -> XlibLocking {
120 THREADING.get().copied().unwrap_or(XlibLocking::Unknown)
121}
122
123/// Run `read` on the windowing system's main thread and hand back its
124/// result.
125///
126/// For the `tauri` getters that reach straight into the event loop's
127/// `window_target` — `available_monitors`, `primary_monitor`,
128/// `monitor_from_point`, `display_handle` — which are *not* dispatched
129/// through the event loop and therefore must not be called from a tokio
130/// worker (#333). `cursor_position` and every window setter already
131/// dispatch and need no hop.
132///
133/// Deliberately **not** used for window creation: `tauri-runtime-wry`
134/// documents that `create_webview` "must be called from a separate thread,
135/// otherwise the channel will introduce a deadlock", so the overlay
136/// builder has to stay on the caller's thread.
137///
138/// `read` must return plain data, never a live handle, so the value is safe
139/// to use after the hop. The `T: Send` bound cannot enforce that — a live
140/// `WebviewWindow` would satisfy it — so it stays a convention callers have
141/// to honour.
142///
143/// Safe to call from the main thread as well as off it, which matters because
144/// both happen: `tauri-runtime-wry`'s `send_user_message` compares the
145/// calling thread to the event loop's and runs the task *inline* when they
146/// match, rather than queueing it, so the value is already in the channel
147/// before `recv_timeout` is reached. No self-deadlock.
148///
149/// Returns `None` if the main thread is unreachable or too slow, which lets
150/// callers degrade rather than risk a hang. `what` names the read in that
151/// warning, because the consequences differ sharply between call sites.
152pub(crate) fn on_main_thread<R, T, F>(app: &AppHandle<R>, what: &'static str, read: F) -> Option<T>
153where
154 R: Runtime,
155 T: Send + 'static,
156 F: FnOnce(&AppHandle<R>) -> T + Send + 'static,
157{
158 let (tx, rx) = std::sync::mpsc::channel();
159 let handle = app.clone();
160 let dispatched = app.run_on_main_thread(move || {
161 let _ = tx.send(read(&handle));
162 });
163 match dispatched {
164 Ok(()) => hop_outcome(what, rx.recv_timeout(MAIN_THREAD_READ_TIMEOUT)),
165 Err(e) => gave_up(what, e),
166 }
167}
168
169/// Interpret the hop's answer: the value if the main thread sent one, else a
170/// warning and `None`. Split out so the give-up arm — which no test can
171/// provoke through a live runtime — is still exercised directly.
172fn hop_outcome<T>(what: &str, outcome: Result<T, std::sync::mpsc::RecvTimeoutError>) -> Option<T> {
173 match outcome {
174 Ok(value) => Some(value),
175 Err(e) => gave_up(what, e),
176 }
177}
178
179/// Report a read the main thread never answered and degrade to `None`.
180/// `why` distinguishes the cases on its own: a `RecvTimeoutError` says
181/// whether the wait timed out or the task was dropped, and a dispatch error
182/// says the event loop is gone.
183fn gave_up<T>(what: &str, why: impl std::fmt::Display) -> Option<T> {
184 log::warn!("display: gave up reading {what} on the main thread: {why}");
185 None
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn threading_init_result_reads_a_non_zero_code_as_locking_on() {
194 assert_eq!(threading_init_result(1), XlibLocking::On);
195 }
196
197 #[test]
198 fn threading_init_result_reads_zero_as_locking_off() {
199 assert_eq!(threading_init_result(0), XlibLocking::Off);
200 }
201
202 #[test]
203 fn xlib_locking_renders_a_short_banner_token() {
204 assert_eq!(XlibLocking::On.as_str(), "on");
205 assert_eq!(XlibLocking::Off.as_str(), "off");
206 assert_eq!(XlibLocking::Unknown.as_str(), "unknown");
207 }
208
209 // One test for both halves of the global, so it cannot race another
210 // test for the write-once slot. On the ubuntu coverage job this also
211 // exercises the real `XInitThreads` FFI line. The expectation is split
212 // with `#[cfg]` rather than `if cfg!()` so the arm for the other
213 // platform is not compiled — and so does not read as an uncovered line.
214 #[test]
215 fn init_display_threading_records_this_platform_state() {
216 init_display_threading();
217 #[cfg(target_os = "linux")]
218 assert_eq!(threading_state(), XlibLocking::On);
219 #[cfg(not(target_os = "linux"))]
220 assert_eq!(threading_state(), XlibLocking::Unknown);
221 }
222
223 #[test]
224 fn hop_outcome_passes_a_received_value_through() {
225 assert_eq!(hop_outcome("monitors", Ok(7u32)), Some(7));
226 }
227
228 #[test]
229 fn hop_outcome_gives_up_on_a_timeout() {
230 let timed_out = Err(std::sync::mpsc::RecvTimeoutError::Timeout);
231 assert_eq!(hop_outcome::<u32>("monitors", timed_out), None);
232 }
233
234 #[test]
235 fn hop_outcome_gives_up_when_the_sender_is_gone() {
236 let dropped = Err(std::sync::mpsc::RecvTimeoutError::Disconnected);
237 assert_eq!(hop_outcome::<u32>("monitors", dropped), None);
238 }
239
240 #[test]
241 fn gave_up_yields_nothing() {
242 assert_eq!(gave_up::<u32>("monitors", "event loop closed"), None);
243 }
244
245 // Windows is excluded from the mock-runtime rig (see `test_support`).
246 #[cfg(not(target_os = "windows"))]
247 #[test]
248 fn on_main_thread_returns_the_read_value() {
249 let (_dir, app, _sched) =
250 crate::test_support::mock_app_with_scheduler(crate::scheduler::Settings::default());
251 assert_eq!(
252 on_main_thread(app.handle(), "a number", |_| 42u32),
253 Some(42)
254 );
255 }
256}