From 414764ef77fd230d6ac54a59321bfcb36752790f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:25:12 +0000 Subject: [PATCH] Stop export lanes when a panic unwinds the batch thread Fixes #2132 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/export.rs | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index d294ed4d9..32c76fc75 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -2013,6 +2013,9 @@ fn run_batch_with( ahead: lanes, }; std::thread::scope(|sc| { + // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer + // would otherwise leave them waiting and the scope's join would never return + let _stop = StopOnDrop(&queue); for k in 1..lanes { let q = &queue; // a lane that can't start leaves its photos to the others (and to this thread) @@ -2030,13 +2033,20 @@ fn run_batch_with( } Ok::<_, String>(()) }; - let r = placed(); - queue.stop(); - r + placed() })?; Ok(std::mem::take(&mut placer.out)) } +/// Stops a [`Queue`] when dropped, also while unwinding from a panic. +struct StopOnDrop<'a>(&'a Queue); + +impl Drop for StopOnDrop<'_> { + fn drop(&mut self) { + self.0.stop(); + } +} + /// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic /// becomes that photo's error, not the batch's end. fn run_gated(item: PreparedExport) -> Result { @@ -3429,6 +3439,32 @@ mod tests { assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); } + // A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it + #[test] + fn a_panic_in_progress_does_not_hang_the_lanes() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); + let items = prepare_batch(&mut s, &ids, &o).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let to = Destination { dir: "out".into(), exact: None }; + let mut write = |_: &str, _: &[u8]| Ok(()); + let mut calls = 0; + let mut progress = |_: usize, _: &str| { + calls += 1; + assert!(calls < 2, "progress panics on its second call"); + true + }; + let r = crate::guard::catch("test batch", || { + run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) + }); + tx.send(r.and_then(|r| r)).ok(); + }); + let r = rx.recv_timeout(std::time::Duration::from_secs(60)); + assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); + } + // A photo that fails is reported in its place while the others are exported; with // stop_on_error the batch ends there and nothing after it is written #[test]