teksilo_platform/wake.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! A window's wake target: the [`RedrawWaker`] any thread uses to have one
5//! window run a frame.
6//!
7//! Two routes, chosen by what the wake keeps current (see
8//! [`teksilo_canvas::wake`]):
9//!
10//! - **[`WakeKind::Draw`]** asks winit for a redraw. It is dropped while the
11//! window is hidden, and one redraw is requested when it is shown again. A
12//! request winit is already holding (a Wayland window waiting for a frame
13//! callback) merges into it, which is right for a wake that only changes
14//! pixels: the redraw winit delivers draws the latest state.
15//! - **[`WakeKind::Layout`]** keeps state current, so it must reach a window
16//! that draws nothing. A redraw request cannot promise that: on Wayland,
17//! while winit holds a redraw back, a further request does not even wake
18//! the event loop (it sets a flag that is already set). It goes through the
19//! *state route* teksilo-app installs, which posts one event per burst to
20//! the event loop; without a route it falls back to a redraw request.
21//!
22//! On macOS a redraw requested from another thread is dispatched
23//! synchronously onto the main thread, so a wake made off it would wait for
24//! the main thread, holding the lock that makes disconnecting exact, and a
25//! main thread taking that lock meanwhile would deadlock. A draw wake made
26//! off the main thread there never asks winit itself: it posts through the
27//! *off-main route* teksilo-app installs, or, with none installed, hands the
28//! request to the main dispatch queue and returns.
29//!
30//! [`disconnect`](WindowWakeTarget::disconnect) ends it: once it has returned,
31//! the target never asks winit for a redraw again, whoever still holds a
32//! waker. A wake already past its check may still post through a route,
33//! which an event loop that has ended refuses. The window's `Drop` runs it,
34//! and teksilo-app runs it for every window when the event loop ends, since
35//! winit panics on X11 when a window is asked for a redraw after that.
36
37use std::sync::{Arc, OnceLock};
38use std::thread::ThreadId;
39
40use teksilo_canvas::sync::{AtomicBool, AtomicU64, Mutex, Ordering};
41use teksilo_canvas::wake::{RedrawWaker, WakeFlag, WakeGate, WakeKind};
42
43/// The one call a wake target makes into winit, a seam so tests can count it.
44pub(crate) trait RequestRedraw: Send + Sync {
45 fn request_redraw(&self);
46}
47
48impl RequestRedraw for winit::window::Window {
49 fn request_redraw(&self) {
50 winit::window::Window::request_redraw(self);
51 }
52}
53
54/// A route that posts to the event loop. Set once by teksilo-app.
55pub(crate) type WakeRoute = Arc<dyn Fn() + Send + Sync>;
56
57/// One window's wake counters. Monotonic.
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub struct LiveWakeStats {
61 /// Wakes of either kind that reached the window, each one counted:
62 /// asked winit for a redraw, or posted to the event loop, where a burst
63 /// is one post.
64 pub wakes: u64,
65 /// Draw wakes dropped because the window was hidden. One redraw is
66 /// requested when it is shown again.
67 pub dropped_hidden: u64,
68 /// Whether the window is hidden, as teksilo-app last said.
69 pub hidden: bool,
70}
71
72/// See the [module docs](self).
73pub(crate) struct WindowWakeTarget {
74 /// A fast path for the check `slot` makes authoritative.
75 connected: AtomicBool,
76 /// The window to ask for a redraw; emptied only by `disconnect`. Held
77 /// across the request, which is what makes "no call after disconnect"
78 /// exact. A leaf: nothing else is locked while it is held.
79 slot: Mutex<Option<Arc<dyn RequestRedraw>>>,
80 /// The thread that created the window, which winit requires to be the
81 /// event loop's.
82 main_thread: ThreadId,
83 state_route: OnceLock<WakeRoute>,
84 off_main_route: OnceLock<WakeRoute>,
85 gate: WakeGate,
86 /// A state wake was posted and the event loop has not taken it yet.
87 state_posted: WakeFlag,
88 /// An off-main draw wake was posted and the event loop has not taken it.
89 draw_posted: WakeFlag,
90 wakes: AtomicU64,
91 dropped_hidden: AtomicU64,
92 /// Itself, for the request a macOS draw wake made off the main thread
93 /// hands to the main queue when no off-main route is installed.
94 #[cfg(target_os = "macos")]
95 this: std::sync::Weak<Self>,
96}
97
98impl WindowWakeTarget {
99 /// A connected target for `window`, created on the window's thread.
100 pub(crate) fn new(window: Arc<dyn RequestRedraw>) -> Arc<Self> {
101 Arc::new_cyclic(|_this| Self {
102 connected: AtomicBool::new(true),
103 slot: Mutex::new(Some(window)),
104 main_thread: std::thread::current().id(),
105 state_route: OnceLock::new(),
106 off_main_route: OnceLock::new(),
107 gate: WakeGate::new(),
108 state_posted: WakeFlag::new(),
109 draw_posted: WakeFlag::new(),
110 wakes: AtomicU64::new(0),
111 dropped_hidden: AtomicU64::new(0),
112 #[cfg(target_os = "macos")]
113 this: _this.clone(),
114 })
115 }
116
117 /// Install the route state wakes post through. The first call wins.
118 pub(crate) fn set_state_route(&self, route: WakeRoute) {
119 let _ = self.state_route.set(route);
120 }
121
122 /// Install the route draw wakes made off the main thread post through.
123 /// The first call wins.
124 pub(crate) fn set_off_main_route(&self, route: WakeRoute) {
125 let _ = self.off_main_route.set(route);
126 }
127
128 /// A draw wake: dropped while hidden, merged into a pending redraw.
129 pub(crate) fn wake_draw(&self) {
130 if !self.connected.load(Ordering::Acquire) {
131 return;
132 }
133 if !self.gate.pass() {
134 self.dropped_hidden.fetch_add(1, Ordering::Relaxed);
135 return;
136 }
137 self.wakes.fetch_add(1, Ordering::Relaxed);
138 if std::thread::current().id() != self.main_thread {
139 if let Some(route) = self.off_main_route.get() {
140 if self.draw_posted.raise() {
141 route();
142 }
143 return;
144 }
145 #[cfg(target_os = "macos")]
146 {
147 self.request_on_main_queue();
148 return;
149 }
150 }
151 self.request_on_slot();
152 }
153
154 /// macOS, off the main thread, with no off-main route: hand the request
155 /// to the main dispatch queue and return, once per burst. It runs on the
156 /// main thread, where asking winit waits for nobody, and after a
157 /// `disconnect` (also made there) it finds the slot empty.
158 #[cfg(target_os = "macos")]
159 fn request_on_main_queue(&self) {
160 if !self.draw_posted.raise() {
161 return;
162 }
163 let this = self.this.clone();
164 dispatch2::DispatchQueue::main().exec_async(move || {
165 if let Some(target) = this.upgrade() {
166 target.draw_posted.take();
167 target.request_on_slot();
168 }
169 });
170 }
171
172 /// A state wake: never dropped, posted once per burst when a route is
173 /// installed, a redraw request otherwise.
174 pub(crate) fn wake_state(&self) {
175 if !self.connected.load(Ordering::Acquire) {
176 return;
177 }
178 self.wakes.fetch_add(1, Ordering::Relaxed);
179 match self.state_route.get() {
180 Some(route) => {
181 if self.state_posted.raise() {
182 route();
183 }
184 }
185 None => self.request_on_slot(),
186 }
187 }
188
189 /// Event loop, on receipt of a posted wake of `kind`, *before* acting on
190 /// it: the next wake of that kind posts again. Returns whether one was
191 /// pending.
192 pub(crate) fn take_posted(&self, kind: WakeKind) -> bool {
193 match kind {
194 WakeKind::Draw => self.draw_posted.take(),
195 _ => self.state_posted.take(),
196 }
197 }
198
199 /// Event loop: `true` while hidden, in which case the redraw the caller
200 /// wanted is owed when the window is shown again.
201 pub(crate) fn defer_until_shown(&self) -> bool {
202 if self.gate.pass() {
203 false
204 } else {
205 self.dropped_hidden.fetch_add(1, Ordering::Relaxed);
206 true
207 }
208 }
209
210 /// Event loop: record whether the window is hidden. Showing it requests
211 /// the one redraw owed for the draw wakes dropped meanwhile, if any.
212 pub(crate) fn set_hidden(&self, hidden: bool) {
213 if hidden {
214 self.gate.hide();
215 } else if self.gate.show() && self.connected.load(Ordering::Acquire) {
216 self.wakes.fetch_add(1, Ordering::Relaxed);
217 self.request_on_slot();
218 }
219 }
220
221 /// Event loop: the window draws again and a frame is about to run, which
222 /// serves the draw wakes dropped while it was hidden. Record it shown,
223 /// without the redraw [`set_hidden`](Self::set_hidden) would deliver for
224 /// them.
225 pub(crate) fn show_for_drawing(&self) {
226 let _ = self.gate.show();
227 }
228
229 pub(crate) fn is_hidden(&self) -> bool {
230 self.gate.is_hidden()
231 }
232
233 /// Never call into winit again. Waits for a request already in progress;
234 /// idempotent. The window reference is dropped on the calling thread,
235 /// after the lock is released.
236 pub(crate) fn disconnect(&self) {
237 self.connected.store(false, Ordering::Release);
238 let window = self
239 .slot
240 .lock()
241 .unwrap_or_else(std::sync::PoisonError::into_inner)
242 .take();
243 drop(window);
244 }
245
246 /// Ask winit for a redraw, unless disconnected.
247 pub(crate) fn request_on_slot(&self) {
248 let slot = self
249 .slot
250 .lock()
251 .unwrap_or_else(std::sync::PoisonError::into_inner);
252 if let Some(window) = slot.as_ref() {
253 window.request_redraw();
254 }
255 }
256
257 pub(crate) fn stats(&self) -> LiveWakeStats {
258 LiveWakeStats {
259 wakes: self.wakes.load(Ordering::Relaxed),
260 dropped_hidden: self.dropped_hidden.load(Ordering::Relaxed),
261 hidden: self.gate.is_hidden(),
262 }
263 }
264
265 /// Hold the slot's lock, as an in-progress request would.
266 #[cfg(all(test, not(teksilo_loom)))]
267 fn hold_slot(&self) -> impl Drop + '_ {
268 self.slot
269 .lock()
270 .unwrap_or_else(std::sync::PoisonError::into_inner)
271 }
272}
273
274impl RedrawWaker for WindowWakeTarget {
275 fn wake(&self, kind: WakeKind) {
276 match kind {
277 WakeKind::Draw => self.wake_draw(),
278 // Every other kind keeps state current, and is never dropped.
279 _ => self.wake_state(),
280 }
281 }
282
283 fn is_hidden(&self) -> bool {
284 self.gate.is_hidden()
285 }
286}
287
288impl std::fmt::Debug for WindowWakeTarget {
289 /// Atomics only: never takes the slot's lock.
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 f.debug_struct("WindowWakeTarget")
292 .field("connected", &self.connected.load(Ordering::Relaxed))
293 .field("gate", &self.gate)
294 .field("stats", &self.stats())
295 .finish_non_exhaustive()
296 }
297}
298
299#[cfg(all(test, not(teksilo_loom)))]
300mod tests;
301
302#[cfg(all(test, teksilo_loom))]
303mod loom_tests;