anodizer_core/parallel.rs
1//! Shared bounded-parallelism helper used by stages that run one subprocess
2//! per sub-config (makeself, nfpm, snapcraft, flatpak, upx, …).
3//!
4//! The stages share the same Step 1 / Step 2 / Step 3 shape:
5//!
6//! 1. **Step 1** (serial, `&mut ctx`): render templates, stage files,
7//! collect a `Vec<Job>` of fully-owned work units.
8//! 2. **Step 2** (parallel, bounded by `ctx.options.parallelism`): run one
9//! subprocess per job in `std::thread::scope`.
10//! 3. **Step 3** (serial, `&mut ctx`): register the returned artifacts.
11//!
12//! Before this helper every stage hand-rolled the Step 2 loop —
13//! `for chunk in jobs.chunks(n) { thread::scope(|s| …) }` with its own
14//! join-unwrap-or-panic handling. The pattern is now shared here so new
15//! parallelized stages just write `run_job`.
16//!
17//! Semantics match the previous hand-rolled loops exactly:
18//!
19//! - **Bounded concurrency**: at most `parallelism` workers run at once,
20//! enforced by chunking the job list and scoping threads per-chunk.
21//! - **Fail-fast within a chunk**: if any worker in a chunk fails, the whole
22//! chunk still runs to completion (threads are already spawned), but the
23//! caller receives the first error and processes no further chunks. The
24//! completed siblings' work is still accounted for: additional failures
25//! in the batch are logged, and a warn summarizes partial progress.
26//! - **Panic-safe**: a worker panic becomes an `anyhow::Error` annotated
27//! with `stage_name`, so a panicked thread doesn't leave the pool
28//! deadlocked or drop all other results on the floor.
29//! - **Order-preserving**: results are collected in job-submission order, so
30//! downstream artifact registration remains deterministic.
31
32use anyhow::{Result, anyhow};
33
34use crate::log::StageLogger;
35use std::sync::{Mutex, MutexGuard};
36
37/// Acquire a `Mutex` guard, recovering from poison rather than panicking.
38///
39/// A poisoned lock means a sibling worker thread panicked while holding
40/// the guard. For the data shapes this helper is used on (counters,
41/// `Vec` accumulators), the inner state has no invariant a panic could
42/// have broken — the worst case is one partial write missing. Panicking
43/// the current worker too would abandon its already-completed network
44/// call without updating the count, silently inflating the operator's
45/// `failed` bucket.
46pub fn lock_recover<'a, T>(m: &'a Mutex<T>, log: &StageLogger, label: &str) -> MutexGuard<'a, T> {
47 match m.lock() {
48 Ok(g) => g,
49 Err(poisoned) => {
50 log.warn(&format!(
51 "{label} mutex poisoned by sibling thread panic; recovering state"
52 ));
53 poisoned.into_inner()
54 }
55 }
56}
57
58/// Translate a `thread::JoinHandle::join` result's panic payload into
59/// an `anyhow::Error` tagged with `label`. The two common panic
60/// payload shapes (`&'static str` / `String`) are downcast so the
61/// surfaced message is readable rather than the opaque `Any`
62/// placeholder.
63///
64/// Accepts `Result<T, Box<dyn Any + Send>>` rather than the handle
65/// itself so a single helper covers both [`std::thread::JoinHandle`]
66/// and [`std::thread::ScopedJoinHandle`] — both expose `.join()`
67/// returning the same `Result` shape.
68///
69/// Use when the worker returns `T` and the caller wants `Result<T>`
70/// so a panic doesn't propagate as a silently-lost result. For
71/// workers that already return `Result<T, anyhow::Error>`, prefer
72/// [`run_parallel_chunks`] which bakes this in.
73pub fn join_panic_to_err<T>(join_result: std::thread::Result<T>, label: &str) -> Result<T> {
74 join_result.map_err(|panic_payload| {
75 let msg = if let Some(s) = panic_payload.downcast_ref::<&'static str>() {
76 (*s).to_string()
77 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
78 s.clone()
79 } else {
80 format!("{:?}", panic_payload)
81 };
82 anyhow!("{label} worker thread panicked: {msg}")
83 })
84}
85
86/// Run `run_job` across `jobs` with bounded parallelism. Returns the
87/// per-job results in submission order.
88///
89/// `stage_name` is embedded in the panic error message so a crash in one
90/// stage is attributable at a glance (`"nfpm worker thread panicked"` vs
91/// `"snapcraft worker thread panicked"`).
92///
93/// `parallelism` is clamped to `>= 1` internally, so callers can pass
94/// `ctx.options.parallelism` without pre-clamping.
95///
96/// On failure the FIRST error is returned and no further chunks run, but
97/// the failed chunk's completed siblings are never silently discarded:
98/// every additional failure in the chunk is logged as a warning (only the
99/// first error propagates), and a warn summarizes the partial progress —
100/// how many jobs in the batch succeeded before the failure and how many
101/// later jobs were never started.
102pub fn run_parallel_chunks<J, T, F>(
103 jobs: &[J],
104 parallelism: usize,
105 stage_name: &'static str,
106 log: &StageLogger,
107 run_job: F,
108) -> Result<Vec<T>>
109where
110 J: Sync,
111 T: Send,
112 F: Fn(&J) -> Result<T> + Sync,
113{
114 let parallelism = parallelism.max(1);
115 let mut results: Vec<T> = Vec::with_capacity(jobs.len());
116
117 for chunk in jobs.chunks(parallelism) {
118 let chunk_results: Vec<Result<T>> = std::thread::scope(|s| {
119 let handles: Vec<_> = chunk.iter().map(|job| s.spawn(|| run_job(job))).collect();
120 handles
121 .into_iter()
122 .map(|h| {
123 h.join()
124 .unwrap_or_else(|_| Err(anyhow!("{} worker thread panicked", stage_name)))
125 })
126 .collect()
127 });
128
129 // The whole chunk already ran to completion (its threads were
130 // spawned together), so account for EVERY result before propagating:
131 // a bare `push(r?)` would silently drop the completed siblings'
132 // work and any second/third failure in the same batch.
133 let mut first_err: Option<anyhow::Error> = None;
134 let mut chunk_ok = 0usize;
135 let chunk_len = chunk.len();
136 for r in chunk_results {
137 match r {
138 Ok(t) => {
139 chunk_ok += 1;
140 results.push(t);
141 }
142 Err(e) => {
143 if first_err.is_none() {
144 first_err = Some(e);
145 } else {
146 // Warn with the root cause only: the full anyhow chain
147 // can embed unredacted subprocess/HTTP detail (upload
148 // URLs, response bodies) that the propagated error gets
149 // caller-side redaction for but this path would not.
150 let root = e.root_cause().to_string();
151 let first = root.lines().next().unwrap_or("");
152 let mut line: String = first.chars().take(200).collect();
153 if first.chars().count() > 200 {
154 line.push('…');
155 }
156 log.warn(&format!(
157 "{stage_name}: additional failure in the same batch \
158 (only the first is propagated): {line}"
159 ));
160 log.verbose(&format!("{stage_name}: additional failure detail: {e:#}"));
161 }
162 }
163 }
164 }
165 if let Some(err) = first_err {
166 let not_started = jobs.len() - results.len() - (chunk_len - chunk_ok);
167 log.warn(&format!(
168 "{stage_name}: {chunk_ok} of {chunk_len} item(s) in this batch succeeded \
169 before the failure ({completed} of {total} total completed, \
170 {not_started} never started)",
171 completed = results.len(),
172 total = jobs.len(),
173 ));
174 return Err(err);
175 }
176 }
177 Ok(results)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::test_helpers::test_logger;
184 use std::sync::atomic::{AtomicUsize, Ordering};
185
186 #[test]
187 fn preserves_submission_order() {
188 // Even with multi-threaded execution, the returned Vec must mirror
189 // the input slice order so downstream artifact registration is
190 // deterministic across runs.
191 let jobs: Vec<u32> = (0..20).collect();
192 let out =
193 run_parallel_chunks(&jobs, 4, "test", test_logger(), |job| Ok(*job * 10)).unwrap();
194 assert_eq!(out, (0..20).map(|i| i * 10).collect::<Vec<_>>());
195 }
196
197 #[test]
198 fn bounded_concurrency() {
199 // With parallelism=2 across 10 jobs, no more than 2 workers should
200 // be in-flight at once. We observe this via an AtomicUsize peak
201 // counter that each worker increments on entry and decrements on
202 // exit, with a small sleep to force overlap.
203 let jobs: Vec<u32> = (0..10).collect();
204 let in_flight = AtomicUsize::new(0);
205 let peak = AtomicUsize::new(0);
206
207 run_parallel_chunks(&jobs, 2, "test", test_logger(), |_| {
208 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
209 peak.fetch_max(now, Ordering::SeqCst);
210 std::thread::sleep(std::time::Duration::from_millis(10));
211 in_flight.fetch_sub(1, Ordering::SeqCst);
212 Ok(())
213 })
214 .unwrap();
215
216 assert!(
217 peak.load(Ordering::SeqCst) <= 2,
218 "peak in-flight workers exceeded parallelism bound"
219 );
220 }
221
222 #[test]
223 fn propagates_first_error() {
224 // A single failing job should fail the batch. The job index returned
225 // in the error payload asserts the failing worker is the one the
226 // caller receives (not silently swallowed by a later success).
227 let jobs: Vec<u32> = (0..4).collect();
228 let result = run_parallel_chunks(&jobs, 2, "test", test_logger(), |job| {
229 if *job == 2 {
230 Err(anyhow!("job 2 failed"))
231 } else {
232 Ok(*job)
233 }
234 });
235 let err = result.unwrap_err();
236 assert!(
237 err.to_string().contains("job 2 failed"),
238 "unexpected error: {}",
239 err
240 );
241 }
242
243 /// A failed chunk must not silently swallow its completed siblings'
244 /// work or the batch's additional failures: every job in the chunk
245 /// still runs, the extra failure is logged, and a warn summarizes the
246 /// partial progress (succeeded-in-batch / total-completed / never-started).
247 #[test]
248 fn failed_chunk_reports_partial_progress_and_sibling_failures() {
249 let jobs: Vec<u32> = (0..8).collect();
250 let executed = AtomicUsize::new(0);
251 let (log, cap) = StageLogger::with_capture("test", crate::log::Verbosity::Quiet);
252
253 // parallelism=4 → chunk [0,1,2,3]: jobs 1 and 3 fail, 0 and 2 succeed;
254 // chunks [4..] must never start.
255 let result = run_parallel_chunks(&jobs, 4, "partial-stage", &log, |job| {
256 executed.fetch_add(1, Ordering::SeqCst);
257 if *job == 1 || *job == 3 {
258 Err(anyhow!("job {} failed", job)
259 .context("POST https://uploads.example/secret-token failed"))
260 } else {
261 Ok(*job)
262 }
263 });
264
265 let err = result.unwrap_err();
266 assert!(
267 format!("{err:#}").contains("job 1 failed"),
268 "the FIRST error (submission order) must propagate: {err:#}"
269 );
270 assert_eq!(
271 executed.load(Ordering::SeqCst),
272 4,
273 "the whole failed chunk runs; later chunks never start"
274 );
275 let warns = cap.warn_messages();
276 assert!(
277 warns
278 .iter()
279 .any(|m| m.contains("job 3 failed") && m.contains("only the first is propagated")),
280 "the sibling failure must be logged, not dropped: {warns:?}"
281 );
282 assert!(
283 !warns.iter().any(|m| m.contains("uploads.example")),
284 "the sibling warn must carry the root cause only, never the \
285 unredacted context chain: {warns:?}"
286 );
287 let details: Vec<String> = cap
288 .all_messages()
289 .into_iter()
290 .filter(|(lvl, _)| *lvl == crate::log::LogLevel::Verbose)
291 .map(|(_, m)| m)
292 .collect();
293 assert!(
294 details
295 .iter()
296 .any(|m| m.contains("uploads.example") && m.contains("job 3 failed")),
297 "the full chain must still be available at verbose: {details:?}"
298 );
299 assert!(
300 warns.iter().any(|m| m.contains(
301 "partial-stage: 2 of 4 item(s) in this batch succeeded before the failure"
302 ) && m.contains("2 of 8 total completed")
303 && m.contains("4 never started")),
304 "partial-progress summary must be warned: {warns:?}"
305 );
306 }
307
308 /// A clean run must emit NO partial-progress warns — the summary is a
309 /// failure-path diagnostic, not routine chatter.
310 #[test]
311 fn successful_run_emits_no_warns() {
312 let jobs: Vec<u32> = (0..6).collect();
313 let (log, cap) = StageLogger::with_capture("test", crate::log::Verbosity::Quiet);
314 let out = run_parallel_chunks(&jobs, 3, "test", &log, |job| Ok(*job)).unwrap();
315 assert_eq!(out.len(), 6);
316 assert_eq!(cap.warn_count(), 0, "no warns on a clean run");
317 }
318
319 #[test]
320 fn zero_parallelism_clamps_to_one() {
321 // `ctx.options.parallelism` can legitimately be 0 (unset) —
322 // callers must not need to pre-clamp. Verify the helper runs
323 // sequentially in that case rather than spawning 0 threads.
324 let jobs: Vec<u32> = (0..3).collect();
325 let out = run_parallel_chunks(&jobs, 0, "test", test_logger(), |job| Ok(*job + 1)).unwrap();
326 assert_eq!(out, vec![1, 2, 3]);
327 }
328
329 #[test]
330 fn empty_jobs_returns_empty() {
331 let out: Vec<u32> =
332 run_parallel_chunks::<u32, u32, _>(&[], 4, "test", test_logger(), |_| Ok(0)).unwrap();
333 assert!(out.is_empty());
334 }
335
336 #[test]
337 fn panic_in_worker_becomes_anyhow_error() {
338 // A panicking worker must not take down the whole thread::scope
339 // silently — we want an attributable error with the stage name.
340 let jobs: Vec<u32> = vec![1, 2, 3];
341 let result = run_parallel_chunks(
342 &jobs,
343 2,
344 "explode-stage",
345 test_logger(),
346 |job| -> Result<u32> {
347 if *job == 2 {
348 panic!("boom");
349 }
350 Ok(*job)
351 },
352 );
353 let err = result.unwrap_err();
354 assert!(
355 err.to_string()
356 .contains("explode-stage worker thread panicked"),
357 "unexpected error: {}",
358 err
359 );
360 }
361
362 // ---------- lock_recover ----------
363
364 #[test]
365 fn lock_recover_returns_inner_when_unpoisoned() {
366 // Happy path: an unpoisoned Mutex yields its guard, the helper
367 // adds no observable behavior over a bare `.lock().unwrap()`.
368 let log = test_logger();
369 let m = Mutex::new(0u32);
370 {
371 let mut g = lock_recover(&m, log, "test");
372 *g = 42;
373 }
374 assert_eq!(*m.lock().unwrap(), 42);
375 }
376
377 #[test]
378 fn lock_recover_recovers_from_poison() {
379 // A poisoned Mutex (sibling thread panicked while holding the
380 // guard) must yield the inner state rather than panicking the
381 // recovering thread too.
382 let log = test_logger();
383 let m = std::sync::Arc::new(Mutex::new(7u32));
384 let m_for_thread = std::sync::Arc::clone(&m);
385 let h = std::thread::spawn(move || {
386 let _g = m_for_thread.lock().unwrap();
387 panic!("poison the mutex");
388 });
389 let _ = h.join();
390 assert!(m.is_poisoned(), "test setup: mutex should be poisoned");
391 let g = lock_recover(&m, log, "test");
392 assert_eq!(*g, 7);
393 }
394
395 // ---------- join_panic_to_err ----------
396
397 #[test]
398 fn join_panic_to_err_passes_through_success() {
399 let h = std::thread::spawn(|| 42u32);
400 let r = join_panic_to_err(h.join(), "worker").unwrap();
401 assert_eq!(r, 42);
402 }
403
404 #[test]
405 fn join_panic_to_err_translates_str_panic() {
406 // The most common panic shape in our codebase is `panic!("msg")`
407 // which produces a `&'static str` payload — verify the message
408 // survives into the surfaced anyhow chain.
409 let h = std::thread::spawn(|| -> u32 {
410 panic!("kaboom");
411 });
412 let err = join_panic_to_err(h.join(), "worker").unwrap_err();
413 let s = err.to_string();
414 assert!(
415 s.contains("worker worker thread panicked") && s.contains("kaboom"),
416 "unexpected error: {}",
417 s
418 );
419 }
420
421 #[test]
422 fn join_panic_to_err_translates_string_panic() {
423 // The other common panic shape — `format!()`-derived `String`
424 // payloads — must also be downcast rather than printing as `Any`.
425 let h = std::thread::spawn(|| -> u32 {
426 panic!("{}", String::from("string-panic"));
427 });
428 let err = join_panic_to_err(h.join(), "worker").unwrap_err();
429 assert!(
430 err.to_string().contains("string-panic"),
431 "unexpected error: {}",
432 err
433 );
434 }
435
436 #[test]
437 fn join_panic_to_err_works_on_scoped_handle() {
438 // ScopedJoinHandle::join returns the same Result shape as
439 // JoinHandle::join — verify a single helper covers both so
440 // callers using `std::thread::scope` don't need a second variant.
441 let out: Result<u32> = std::thread::scope(|s| {
442 let h = s.spawn(|| 99u32);
443 join_panic_to_err(h.join(), "scoped")
444 });
445 assert_eq!(out.unwrap(), 99);
446 }
447}