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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user