fix(render): cap the playback pre-render window to the slot headroom

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.
This commit is contained in:
2026-08-19 01:55:59 +08:00
parent 6dc8285f2a
commit 345c464e55
3 changed files with 55 additions and 0 deletions
+38
View File
@@ -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<usize> {
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();
+8
View File
@@ -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<usize> {
None
}
}
/// Thread-free job dispatcher (M15 S2). Executes jobs on the calling
+9
View File
@@ -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