diff --git a/src/app/mod.rs b/src/app/mod.rs index 62991eee8..493de6fc6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3573,6 +3573,15 @@ pub enum Message { /// Completion of a PDF/preview/print job performed outside the UI thread. /// The boolean restores the Plot dialog after a preview. BackgroundIoFinished(Result, bool), + /// A background worker thread panicked or stopped without reporting a + /// result. Surfaced on the command line instead of taking the whole + /// process down with it. + BackgroundTaskFailed { + /// Which operation the worker was running (e.g. "STL export"). + context: String, + /// Panic message (or channel-failure description) from the worker. + detail: String, + }, /// Send current layout to the system printer (via lp / lpr). PrintToPrinter, /// Callback from the async printer job. diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 57abaaa0f..bf54ca175 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -34,7 +34,13 @@ fn write_export(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { } } -pub(super) fn background_task(work: F, map: M) -> Task +/// Run `work` on a worker thread and map its result into a `Message`. +/// +/// `context` names the operation for error reporting. A worker panic never +/// reaches the UI thread: it is caught on the worker and re-surfaced as +/// [`Message::BackgroundTaskFailed`] so a crashing export or import reports +/// on the command line instead of killing the application. +pub(super) fn background_task(context: impl Into, work: F, map: M) -> Task where T: Send + 'static, F: FnOnce() -> T + Send + 'static, @@ -42,21 +48,49 @@ where { #[cfg(not(target_arch = "wasm32"))] { + let context = context.into(); let (tx, rx) = iced::futures::channel::oneshot::channel(); std::thread::spawn(move || { - let _ = tx.send(work()); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)); + let _ = tx.send(result); }); Task::perform( - async move { rx.await.expect("background export worker dropped") }, - map, + async move { + match rx.await { + Ok(Ok(value)) => map(value), + Ok(Err(payload)) => Message::BackgroundTaskFailed { + context, + detail: panic_detail(payload), + }, + Err(_) => Message::BackgroundTaskFailed { + context, + detail: "worker stopped without reporting a result".to_string(), + }, + } + }, + |message| message, ) } #[cfg(target_arch = "wasm32")] { + let _ = context; Task::perform(async move { work() }, map) } } +/// Render a panic payload the way `std` reports it: `&'static str` messages, +/// `String` messages, or a fallback for foreign payloads. +#[cfg(not(target_arch = "wasm32"))] +fn panic_detail(payload: Box) -> String { + if let Some(text) = payload.downcast_ref::<&'static str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "worker panicked".to_string() + } +} + fn plot_dialog_scale_factor(d: &crate::ui::window::plot::PlotDialogState) -> f64 { d.scales .iter() @@ -1990,6 +2024,7 @@ impl OpenCADStudio { let worker_name = block_name.clone(); let worker_path = path.clone(); background_task( + crate::t!("WBLOCK save"), move || { let document = if worker_name == "*" { crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) @@ -2017,6 +2052,7 @@ impl OpenCADStudio { .collect(); let worker_path = path.clone(); background_task( + crate::t!("STL export"), move || { let mesh_refs: Vec<_> = meshes.iter().collect(); let bytes = crate::io::stl::build_stl(&mesh_refs) @@ -2038,6 +2074,7 @@ impl OpenCADStudio { .collect(); let worker_path = path.clone(); background_task( + crate::t!("STEP export"), move || { let mesh_refs: Vec<_> = meshes.iter().collect(); let text = crate::io::step::build_step(&mesh_refs) @@ -2052,6 +2089,7 @@ impl OpenCADStudio { let tab_id = self.tabs[self.active_tab].id; let worker_path = path.clone(); background_task( + crate::t!("OBJ import"), move || { let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0]) @@ -3882,7 +3920,7 @@ impl OpenCADStudio { F: FnOnce() -> Result + Send + 'static, { if background { - background_task(work, Message::PrintAllFinished) + background_task(crate::t!("Print all"), work, Message::PrintAllFinished) } else { Task::done(Message::PrintAllFinished(work())) } @@ -3893,7 +3931,7 @@ impl OpenCADStudio { F: FnOnce() -> Result + Send + 'static, { if background { - background_task(work, move |result| { + background_task(crate::t!("Plot"), work, move |result| { Message::BackgroundIoFinished(result, reopen_plot) }) } else { @@ -4103,6 +4141,7 @@ impl OpenCADStudio { self.command_line .push_info(crate::t!("Sending to system printer…").as_ref()); background_task( + crate::t!("Print to printer"), move || { iced::futures::executor::block_on( crate::io::print_to_printer::print_wires_with(page, options), @@ -4358,6 +4397,7 @@ impl OpenCADStudio { d.printer_media = None; let name = printer.clone(); background_task( + crate::t!("Printer capabilities"), move || crate::io::plot_device::printer_capabilities(&printer), move |caps| Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(name, caps)), ) @@ -4483,6 +4523,7 @@ impl OpenCADStudio { }); let name = printer.clone(); background_task( + crate::t!("Printer options"), move || crate::io::print_to_printer::printer_options(&printer), move |result| { Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded( @@ -6583,3 +6624,54 @@ mod plot_device_persistence_tests { assert!(app.plot_dialog.printer.is_none()); } } + +#[cfg(test)] +mod background_task_tests { + use super::background_task; + use crate::app::{Message, OpenCADStudio}; + use iced::futures::StreamExt; + use iced::Task; + + /// Pull the single message a finished background task produces, the same + /// way the headless automation driver consumes task streams. + fn next_message(task: Task) -> Message { + let mut stream = + iced_runtime::task::into_stream(task).expect("background_task yields a stream"); + match iced::futures::executor::block_on(stream.next()) { + Some(iced_runtime::Action::Output(message)) => message, + other => panic!("expected task output, got {other:?}"), + } + } + + #[test] + fn worker_panic_becomes_a_failure_message() { + let task = + background_task("STL export", || panic!("worker exploded"), |()| Message::Noop); + match next_message(task) { + Message::BackgroundTaskFailed { context, detail } => { + assert_eq!(context, "STL export"); + assert!( + detail.contains("worker exploded"), + "detail should carry the panic message, got: {detail}" + ); + } + other => panic!("expected BackgroundTaskFailed, got {other:?}"), + } + } + + #[test] + fn worker_panic_is_reported_on_the_command_line() { + let mut app = OpenCADStudio::new_for_test(); + let task = + background_task("STL export", || panic!("worker exploded"), |()| Message::Noop); + app.drive_headless_task(task) + .expect("failed worker task drives headlessly"); + let error = app + .command_line + .last_error + .as_deref() + .expect("worker panic reported as an error"); + assert!(error.contains("STL export"), "error = {error}"); + assert!(error.contains("worker exploded"), "error = {error}"); + } +} \ No newline at end of file diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8560ba1c0..66770e34a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -5288,6 +5288,7 @@ impl OpenCADStudio { let worker_display = display_name.clone(); file::background_task( + crate::t!("WBLOCK save"), move || { let out_doc = match source_mode { WblockSourceMode::Block => { @@ -9717,6 +9718,12 @@ impl OpenCADStudio { Task::none() } + Message::BackgroundTaskFailed { context, detail } => { + self.command_line + .push_error(crate::tf!("{context} failed: {detail}").as_ref()); + Task::none() + } + // ── Print to system printer ─────────────────────────────────────── Message::PrintToPrinter => self.on_print_to_printer(), Message::PrintResult(Ok(printer)) => { diff --git a/src/app/update/page_setup_import.rs b/src/app/update/page_setup_import.rs index 3388359ca..44ab7ef1f 100644 --- a/src/app/update/page_setup_import.rs +++ b/src/app/update/page_setup_import.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; /// The result names the file the way the chooser shows it. fn read_page_setups_task(path: PathBuf) -> Task { super::file::background_task( + crate::t!("Page setup import"), move || { let file = path .file_name()