Skip to content

Commit 378aaef

Browse files
committed
fix(studio): surface a render panic instead of caching an empty frame
render_frame panics on failure (.expect("render frame"), .expect("rgba matches dimensions"), .expect("encode jpeg")). Because the panic happens in the scoped child thread and is consumed by join().unwrap_or_default(), the catch_unwind wrapper in view.rs::serve_or_render never fires — so fail_ledger().record_failure(key) is unreachable on that path and the whole retry-budget mechanism is dead code for the on-demand asset handler. Instead the empty Vec is treated as a successful render: frame_cache().insert(key, rendered.clone(), ...) caches zero bytes, and the responder replies 200 image/jpeg with an empty body. The canvas shows a permanently broken image for that (generation, frame, scale) and the cache guarantees it is never re- attempted. (The prefetch worker path calls render_frame directly and is correctly fenced.) Fix: Return Result from render_frame_deep (propagate join()'s Err instead of unwrap_or_default), or at minimum refuse to cache/serve an empty byte vector and record it in the fail ledger so serve_or_render returns 500 and the webview keeps the previous frame. Refs #220
1 parent 64683cc commit 378aaef

5 files changed

Lines changed: 82 additions & 24 deletions

File tree

‎crates/rustmotion-studio/src/editor/frames.rs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,25 @@ pub const RENDER_STACK: usize = 32 * 1024 * 1024;
2626

2727
/// [`render_frame`] on a thread with [`RENDER_STACK`], for callers that are not
2828
/// on the main thread. Scoped, so the scenario and tasks are borrowed rather
29-
/// than cloned.
29+
/// than cloned. `Err` means the render thread panicked (a Skia panic, a
30+
/// dimension mismatch, a JPEG encode failure): the caller must not treat that
31+
/// as a successful empty frame.
32+
// The panic payload carries no information callers act on; they only branch
33+
// on success vs. failure (retry-budget ledger, cache, HTTP status).
34+
#[allow(clippy::result_unit_err)]
3035
pub fn render_frame_deep(
3136
scenario: &ResolvedScenario,
3237
tasks: &[rustmotion::encode::video::FrameTask],
3338
frame: u32,
3439
scale: f32,
35-
) -> Vec<u8> {
40+
) -> Result<Vec<u8>, ()> {
3641
std::thread::scope(|scope| {
3742
std::thread::Builder::new()
3843
.stack_size(RENDER_STACK)
3944
.spawn_scoped(scope, || render_frame(scenario, tasks, frame, scale))
4045
.expect("spawn render thread")
4146
.join()
42-
.unwrap_or_default()
47+
.map_err(|_| ())
4348
})
4449
}
4550

‎crates/rustmotion-studio/src/editor/view.rs‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,10 @@ pub fn StudioApp(view: Signal<View>) -> Element {
361361
/// into the cache for the next request. `gen_b` is `Some(model generation)`
362362
/// when serving side B (drives stale-generation eviction); side A passes the
363363
/// baseline hash inside `key.generation` and `None` here.
364+
///
365+
/// `render_frame_deep` already isolates a render panic inside its own scoped
366+
/// thread and reports it as `Err`; the `catch_unwind` here only guards the
367+
/// (rarer) case of the OS failing to spawn that thread.
364368
fn serve_or_render(
365369
key: FrameKey,
366370
idx: u32,
@@ -385,15 +389,19 @@ fn serve_or_render(
385389
{
386390
return Err(());
387391
}
388-
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
392+
let outer = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
389393
render_frame_deep(scenario, tasks, idx, scale_factor(key.scale_pct))
390-
}))
391-
.map_err(|_| {
392-
fail_ledger()
393-
.lock()
394-
.unwrap_or_else(|e| e.into_inner())
395-
.record_failure(key);
396-
})?;
394+
}));
395+
let rendered = match outer {
396+
Ok(Ok(bytes)) => bytes,
397+
_ => {
398+
fail_ledger()
399+
.lock()
400+
.unwrap_or_else(|e| e.into_inner())
401+
.record_failure(key);
402+
return Err(());
403+
}
404+
};
397405
let (gen_b, gen_a) = match key.side {
398406
DiffSide::B => (gen_b, None),
399407
DiffSide::A => (None, Some(key.generation)),

‎crates/rustmotion-studio/src/lib.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
mod app;
22
mod components;
3-
mod editor;
3+
pub mod editor;
44
mod library;
55
pub mod scenario;
66

‎crates/rustmotion-studio/src/library/data.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,7 @@ pub fn render_thumbnail(path: &Path) -> Option<Vec<u8>> {
132132
if tasks.is_empty() {
133133
return None;
134134
}
135-
Some(crate::editor::frames::render_frame_deep(
136-
&scenario, &tasks, 0, 0.25,
137-
))
135+
crate::editor::frames::render_frame_deep(&scenario, &tasks, 0, 0.25).ok()
138136
}
139137

140138
/// Cheap check: is this JSON a Rustmotion scenario? Avoids the heavier

‎crates/rustmotion-studio/tests/audit_ws_g.rs‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1-
//! Regression tests for the studio's file-write pipeline: a debounced write
1+
//! Regression tests for the studio's file-write pipeline — a debounced write
22
//! that must rebase onto the current disk instead of replaying a stale
33
//! in-memory snapshot, coalesced edits inside one debounce window that must
44
//! all survive (not just the last), and undo/redo cancelling a still-pending
5-
//! write before it can clobber the just-restored state.
5+
//! write before it can clobber the just-restored state — plus a render-thread
6+
//! panic that must surface as an error instead of being cached as an empty
7+
//! JPEG.
68
//!
79
//! `rustmotion-studio` has no `[dev-dependencies]` and cannot gain one in
810
//! this change, so every test below drives the crate's existing public
9-
//! surface (`scenario::*`) against real temp files with plain synchronous
10-
//! `#[test]`s — no Dioxus runtime, no async executor. That public surface is
11-
//! itself the fix for the crate having no integration tests: the defects
12-
//! lived in a debounce timer and Dioxus event handlers that cannot be driven
13-
//! from a test, so each one was reduced to a pure decision over plain data
14-
//! (`resolve_flush`, the pending-write queue) and the handler calls that
15-
//! instead of deciding inline.
11+
//! surface (`scenario::*`, `editor::frames`) against real temp files with
12+
//! plain synchronous `#[test]`s — no Dioxus runtime, no async executor. That
13+
//! public surface is itself the fix for the crate having no integration
14+
//! tests: the defects lived in a debounce timer and Dioxus event handlers
15+
//! that cannot be driven from a test, so each one was reduced to a pure
16+
//! decision over plain data (`resolve_flush`, the pending-write queue,
17+
//! `render_frame_deep` returning `Result`) and the handler calls that instead
18+
//! of deciding inline.
1619
1720
use std::fs;
1821
use std::path::PathBuf;
1922
use std::sync::{Arc, Mutex};
2023

24+
use rustmotion_studio::editor::frames::render_frame_deep;
2125
use rustmotion_studio::scenario::{
2226
apply_optimistic, empty_scenario, pending_write_slot, queue_mutation, record_edit,
2327
resolve_flush, take_pending, undo, Mutation, Shared, SharedHistory, StudioModel,
@@ -219,3 +223,46 @@ fn the_write_pipeline_round_trips_an_edit_through_a_real_file() {
219223
);
220224
let _ = fs::remove_file(&path);
221225
}
226+
227+
// ── A render-thread panic surfaces as an error, never a cached empty JPEG ──
228+
229+
#[test]
230+
fn a_render_thread_panic_is_reported_as_an_error_not_an_empty_jpeg() {
231+
let big = rustmotion::loader::load_scenario_from_source(
232+
None,
233+
Some(r##"{ "video": { "width": 64, "height": 64 }, "scenes": [ { "duration": 0.1 }, { "duration": 0.1 } ] }"##),
234+
)
235+
.unwrap();
236+
let tasks = rustmotion::encode::build_frame_tasks(&big);
237+
assert!(
238+
tasks.len() >= 2,
239+
"need frames spanning both scenes to reach scene_idx 1"
240+
);
241+
242+
// Deliberately mismatched: `tasks` reference a second scene this smaller
243+
// scenario does not have, which panics inside the render thread.
244+
let small = rustmotion::loader::load_scenario_from_source(
245+
None,
246+
Some(r##"{ "video": { "width": 64, "height": 64 }, "scenes": [ { "duration": 0.1 } ] }"##),
247+
)
248+
.unwrap();
249+
250+
let last_frame = (tasks.len() - 1) as u32;
251+
let result = render_frame_deep(&small, &tasks, last_frame, 1.0);
252+
assert!(
253+
result.is_err(),
254+
"a scenario/task mismatch panics inside the render thread and must surface as Err"
255+
);
256+
}
257+
258+
#[test]
259+
fn a_normal_render_returns_nonempty_jpeg_bytes() {
260+
let scenario = rustmotion::loader::load_scenario_from_source(
261+
None,
262+
Some(r##"{ "video": { "width": 64, "height": 64 }, "scenes": [ { "duration": 0.1 } ] }"##),
263+
)
264+
.unwrap();
265+
let tasks = rustmotion::encode::build_frame_tasks(&scenario);
266+
let jpeg = render_frame_deep(&scenario, &tasks, 0, 1.0).expect("a normal render succeeds");
267+
assert!(!jpeg.is_empty());
268+
}

0 commit comments

Comments
 (0)