truce_gpu/pump.rs
1//! Surface pump: owns the wgpu surface and every swapchain call, on a
2//! dedicated thread on Windows and inline elsewhere.
3//!
4//! On Windows the editor frame loop runs on the host's GUI thread, and
5//! any wgpu call that enters the graphics driver can park that thread
6//! for an unbounded time - a stalled AMD driver was measured blocking
7//! device creation, swapchain reconfigure (`DestroyAllocation2`), and
8//! acquire in kernel calls that never return, freezing the DAW. The
9//! threaded pump moves all of those off the GUI thread:
10//!
11//! - **init**: instance / surface / adapter / device creation runs on
12//! the pump thread; the GUI adopts the product when it lands.
13//! - **configure**: resizes are queued latest-wins and applied there.
14//! - **acquire**: the pump pre-acquires the next frame, so the GUI
15//! thread picks up an already-acquired texture or skips the paint.
16//! - **present**: painted frames are handed back and presented there.
17//!
18//! The GUI thread's only remaining GPU work is encoding + submitting
19//! into a pre-acquired texture - queue submission was never observed
20//! blocking in any captured hang.
21//!
22//! On macOS / Linux the same [`PumpClient`] API runs everything
23//! synchronously on the calling thread (their drivers don't exhibit
24//! the unbounded blocking, and macOS ties layer updates to the main
25//! thread), so editors share one code path across platforms.
26
27#[cfg(target_os = "windows")]
28use std::sync::Condvar;
29use std::sync::atomic::AtomicBool;
30#[cfg(target_os = "windows")]
31use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
32use std::sync::{Arc, Mutex, PoisonError};
33
34/// What the per-backend init closure returns: the GUI-side product
35/// (device handles, pipelines, ...) plus the device + configuration
36/// the pump needs for `surface.configure`.
37pub type PumpInit<T> = (T, wgpu::Device, wgpu::SurfaceConfiguration);
38
39/// Per-backend GPU init, run once the instance / adapter / surface
40/// exist (on the pump thread on Windows, inline elsewhere). Returns
41/// `None` on failure (editor stays blank, host survives). Must NOT
42/// configure the surface - the pump does that with the returned
43/// configuration.
44pub type PumpInitFn<T> = Box<
45 dyn FnOnce(&wgpu::Instance, &wgpu::Adapter, &wgpu::Surface<'static>) -> Option<PumpInit<T>>
46 + Send,
47>;
48
49// --- Windows: threaded pump ---
50
51/// GPU init still running on the pump thread.
52#[cfg(target_os = "windows")]
53const STATE_INIT: u8 = 0;
54/// Init finished; the pump is serving frames.
55#[cfg(target_os = "windows")]
56const STATE_READY: u8 = 1;
57/// Init failed or the pump thread died; the editor stays blank until
58/// a device-loss recovery spawns a fresh pump.
59#[cfg(target_os = "windows")]
60const STATE_FAILED: u8 = 2;
61
62/// Latest-wins mailbox between the GUI thread and the pump thread.
63// The bools are independent protocol flags (want / taken / shutdown /
64// exited), not a state machine in disguise; an enum would obscure
65// which combinations are legal.
66#[allow(clippy::struct_excessive_bools)]
67#[cfg(target_os = "windows")]
68#[derive(Default)]
69struct Slot {
70 /// Physical size to reconfigure the surface to.
71 resize: Option<(u32, u32)>,
72 /// Pre-acquired frame waiting for the GUI thread.
73 held: Option<wgpu::SurfaceTexture>,
74 /// Whether the GUI thread wants a frame pre-acquired.
75 want_frame: bool,
76 /// Painted frame waiting to be presented.
77 present: Option<wgpu::SurfaceTexture>,
78 /// A frame is out with the GUI thread (taken, not yet presented
79 /// or discarded). wgpu allows only ONE outstanding acquired
80 /// texture per surface, so the pump must not acquire while set.
81 taken: bool,
82 shutdown: bool,
83 /// Set by the pump thread on its way out; `Drop` waits on this
84 /// (bounded) to decide between join and detach.
85 exited: bool,
86}
87
88#[cfg(target_os = "windows")]
89struct Shared {
90 slot: Mutex<Slot>,
91 cv: Condvar,
92 state: AtomicU8,
93 /// How long the pump's most recent acquire blocked, in
94 /// nanoseconds. Feeds the GUI thread's `PaintPacer` so paint
95 /// cadence still tracks the compositor's real consumption rate.
96 last_acquire_nanos: AtomicU64,
97}
98
99#[cfg(target_os = "windows")]
100fn lock(slot: &Mutex<Slot>) -> std::sync::MutexGuard<'_, Slot> {
101 // Pump-thread work is wrapped in `catch_unwind`, so poisoning is
102 // unexpected; recover rather than deadlock editor teardown.
103 slot.lock().unwrap_or_else(PoisonError::into_inner)
104}
105
106// --- macOS / Linux: inline pump ---
107
108/// Synchronous surface owner for the platforms where swapchain calls
109/// stay on the calling thread. `Mutex` only for `Send`/`Clone`; there
110/// is no cross-thread contention.
111#[cfg(not(target_os = "windows"))]
112struct InlineState {
113 surface: wgpu::Surface<'static>,
114 device: wgpu::Device,
115 config: wgpu::SurfaceConfiguration,
116 last_acquire: std::time::Duration,
117}
118
119#[cfg(not(target_os = "windows"))]
120fn lock_inline(state: &Mutex<InlineState>) -> std::sync::MutexGuard<'_, InlineState> {
121 state.lock().unwrap_or_else(PoisonError::into_inner)
122}
123
124/// Cheap cloneable handle for per-frame pump operations: resize,
125/// frame acquire, present. The editor's backend keeps one; the
126/// [`SurfacePump`] owner keeps the lifecycle.
127#[derive(Clone)]
128pub struct PumpClient {
129 #[cfg(target_os = "windows")]
130 shared: Arc<Shared>,
131 #[cfg(not(target_os = "windows"))]
132 state: Arc<Mutex<InlineState>>,
133}
134
135impl PumpClient {
136 /// Reconfigure the surface (physical pixels). On Windows this is
137 /// queued latest-wins - a resize burst costs one configure once
138 /// the pump gets to it, and any pre-acquired frame at the old
139 /// size is discarded. Elsewhere it configures inline.
140 pub fn resize(&self, phys_w: u32, phys_h: u32) {
141 #[cfg(target_os = "windows")]
142 {
143 let mut slot = lock(&self.shared.slot);
144 slot.resize = Some((phys_w, phys_h));
145 drop(slot);
146 self.shared.cv.notify_all();
147 }
148 #[cfg(not(target_os = "windows"))]
149 {
150 let mut state = lock_inline(&self.state);
151 state.config.width = phys_w.max(1);
152 state.config.height = phys_h.max(1);
153 let InlineState {
154 surface,
155 device,
156 config,
157 ..
158 } = &mut *state;
159 surface.configure(device, config);
160 }
161 }
162
163 /// Get a frame to paint into. `None` means skip this paint and
164 /// keep the dirty state - retry on a later tick. Callers should
165 /// verify the texture's size still matches their target and
166 /// discard (drop) it if a resize raced in.
167 ///
168 /// Windows: takes the pre-acquired frame if ready, arming the
169 /// pump to acquire the next one either way - never blocks.
170 /// Elsewhere: acquires inline (recovering from a stale surface by
171 /// reconfiguring), exactly as the editors did before the pump.
172 #[must_use]
173 pub fn try_take_frame(&self) -> Option<wgpu::SurfaceTexture> {
174 #[cfg(target_os = "windows")]
175 {
176 let mut slot = lock(&self.shared.slot);
177 slot.want_frame = true;
178 let frame = slot.held.take();
179 if frame.is_some() {
180 slot.taken = true;
181 }
182 drop(slot);
183 self.shared.cv.notify_all();
184 frame
185 }
186 #[cfg(not(target_os = "windows"))]
187 {
188 let mut state = lock_inline(&self.state);
189 let acquire_start = std::time::Instant::now();
190 let mut acquired = None;
191 // `Outdated` / `Lost` / `Validation` persist until a
192 // reconfigure (even same-size clears the flag); `Timeout`
193 // / `Occluded` are transient, skip the frame.
194 for _ in 0..2 {
195 match state.surface.get_current_texture() {
196 wgpu::CurrentSurfaceTexture::Success(frame)
197 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
198 acquired = Some(frame);
199 break;
200 }
201 wgpu::CurrentSurfaceTexture::Outdated
202 | wgpu::CurrentSurfaceTexture::Lost
203 | wgpu::CurrentSurfaceTexture::Validation => {
204 let InlineState {
205 surface,
206 device,
207 config,
208 ..
209 } = &mut *state;
210 surface.configure(device, config);
211 }
212 wgpu::CurrentSurfaceTexture::Timeout
213 | wgpu::CurrentSurfaceTexture::Occluded => break,
214 }
215 }
216 state.last_acquire = acquire_start.elapsed();
217 acquired
218 }
219 }
220
221 /// Present a painted frame (handed back to the pump thread on
222 /// Windows, presented inline elsewhere).
223 pub fn present(&self, frame: wgpu::SurfaceTexture) {
224 #[cfg(target_os = "windows")]
225 {
226 let mut slot = lock(&self.shared.slot);
227 // At most one frame is ever out with the GUI thread, so a
228 // pending unpresented frame here is impossible; replace-
229 // drop is just belt-and-braces.
230 slot.present = Some(frame);
231 slot.taken = false;
232 drop(slot);
233 self.shared.cv.notify_all();
234 }
235 #[cfg(not(target_os = "windows"))]
236 {
237 frame.present();
238 }
239 }
240
241 /// Release a taken frame that won't be painted (stale size after
242 /// a raced resize, or an aborted paint), so the pump may acquire
243 /// again - wgpu allows only one outstanding acquired texture per
244 /// surface. On Windows the frame is presented unrendered rather
245 /// than dropped: DX12 replenishes the frame-latency waitable only
246 /// on present, and a dropped acquire burns a slot until every
247 /// acquire blocks wgpu's full 1 s timeout. The recycled old-frame
248 /// content matches what the compositor is already showing
249 /// mid-churn.
250 pub fn discard(&self, frame: wgpu::SurfaceTexture) {
251 #[cfg(target_os = "windows")]
252 self.present(frame);
253 #[cfg(not(target_os = "windows"))]
254 drop(frame);
255 }
256
257 /// Whether GPU init has completed and frames can be served. Always
258 /// true on the inline platforms (init ran synchronously in
259 /// `spawn`).
260 #[must_use]
261 pub fn is_ready(&self) -> bool {
262 #[cfg(target_os = "windows")]
263 {
264 self.shared.state.load(Ordering::Acquire) == STATE_READY
265 }
266 #[cfg(not(target_os = "windows"))]
267 {
268 true
269 }
270 }
271
272 /// Whether init failed or the pump thread died.
273 #[must_use]
274 pub fn failed(&self) -> bool {
275 #[cfg(target_os = "windows")]
276 {
277 self.shared.state.load(Ordering::Acquire) == STATE_FAILED
278 }
279 #[cfg(not(target_os = "windows"))]
280 {
281 false
282 }
283 }
284
285 /// The most recent swapchain-acquire wait, for compositor pacing.
286 #[must_use]
287 pub fn last_acquire_wait(&self) -> std::time::Duration {
288 #[cfg(target_os = "windows")]
289 {
290 std::time::Duration::from_nanos(self.shared.last_acquire_nanos.load(Ordering::Relaxed))
291 }
292 #[cfg(not(target_os = "windows"))]
293 {
294 lock_inline(&self.state).last_acquire
295 }
296 }
297}
298
299/// Owning handle for the pump. On Windows, dropping it shuts the
300/// thread down (bounded join, then detach - a thread wedged in a
301/// driver call is left behind rather than hanging the GUI thread).
302pub struct SurfacePump<T: Send + 'static> {
303 client: PumpClient,
304 init: InitDelivery<T>,
305 #[cfg(target_os = "windows")]
306 join: Option<std::thread::JoinHandle<()>>,
307}
308
309enum InitDelivery<T> {
310 /// Inline init: the product is available immediately.
311 #[cfg_attr(target_os = "windows", allow(dead_code))]
312 Now(Option<T>),
313 /// Threaded init: the product arrives when the pump thread
314 /// finishes.
315 #[cfg(target_os = "windows")]
316 Chan(std::sync::mpsc::Receiver<T>),
317}
318
319impl<T: Send + 'static> SurfacePump<T> {
320 /// Build the pump for a baseview window. On Windows this spawns
321 /// the pump thread and returns immediately (GPU init runs there;
322 /// poll [`Self::take_init`]); elsewhere init runs synchronously
323 /// and `take_init` succeeds on the first call.
324 ///
325 /// `device_lost` is raised if the pump thread panics so the
326 /// editor's device-loss recovery can rebuild with a fresh pump.
327 ///
328 /// # Safety
329 /// The window must remain valid while the pump lives - the editor
330 /// guarantees this by dropping the pump before closing its child
331 /// window (a destroyed HWND fails surface creation with a driver
332 /// error, not UB).
333 #[cfg(not(target_os = "ios"))]
334 pub unsafe fn spawn(
335 window: &baseview::Window,
336 device_lost: &Arc<AtomicBool>,
337 init: PumpInitFn<T>,
338 ) -> Option<Self> {
339 #[cfg(target_os = "windows")]
340 {
341 use raw_window_handle::{HasRawWindowHandle, RawWindowHandle};
342 let RawWindowHandle::Win32(handle) = window.raw_window_handle() else {
343 return None;
344 };
345 let hwnd = handle.hwnd as isize;
346 if hwnd == 0 {
347 return None;
348 }
349 Self::spawn_threaded(hwnd, device_lost.clone(), init)
350 }
351 #[cfg(not(target_os = "windows"))]
352 {
353 let _ = device_lost;
354 let instance = wgpu::Instance::new(super::platform::editor_instance_descriptor());
355 let surface = unsafe { super::platform::create_wgpu_surface(&instance, window) }?;
356 let adapter =
357 pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
358 power_preference: wgpu::PowerPreference::HighPerformance,
359 compatible_surface: Some(&surface),
360 force_fallback_adapter: false,
361 }))
362 .ok()?;
363 let (product, device, config) = init(&instance, &adapter, &surface)?;
364 surface.configure(&device, &config);
365 Some(Self {
366 client: PumpClient {
367 state: Arc::new(Mutex::new(InlineState {
368 surface,
369 device,
370 config,
371 last_acquire: std::time::Duration::ZERO,
372 })),
373 },
374 init: InitDelivery::Now(Some(product)),
375 })
376 }
377 }
378
379 #[cfg(target_os = "windows")]
380 fn spawn_threaded(
381 hwnd: isize,
382 device_lost: Arc<AtomicBool>,
383 init: PumpInitFn<T>,
384 ) -> Option<Self> {
385 let shared = Arc::new(Shared {
386 slot: Mutex::new(Slot::default()),
387 cv: Condvar::new(),
388 state: AtomicU8::new(STATE_INIT),
389 last_acquire_nanos: AtomicU64::new(0),
390 });
391 let (init_tx, init_rx) = std::sync::mpsc::channel();
392 let thread_shared = shared.clone();
393 let spawned = std::thread::Builder::new()
394 .name("truce-surface-pump".into())
395 .spawn(move || run(&thread_shared, hwnd, &device_lost, init, &init_tx));
396 match spawned {
397 Ok(join) => Some(Self {
398 client: PumpClient { shared },
399 init: InitDelivery::Chan(init_rx),
400 join: Some(join),
401 }),
402 Err(e) => {
403 log::error!("surface pump: failed to spawn: {e}");
404 None
405 }
406 }
407 }
408
409 /// Poll for the init closure's product (non-blocking). Returns it
410 /// exactly once.
411 pub fn take_init(&mut self) -> Option<T> {
412 match &mut self.init {
413 InitDelivery::Now(product) => product.take(),
414 #[cfg(target_os = "windows")]
415 InitDelivery::Chan(rx) => rx.try_recv().ok(),
416 }
417 }
418
419 #[must_use]
420 pub fn client(&self) -> PumpClient {
421 self.client.clone()
422 }
423}
424
425#[cfg(target_os = "windows")]
426impl<T: Send + 'static> Drop for SurfacePump<T> {
427 fn drop(&mut self) {
428 let mut slot = lock(&self.client.shared.slot);
429 slot.shutdown = true;
430 self.client.shared.cv.notify_all();
431 // Bounded wait; a thread wedged inside the driver can't notice
432 // the flag, so detach instead of hanging the GUI thread.
433 let (slot, timeout) = self
434 .client
435 .shared
436 .cv
437 .wait_timeout_while(slot, std::time::Duration::from_secs(1), |s| !s.exited)
438 .unwrap_or_else(PoisonError::into_inner);
439 drop(slot);
440 if timeout.timed_out() {
441 log::warn!("surface pump did not exit within 1s (driver stall?); detaching");
442 drop(self.join.take());
443 } else if let Some(join) = self.join.take() {
444 let _ = join.join();
445 }
446 }
447}
448
449/// Create a wgpu surface for a raw Win32 HWND. `Send`-able input, so
450/// the pump thread can build its own surface.
451///
452/// # Safety
453/// `hwnd` must be a valid window handle that outlives the surface.
454#[cfg(target_os = "windows")]
455unsafe fn surface_from_hwnd(
456 instance: &wgpu::Instance,
457 hwnd: isize,
458) -> Option<wgpu::Surface<'static>> {
459 let mut win32 = wgpu::rwh::Win32WindowHandle::new(std::num::NonZeroIsize::new(hwnd)?);
460 win32.hinstance = super::platform::current_module_hinstance();
461 let target = wgpu::SurfaceTargetUnsafe::RawHandle {
462 raw_display_handle: Some(wgpu::rwh::RawDisplayHandle::Windows(
463 wgpu::rwh::WindowsDisplayHandle::new(),
464 )),
465 raw_window_handle: wgpu::rwh::RawWindowHandle::Win32(win32),
466 };
467 unsafe { instance.create_surface_unsafe(target) }.ok()
468}
469
470/// Pump thread body: init, then serve resize / acquire / present
471/// until shutdown.
472#[cfg(target_os = "windows")]
473fn run<T: Send>(
474 shared: &Shared,
475 hwnd: isize,
476 device_lost: &Arc<AtomicBool>,
477 init: PumpInitFn<T>,
478 init_tx: &std::sync::mpsc::Sender<T>,
479) {
480 let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
481 let instance = wgpu::Instance::new(super::platform::editor_instance_descriptor());
482 // SAFETY: the hwnd outlives the pump - see `SurfacePump::spawn`.
483 let surface = unsafe { surface_from_hwnd(&instance, hwnd) }?;
484 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
485 power_preference: wgpu::PowerPreference::HighPerformance,
486 compatible_surface: Some(&surface),
487 force_fallback_adapter: false,
488 }))
489 .ok()?;
490 let (product, device, config) = init(&instance, &adapter, &surface)?;
491 surface.configure(&device, &config);
492 Some((product, device, config, surface))
493 }))
494 .ok()
495 .flatten();
496 let Some((product, device, mut config, surface)) = built else {
497 shared.state.store(STATE_FAILED, Ordering::Release);
498 log::error!("surface pump: gpu init failed; editor stays blank");
499 mark_exited(shared);
500 return;
501 };
502 // Deliver before flipping state so a GUI that sees READY always
503 // finds the product waiting.
504 let _ = init_tx.send(product);
505 shared.state.store(STATE_READY, Ordering::Release);
506
507 'work: loop {
508 let (resize, present, need_acquire) = {
509 let mut slot = lock(&shared.slot);
510 loop {
511 if slot.shutdown {
512 break 'work;
513 }
514 let need_acquire = slot.want_frame && slot.held.is_none() && !slot.taken;
515 // A resize can't be applied while a frame is out with
516 // the GUI thread: `surface.configure` panics if any
517 // acquired texture is still alive. The GUI's present /
518 // discard clears `taken` and notifies.
519 let can_resize = slot.resize.is_some() && !slot.taken;
520 if can_resize || slot.present.is_some() || need_acquire {
521 break;
522 }
523 slot = shared.cv.wait(slot).unwrap_or_else(PoisonError::into_inner);
524 }
525 let need_acquire = slot.want_frame && slot.held.is_none() && !slot.taken;
526 let resize = if slot.taken { None } else { slot.resize.take() };
527 (resize, slot.present.take(), need_acquire)
528 };
529 // Everything below can block inside the driver - that is the
530 // point of this thread. A panic flags device loss so the
531 // editor rebuilds with a fresh pump.
532 let ok = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
533 if let Some(frame) = present {
534 frame.present();
535 }
536 if let Some((w, h)) = resize {
537 // A frame acquired under the old configuration can't
538 // be painted meaningfully - but it must be PRESENTED,
539 // not dropped: DX12 replenishes the swapchain's
540 // frame-latency waitable only on present, so a dropped
541 // acquire burns a latency slot and once starved every
542 // subsequent acquire blocks wgpu's full 1 s timeout
543 // (measured; it was the multi-second post-resize
544 // stall). Its recycled old-frame content matches the
545 // stretched frame the compositor is showing anyway.
546 if let Some(stale) = lock(&shared.slot).held.take() {
547 stale.present();
548 }
549 config.width = w.max(1);
550 config.height = h.max(1);
551 surface.configure(&device, &config);
552 }
553 // A drag queues resizes faster than configure + acquire
554 // can run; if another one is already waiting, coalesce it
555 // first - a frame acquired now would only be discarded.
556 if need_acquire && lock(&shared.slot).resize.is_none() {
557 let acquire_start = std::time::Instant::now();
558 let mut acquired = None;
559 // Same stale-surface recovery as the inline path.
560 for _ in 0..2 {
561 match surface.get_current_texture() {
562 wgpu::CurrentSurfaceTexture::Success(frame)
563 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
564 acquired = Some(frame);
565 break;
566 }
567 wgpu::CurrentSurfaceTexture::Outdated
568 | wgpu::CurrentSurfaceTexture::Lost
569 | wgpu::CurrentSurfaceTexture::Validation => {
570 surface.configure(&device, &config);
571 }
572 wgpu::CurrentSurfaceTexture::Timeout
573 | wgpu::CurrentSurfaceTexture::Occluded => break,
574 }
575 }
576 let nanos = u64::try_from(acquire_start.elapsed().as_nanos()).unwrap_or(u64::MAX);
577 shared.last_acquire_nanos.store(nanos, Ordering::Relaxed);
578 if let Some(frame) = acquired {
579 let mut slot = lock(&shared.slot);
580 // A resize that raced in invalidates this frame;
581 // present it (see the resize branch - dropping
582 // burns a frame-latency slot) and let the next
583 // loop pass reconfigure + reacquire.
584 if slot.resize.is_none() {
585 slot.held = Some(frame);
586 } else {
587 drop(slot);
588 frame.present();
589 }
590 }
591 }
592 }));
593 if ok.is_err() {
594 device_lost.store(true, Ordering::Release);
595 shared.state.store(STATE_FAILED, Ordering::Release);
596 log::error!("surface pump panicked; flagging device loss for rebuild");
597 break;
598 }
599 }
600 mark_exited(shared);
601}
602
603#[cfg(target_os = "windows")]
604fn mark_exited(shared: &Shared) {
605 let mut slot = lock(&shared.slot);
606 // Frames can't outlive the surface; drop any still queued. The
607 // drop itself can panic (wgpu discards the texture against a
608 // surface whose configure already failed); swallow it so teardown
609 // always completes.
610 let held = slot.held.take();
611 let present = slot.present.take();
612 slot.exited = true;
613 drop(slot);
614 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
615 drop(held);
616 drop(present);
617 }));
618 shared.cv.notify_all();
619}