From 345c464e55483a1ccf76865496443d73a1ab42d6 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 19 Aug 2026 01:55:59 +0800 Subject: [PATCH] fix(render): cap the playback pre-render window to the slot headroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing play froze the app: the 120-frame pre-render window could hold every shm slot in the pool (e.g. 8 workers x 3 F32 slots = 24 < 120). Once the wall-clock playhead outran the renders, the UI's synchronous frame wait had no credit to dispatch, and the slot-releasing cleanup runs on that same blocked UI thread — a hard deadlock. The window is now capped to (workers x slots - workers), reserving one slot per worker so interactive (seek/sync display) and audio tickets always dispatch. preview_window_capacity is exposed through JobDispatch; a unit test pins the reserve math. --- crates/oakrender/src/procpool.rs | 38 ++++++++++++++++++++++++++++++++ crates/oakrender/src/worker.rs | 8 +++++++ src/oakui/real.rs | 9 ++++++++ 3 files changed, 55 insertions(+) diff --git a/crates/oakrender/src/procpool.rs b/crates/oakrender/src/procpool.rs index 91ce58736..c3d388989 100644 --- a/crates/oakrender/src/procpool.rs +++ b/crates/oakrender/src/procpool.rs @@ -781,6 +781,20 @@ impl ProcessDispatcher { } } + /// Slot headroom for best-effort pre-render windows: the pool's total + /// slots minus one per worker, so interactive (seek / synchronous + /// display) and audio tickets always keep credit to dispatch. A + /// pre-render window larger than the pool exhausted every slot, which + /// deadlocked the UI's synchronous frame wait (the playback freeze). + pub fn preview_window_capacity(&self) -> usize { + let inner = lock(&self.inner); + let workers = inner.scheduler.workers(); + workers + .saturating_mul(inner.slots as usize) + .saturating_sub(workers) + .max(1) + } + /// The configured worker count. pub fn worker_count(&self) -> usize { lock(&self.inner).scheduler.workers() @@ -1575,6 +1589,12 @@ impl JobDispatch for ProcessDispatcher { self.poll(); } + /// The pre-render window's slot headroom (see the inherent + /// [`ProcessDispatcher::preview_window_capacity`]). + fn preview_window_capacity(&self) -> Option { + Some(self.preview_window_capacity()) + } + /// Release a consumed frame's slot (delegates to the inherent /// release — see [`ProcessDispatcher::release_frame`]). fn release_frame(&self, frame: &ShmFrameRef) { @@ -1785,6 +1805,24 @@ mod tests { assert!(n >= 1); } + #[test] + fn preview_window_capacity_reserves_one_slot_per_worker() { + // workers=3 × slots=4 → the window may hold 12-3=9 slots; the + // reserve keeps interactive/audio tickets dispatchable (the + // playback-freeze regression guard). + let config = DispatcherConfig { + worker_bin: Some(std::path::PathBuf::from("/bin/true")), + workers: 3, + slots_per_worker: 4, + width: 64, + height: 64, + batch_size: 2, + ..Default::default() + }; + let dispatcher = ProcessDispatcher::new(config).expect("dispatcher"); + assert_eq!(dispatcher.preview_window_capacity(), 9); + } + #[test] fn config_normalization_defaults() { let c = DispatcherConfig::default().normalize(); diff --git a/crates/oakrender/src/worker.rs b/crates/oakrender/src/worker.rs index 5124d6d8e..c461750af 100644 --- a/crates/oakrender/src/worker.rs +++ b/crates/oakrender/src/worker.rs @@ -147,6 +147,14 @@ pub trait JobDispatch: Send + Sync { /// preview-window invalidation); their completions fire with /// `Error::State`. Default no-op: only the process backend schedules. fn cancel_preview_sequence(&self, _sequence: u64) {} + + /// Slot headroom available to a best-effort pre-render window (total + /// pool slots minus one per worker), so interactive and audio tickets + /// always keep credit. Default `None`: backends without slots impose + /// no constraint. + fn preview_window_capacity(&self) -> Option { + None + } } /// Thread-free job dispatcher (M15 S2). Executes jobs on the calling diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 26a06f01c..c5c2aef07 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -1609,6 +1609,15 @@ impl RealEngine { let forward = config_get_int(CONFIG_KEY_PREVIEW_WINDOW, DEFAULT_PREVIEW_WINDOW_FORWARD) .clamp(8, 1200); + // Cap the window to the pool's slot headroom: a window that can + // hold every slot starves interactive (this frame's synchronous + // render) and audio tickets of credit, and since the slot-releasing + // cleanup runs on this same UI thread, a synchronous wait then + // deadlocks playback. Keep one slot per worker in reserve. + let forward = match m.dispatch.preview_window_capacity() { + Some(capacity) => forward.min(capacity as i64).max(1), + None => forward, + }; let end = length.max(playhead).min(playhead + forward); // Reset / rebuild when the node changed or an invalidation bumped the