Skip to main content

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}