LR-BEHAV-BG-TASKS: a render job that panics fails instead of never answering
A panic inside RenderJob::run or QuickJob::run (a raw decoder or pipeline bug) unwound the job pool's worker without sending a result. The slot stayed pending and its key was never asked for again, so the loupe or the thumbnail rendered forever; and the pool never replaces a dead worker, so after as many such panics as it has workers (2–6) no photo rendered until LightCraft was restarted. Both now catch the panic (guard::catch) and return it as the job's failure, answering the request it came from, so the renderer records it like any failed render: no retry until the catalog changes, and the workers live on. run() is the one place every render goes through (the pool, export, Build Previews, contact sheets, face and AI-mask workers); callers that had their own guard keep working, and the watermark preview's, now redundant, is gone. The web build aborts on panic, so nothing changes there. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
76b5377868
commit
2df78c9e58
|
|
@ -803,7 +803,31 @@ impl RenderJob {
|
|||
self
|
||||
}
|
||||
|
||||
/// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting
|
||||
/// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace
|
||||
/// it, so a few such panics used to stop every render).
|
||||
pub fn run(self) -> RenderResult {
|
||||
let failed = self.failed(String::new());
|
||||
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed })
|
||||
}
|
||||
|
||||
/// This job's result when it fails with `e`.
|
||||
fn failed(&self, e: String) -> RenderResult {
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_unguarded(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
{
|
||||
|
|
@ -877,18 +901,7 @@ impl RenderJob {
|
|||
display,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
},
|
||||
Err(e) => self.failed(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -976,7 +989,25 @@ pub struct QuickJob {
|
|||
}
|
||||
|
||||
impl QuickJob {
|
||||
/// The stand-in, from the first source that has one. A panic inside is this job's failure
|
||||
/// (see [`RenderJob::run`]).
|
||||
pub fn run(self) -> RenderResult {
|
||||
let (request_id, photo, key) = (self.request_id, self.photo, self.key);
|
||||
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult {
|
||||
request_id,
|
||||
source_key: None,
|
||||
photo,
|
||||
level: SourceLevel::Thumb,
|
||||
key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_unguarded(self) -> RenderResult {
|
||||
let (photo, key) = (self.photo, self.key);
|
||||
let request_id = self.request_id;
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
|
||||
|
|
@ -1588,6 +1619,31 @@ mod thumbnail_hash_tests {
|
|||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0);
|
||||
}
|
||||
/// A panic while rendering (here in the decoder) used to end the pool's worker thread without
|
||||
/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now,
|
||||
/// for renders and for stand-ins, answering the request it came from.
|
||||
#[test]
|
||||
fn a_render_that_panics_fails_instead_of_never_answering() {
|
||||
let mut s = session();
|
||||
s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug")));
|
||||
let mut job = s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
job.request_id = 42;
|
||||
let (key, level, source_key) = (job.key, job.level, job.source_key);
|
||||
let r = job.run();
|
||||
assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key));
|
||||
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug"));
|
||||
let quick = QuickJob {
|
||||
request_id: 7,
|
||||
photo: PhotoId(1),
|
||||
key: 9,
|
||||
cached: Vec::new(),
|
||||
embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option<Rgba8> { panic!("preview reader bug") }), 256)),
|
||||
small: None,
|
||||
};
|
||||
let r = quick.run();
|
||||
assert_eq!((r.request_id, r.key), (7, 9));
|
||||
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ fn watermark_preview(
|
|||
let (out, repaint) = (base.clone(), ctx.clone());
|
||||
let work = move || {
|
||||
for job in jobs {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run().rendered.map(|r| r.image)))
|
||||
.unwrap_or_else(|_| Err("the preview failed unexpectedly".into()));
|
||||
// (a panic in the render is its failure: `RenderJob::run`)
|
||||
let r = job.run().rendered.map(|r| r.image);
|
||||
let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// a failed sharp render keeps the thumbnail
|
||||
if r.is_ok() || !matches!(got.1, Some(Ok(_))) {
|
||||
|
|
|
|||
|
|
@ -1057,6 +1057,39 @@ mod thumbnail_tests {
|
|||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting,
|
||||
/// the error is kept (not retried every frame), and the pool's workers live on. Before, the
|
||||
/// worker died without an answer and the slot stayed pending forever.
|
||||
#[test]
|
||||
fn a_panicking_render_is_a_failure_not_a_dead_worker() {
|
||||
let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone());
|
||||
app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug")));
|
||||
app.renderer.offload = None; // the real job pool
|
||||
let slot = Slot::Thumb(PhotoId(1));
|
||||
let ask = |app: &mut crate::LightcraftApp| {
|
||||
if let Some(job) = app.session.thumb_job(PhotoId(1), 256) {
|
||||
app.renderer.request(slot, job, 30);
|
||||
}
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
};
|
||||
ask(&mut app);
|
||||
let t0 = std::time::Instant::now();
|
||||
while app.renderer.is_pending(slot) {
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
}
|
||||
assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug"));
|
||||
let workers = app.renderer.live_workers();
|
||||
assert!(workers > 0, "the worker that panicked is still there");
|
||||
let done = app.renderer.completed;
|
||||
for _ in 0..30 {
|
||||
ask(&mut app);
|
||||
}
|
||||
assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop");
|
||||
assert_eq!(app.renderer.live_workers(), workers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_quick_path_frames_build_no_jobs() {
|
||||
// an unedited raw: the grid shows its embedded preview (held here until released) and stops
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@
|
|||
- Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed
|
||||
in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade
|
||||
installs to Program Files.
|
||||
- A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails
|
||||
like a photo that can't be opened, with the reason, instead of rendering forever; its thumbnail stops waiting too.
|
||||
Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until
|
||||
LightCraft was restarted. The bug is still written to the log (Help ▸ Open Log Folder) for a report.
|
||||
|
||||
## 2026-10-10
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue