Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/rustmotion-studio/src/editor/frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@ use std::sync::{Arc, Mutex, OnceLock};
use rustmotion::encode::video::FrameTask;
use rustmotion::schema::ResolvedScenario;

/// Stack for any thread that renders a frame.
///
/// The depth cost is **deserialisation**, not painting: `prepare_scene` calls
/// `deserialize_children` on every frame, and `ChildComponent` → `Component` →
/// `Container`/`Card` recurses once per level of nesting. Because those enums
/// are untagged, serde buffers each level through `ContentDeserializer`, so a
/// level is expensive in stack as well as deep — and a debug build's frames are
/// several times fatter than a release build's.
///
/// Measured: a scenario nested ~28 levels — which a UI composed from small
/// helper functions reaches without trying — needs between 2 and 4 MiB in debug.
/// Rust's default for a spawned thread is 2 MiB, so the prefetch workers
/// overflowed and aborted the process; the same file rendered fine from the CLI,
/// which happens to run on the 8 MiB main thread. The two webview asset handlers
/// get whatever stack the platform hands their callback, which is no better.
///
/// Address space is cheap and committed lazily; take a wide margin.
pub const RENDER_STACK: usize = 32 * 1024 * 1024;

/// [`render_frame`] on a thread with [`RENDER_STACK`], for callers that are not
/// on the main thread. Scoped, so the scenario and tasks are borrowed rather
/// than cloned.
pub fn render_frame_deep(
scenario: &ResolvedScenario,
tasks: &[rustmotion::encode::video::FrameTask],
frame: u32,
scale: f32,
) -> Vec<u8> {
std::thread::scope(|scope| {
std::thread::Builder::new()
.stack_size(RENDER_STACK)
.spawn_scoped(scope, || render_frame(scenario, tasks, frame, scale))
.expect("spawn render thread")
.join()
.unwrap_or_default()
})
}

/// Render one frame and encode it to JPEG bytes (preview-only; the final video
/// render path does not use this). JPEG keeps the encode cost low enough for
/// the webview transport to keep up with playback.
Expand Down
7 changes: 6 additions & 1 deletion crates/rustmotion-studio/src/editor/prefetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,12 @@ pub fn ensure_prefetcher() {
.map(|n| n.get())
.unwrap_or(4);
for _ in 0..worker_count(cores) {
std::thread::spawn(prefetch_loop);
// Explicit stack: these workers render, and the 2 MiB default is
// not enough for a deeply nested scenario in a debug build. Sized
// once here rather than per frame — see `frames::RENDER_STACK`.
let _ = std::thread::Builder::new()
.stack_size(crate::editor::frames::RENDER_STACK)
.spawn(prefetch_loop);
}
});
}
Expand Down
4 changes: 2 additions & 2 deletions crates/rustmotion-studio/src/editor/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::scenario::{
use super::annotations::AnnotationsPanel;
use super::diff_panel::{DiffPanel, DiffSide};
use super::export::ExportToast;
use super::frames::{baseline_arcs, frame_hits, render_frame, scene_prefix, HitPct};
use super::frames::{baseline_arcs, frame_hits, render_frame_deep, scene_prefix, HitPct};
use super::inspector::InspectorPanel;
use super::playback::{
playback_action, use_hot_reload, use_playback_clock, PlaybackAction, PlaybackBar,
Expand Down Expand Up @@ -386,7 +386,7 @@ fn serve_or_render(
return Err(());
}
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
render_frame(scenario, tasks, idx, scale_factor(key.scale_pct))
render_frame_deep(scenario, tasks, idx, scale_factor(key.scale_pct))
}))
.map_err(|_| {
fail_ledger()
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion-studio/src/library/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ pub fn render_thumbnail(path: &Path) -> Option<Vec<u8>> {
if tasks.is_empty() {
return None;
}
Some(crate::editor::frames::render_frame(
Some(crate::editor::frames::render_frame_deep(
&scenario, &tasks, 0, 0.25,
))
}
Expand Down
40 changes: 40 additions & 0 deletions crates/rustmotion/tests/deep_scenario_stack.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! A deeply nested scenario must render on the stacks the app actually uses.
//!
//! The CLI renders on the main thread (8 MB on macOS) and never noticed. The
//! studio renders thumbnails inside a webview asset-handler callback, which runs
//! on an OS-provided thread with a far smaller stack — so a scenario that is
//! merely *deep* aborts the whole process with a stack overflow there while
//! rendering fine from the terminal.
use std::path::PathBuf;

fn render_on(stack_kb: usize, path: PathBuf) -> bool {
std::thread::Builder::new()
.stack_size(stack_kb * 1024)
.spawn(move || {
let scenario = rustmotion::loader::load_input(&path).expect("load");
let tasks = rustmotion::encode::build_frame_tasks(&scenario);
rustmotion::encode::render_frame_task_scaled(
&scenario.video,
&scenario,
&tasks[0],
0.25,
)
.expect("render");
})
.unwrap()
.join()
.is_ok()
}

#[test]
#[ignore = "probe: needs PROBE_FILE=<scenario.json>"]
fn report_stack_needed() {
// One size per process: a stack overflow aborts, it cannot be caught, so a
// loop inside a single run would stop at the first failure.
let p = PathBuf::from(std::env::var("PROBE_FILE").expect("PROBE_FILE"));
let kb: usize = std::env::var("PROBE_STACK_KB").unwrap().parse().unwrap();
println!(
"stack {kb} KiB -> {}",
if render_on(kb, p) { "ok" } else { "panic" }
);
}
Loading