Skip to main content

ashlar_jobs/
lib.rs

1//! Background jobs: progress, cancel, panic isolation, apply-on-UI-thread.
2//!
3//! A job runs on a worker thread with a snapshot of what it needs (moved in: an `Arc` of the
4//! document, a path…). It reports progress through [`Progress`], stops when its [`CancelToken`] is
5//! cancelled, and runs under `catch_unwind`. It finishes with an [`Apply`]: a closure the UI thread
6//! runs on the app state when [`Jobs::drain_ready`] sees it (each frame, or when the `wake` hook
7//! asked for one). That is where the document is edited, as one undo step.
8//!
9//! [`Jobs::list`] / [`Jobs::to_json`] describe every job for the progress panel and for agents.
10//! On wasm32 jobs run inline (no threads); long work there uses [`Jobs::spawn_stepped`], which
11//! `drain_ready` advances a few steps per frame.
12#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
13
14mod pool;
15
16use std::any::Any;
17use std::collections::VecDeque;
18use std::fmt;
19use std::panic::{AssertUnwindSafe, catch_unwind};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
22use std::time::Duration;
23
24use serde::{Deserialize, Serialize};
25use serde_json::{Value, json};
26
27/// What a finished job does to the app state, on the UI thread.
28pub type Apply<S> = Box<dyn FnOnce(&mut S) -> Result<Value, String> + Send>;
29
30/// Box an apply closure.
31pub fn apply<S>(f: impl FnOnce(&mut S) -> Result<Value, String> + Send + 'static) -> Apply<S> {
32    Box::new(f)
33}
34
35/// Called (from any thread) when a job has something to show: started, progressed, finished.
36/// Apps request a repaint here, so an idle app with no reporting jobs does no work.
37pub type Wake = Arc<dyn Fn() + Send + Sync>;
38
39/// Finished jobs remembered for [`Jobs::recent`].
40const HISTORY: usize = 50;
41/// Progress moves smaller than this do not wake the UI.
42const WAKE_STEP: f64 = 0.005;
43/// Longest progress message kept.
44const MAX_MESSAGE: usize = 512;
45
46fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
47    m.lock().unwrap_or_else(PoisonError::into_inner)
48}
49
50/// Monotonic time, where the platform has a clock (not on wasm32-unknown-unknown).
51mod clock {
52    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
53    pub type Instant = std::time::Instant;
54    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
55    pub fn now() -> Option<Instant> {
56        Some(Instant::now())
57    }
58
59    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
60    #[derive(Clone, Copy, Debug)]
61    pub struct Instant;
62    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
63    impl Instant {
64        pub fn elapsed(&self) -> std::time::Duration {
65            std::time::Duration::ZERO
66        }
67    }
68    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
69    pub fn now() -> Option<Instant> {
70        None
71    }
72}
73
74/// A job's id, unique within one [`Jobs`].
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct JobId(pub u64);
78
79impl fmt::Display for JobId {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}", self.0)
82    }
83}
84
85/// Cooperative cancellation: the job checks it (or the `bool` its progress calls return).
86#[derive(Debug, Clone, Default)]
87pub struct CancelToken(Arc<AtomicBool>);
88
89impl CancelToken {
90    pub fn cancel(&self) {
91        self.0.store(true, Ordering::Relaxed);
92    }
93    pub fn is_cancelled(&self) -> bool {
94        self.0.load(Ordering::Relaxed)
95    }
96}
97
98/// Where a job is.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum Status {
102    /// Waiting for a worker.
103    Queued,
104    Running,
105    /// Finished on its worker; applied at the next [`Jobs::drain_ready`].
106    Ready,
107    /// Applied.
108    Done,
109    Failed,
110    Cancelled,
111}
112
113impl Status {
114    pub fn is_finished(self) -> bool {
115        matches!(self, Status::Done | Status::Failed | Status::Cancelled)
116    }
117}
118
119struct State {
120    status: Status,
121    fraction: Option<f64>,
122    message: String,
123    started: Option<clock::Instant>,
124    woken_fraction: f64,
125    /// Set when one of the job's updates failed to apply (the job was cancelled because of it).
126    update_error: Option<String>,
127}
128
129struct Shared {
130    cancel: CancelToken,
131    state: Mutex<State>,
132    wake: Arc<Mutex<Option<Wake>>>,
133}
134
135impl Shared {
136    fn wake(&self) {
137        let w = lock(&self.wake).clone();
138        if let Some(w) = w {
139            // the app's hook must not take a worker down
140            let _ = catch_unwind(AssertUnwindSafe(|| w()));
141        }
142    }
143
144    /// Queued → Running; `false` when the job was cancelled before it started.
145    fn begin(&self) -> bool {
146        {
147            let mut s = lock(&self.state);
148            if s.status != Status::Queued {
149                return false;
150            }
151            s.status = Status::Running;
152            s.started = clock::now();
153        }
154        self.wake();
155        true
156    }
157
158    fn set_status(&self, status: Status) {
159        lock(&self.state).status = status;
160    }
161
162    fn status(&self) -> Status {
163        lock(&self.state).status
164    }
165}
166
167/// Handed to a running job: report progress, check for cancellation.
168pub struct Progress {
169    shared: Arc<Shared>,
170}
171
172impl Progress {
173    /// Report progress 0..=1 (clamped; NaN ignored). Returns `false` once the job was cancelled.
174    pub fn set(&self, fraction: f64) -> bool {
175        self.update(Some(fraction), None)
176    }
177
178    /// Report `done` of `total` steps. Returns `false` once the job was cancelled.
179    pub fn steps(&self, done: u64, total: u64) -> bool {
180        let f = if total == 0 { 0.0 } else { done as f64 / total as f64 };
181        self.update(Some(f), None)
182    }
183
184    /// Say what the job is doing ("Reading frame 12 of 300").
185    pub fn message(&self, message: impl Into<String>) {
186        self.update(None, Some(message.into()));
187    }
188
189    /// Progress and message together. Returns `false` once the job was cancelled.
190    pub fn report(&self, fraction: f64, message: impl Into<String>) -> bool {
191        self.update(Some(fraction), Some(message.into()))
192    }
193
194    pub fn is_cancelled(&self) -> bool {
195        self.shared.cancel.is_cancelled()
196    }
197
198    /// A token to hand to code that only needs to check for cancellation.
199    pub fn token(&self) -> CancelToken {
200        self.shared.cancel.clone()
201    }
202
203    fn update(&self, fraction: Option<f64>, message: Option<String>) -> bool {
204        let wake = {
205            let mut s = lock(&self.shared.state);
206            let mut wake = false;
207            if let Some(f) = fraction.filter(|f| !f.is_nan()) {
208                let f = f.clamp(0.0, 1.0);
209                s.fraction = Some(f);
210                if (f - s.woken_fraction).abs() >= WAKE_STEP || f >= 1.0 {
211                    s.woken_fraction = f;
212                    wake = true;
213                }
214            }
215            if let Some(mut m) = message {
216                if let Some((i, _)) = m.char_indices().nth(MAX_MESSAGE) {
217                    m.truncate(i);
218                }
219                if m != s.message {
220                    s.message = m;
221                    wake = true;
222                }
223            }
224            wake
225        };
226        if wake {
227            self.shared.wake();
228        }
229        !self.is_cancelled()
230    }
231}
232
233/// What a stepped job's step returns.
234pub enum Step<S> {
235    /// More to do: call the step again.
236    Continue,
237    Done(Apply<S>),
238    Failed(String),
239    /// Nothing to do until something outside the job happens (a file or a fetch is still loading):
240    /// inline, stepping stops until the next drain; on a worker the step is retried shortly.
241    Wait,
242}
243
244enum Outcome<S> {
245    Apply(Apply<S>),
246    Failed(String),
247    Cancelled,
248    /// A partial result posted by a running job ([`Updates::send`]).
249    Update(Apply<S>),
250}
251
252struct Inbox<S> {
253    items: Mutex<VecDeque<(JobId, Outcome<S>)>>,
254    cv: Condvar,
255}
256
257impl<S> Inbox<S> {
258    fn push(&self, id: JobId, outcome: Outcome<S>) {
259        lock(&self.items).push_back((id, outcome));
260        self.cv.notify_all();
261    }
262
263    /// A job's last outcome: queued and the job marked [`Status::Ready`] under the inbox lock, so
264    /// whoever checks statuses while holding it ([`Jobs::wait_all`]) sees the outcome as soon as it
265    /// sees the job finished, never a finished job with nothing in the inbox yet.
266    fn finish(&self, id: JobId, outcome: Outcome<S>, shared: &Shared) {
267        let mut items = lock(&self.items);
268        items.push_back((id, outcome));
269        shared.set_status(Status::Ready);
270        drop(items);
271        self.cv.notify_all();
272    }
273}
274
275struct Entry {
276    id: JobId,
277    kind: &'static str,
278    label: String,
279    shared: Arc<Shared>,
280}
281
282type StepFn<S> = Box<dyn FnMut(&Progress) -> Step<S> + Send>;
283
284struct SteppedEntry<S> {
285    id: JobId,
286    shared: Arc<Shared>,
287    step: StepFn<S>,
288}
289
290/// Sends partial results from a running job ([`JobBuilder::spawn_streaming`]): each one is applied
291/// on the UI thread by the next [`Jobs::drain_ready`], in the order sent, while the job goes on
292/// (keyframes as a tracker finds them, hits as a search finds them). Updates sent before the job
293/// finished are applied even when it was cancelled; an update that fails to apply cancels its job,
294/// the failure becomes the job's message and later updates are dropped.
295pub struct Updates<S> {
296    id: JobId,
297    inbox: Arc<Inbox<S>>,
298    shared: Arc<Shared>,
299}
300
301impl<S> Clone for Updates<S> {
302    fn clone(&self) -> Self {
303        Self { id: self.id, inbox: self.inbox.clone(), shared: self.shared.clone() }
304    }
305}
306
307impl<S> Updates<S> {
308    /// Post a partial result. What a job found before it noticed a cancel is still applied (a
309    /// stopped tracker keeps the frames it tracked). Returns `false` once the job was cancelled:
310    /// time to stop.
311    pub fn send(&self, apply: Apply<S>) -> bool {
312        self.inbox.push(self.id, Outcome::Update(apply));
313        self.shared.wake();
314        !self.shared.cancel.is_cancelled()
315    }
316}
317
318/// A job being described ([`Jobs::job`]): how it runs, then one of the `spawn` methods starts it.
319pub struct JobBuilder<'a, S: 'static> {
320    jobs: &'a mut Jobs<S>,
321    kind: &'static str,
322    label: String,
323    here: bool,
324}
325
326impl<S: 'static> JobBuilder<'_, S> {
327    /// Run the job on the calling thread, now, instead of on a worker (a command that waits for
328    /// its result, a CLI). It is listed, reports progress, can be cancelled from another thread
329    /// and runs under `catch_unwind` like any job; its apply still waits for [`Jobs::drain_ready`].
330    pub fn here(mut self) -> Self {
331        self.here = true;
332        self
333    }
334
335    /// Start it: see [`Jobs::spawn`].
336    pub fn spawn<I: Send + 'static>(self, input: I, work: impl FnOnce(I, &Progress) -> Result<Apply<S>, String> + Send + 'static) -> JobId {
337        self.jobs.launch(self.kind, self.label, self.here, input, move |i, p, _| work(i, p))
338    }
339
340    /// Start a job that also posts partial results through [`Updates`] while it runs.
341    pub fn spawn_streaming<I: Send + 'static>(
342        self,
343        input: I,
344        work: impl FnOnce(I, &Progress, &Updates<S>) -> Result<Apply<S>, String> + Send + 'static,
345    ) -> JobId {
346        self.jobs.launch(self.kind, self.label, self.here, input, work)
347    }
348
349    /// Start a stepped job: see [`Jobs::spawn_stepped`]. With [`JobBuilder::here`] all its steps
350    /// run now.
351    pub fn spawn_stepped<I: Send + 'static>(self, input: I, step: impl FnMut(&mut I, &Progress) -> Step<S> + Send + 'static) -> JobId {
352        self.jobs.launch_stepped(self.kind, self.label, self.here, input, step)
353    }
354}
355
356/// Retry delay for a stepped job that is waiting on something outside it.
357fn wait_a_moment() {
358    #[cfg(not(target_arch = "wasm32"))]
359    std::thread::sleep(Duration::from_millis(1));
360    #[cfg(target_arch = "wasm32")]
361    std::hint::spin_loop();
362}
363
364struct ReadyItem<S> {
365    entry: Entry,
366    outcome: Outcome<S>,
367}
368
369/// What [`Jobs::take_ready`] took: finished jobs and partial results, in order, to apply.
370#[must_use = "apply it, or finished jobs are lost"]
371pub struct Ready<S> {
372    items: Vec<ReadyItem<S>>,
373}
374
375impl<S> Ready<S> {
376    pub fn is_empty(&self) -> bool {
377        self.items.is_empty()
378    }
379
380    /// Apply everything to `state`, in order, each under `catch_unwind` (the second half of
381    /// [`Jobs::drain_ready`]). Returns the records of the jobs that finished.
382    pub fn apply(self, state: &mut S) -> Vec<JobRecord> {
383        let mut out = Vec::new();
384        for ReadyItem { entry, outcome } in self.items {
385            if let Outcome::Update(apply) = outcome {
386                // a job whose earlier update failed takes no more
387                if lock(&entry.shared.state).update_error.is_some() {
388                    continue;
389                }
390                let failed = match catch_unwind(AssertUnwindSafe(|| apply(state))) {
391                    Ok(Ok(_)) => None,
392                    Ok(Err(e)) => Some(e),
393                    Err(p) => {
394                        let msg = panic_message(p.as_ref());
395                        log::error!("applying an update of job `{}` panicked: {msg}", entry.label);
396                        Some(format!("internal error while applying the result ({msg})"))
397                    }
398                };
399                if let Some(e) = failed {
400                    lock(&entry.shared.state).update_error = Some(e);
401                    entry.shared.cancel.cancel();
402                }
403                continue;
404            }
405            let cancelled = entry.shared.cancel.is_cancelled();
406            let update_error = lock(&entry.shared.state).update_error.take();
407            let (status, message, result) = match outcome {
408                // an update failed to apply: the job was cancelled because of it
409                _ if update_error.is_some() => (Status::Failed, update_error.clone().unwrap_or_default(), Value::Null),
410                _ if cancelled => (Status::Cancelled, String::new(), Value::Null),
411                Outcome::Cancelled | Outcome::Update(_) => (Status::Cancelled, String::new(), Value::Null),
412                Outcome::Failed(e) => (Status::Failed, e, Value::Null),
413                Outcome::Apply(apply) => match catch_unwind(AssertUnwindSafe(|| apply(state))) {
414                    Ok(Ok(v)) => (Status::Done, lock(&entry.shared.state).message.clone(), v),
415                    Ok(Err(e)) => (Status::Failed, e, Value::Null),
416                    Err(p) => {
417                        let msg = panic_message(p.as_ref());
418                        log::error!("applying job `{}` panicked: {msg}", entry.label);
419                        (Status::Failed, format!("internal error while applying the result ({msg})"), Value::Null)
420                    }
421                },
422            };
423            entry.shared.set_status(status);
424            let seconds = lock(&entry.shared.state).started.map(|t| t.elapsed().as_secs_f64());
425            out.push(JobRecord { id: entry.id, kind: entry.kind.to_string(), label: entry.label, status, message, result, seconds });
426        }
427        out
428    }
429}
430
431/// One row of the progress panel / `jobs.list`.
432#[derive(Debug, Clone, PartialEq, Serialize)]
433pub struct JobInfo {
434    pub id: JobId,
435    pub kind: String,
436    pub label: String,
437    pub status: Status,
438    /// 0..=1, `None` until the job reports.
439    pub fraction: Option<f64>,
440    pub message: String,
441    pub elapsed_secs: Option<f64>,
442    /// Estimated seconds left, from the elapsed time and the fraction done.
443    pub eta_secs: Option<f64>,
444    pub cancellable: bool,
445}
446
447/// A finished job (kept for the progress panel and agents).
448#[derive(Debug, Clone, PartialEq, Serialize)]
449pub struct JobRecord {
450    pub id: JobId,
451    pub kind: String,
452    pub label: String,
453    /// `done`, `failed` or `cancelled`.
454    pub status: Status,
455    /// The error for a failed job, else the last progress message.
456    pub message: String,
457    /// What the apply returned.
458    pub result: Value,
459    pub seconds: Option<f64>,
460}
461
462fn panic_message(payload: &(dyn Any + Send)) -> String {
463    payload
464        .downcast_ref::<&str>()
465        .map(|s| (*s).to_string())
466        .or_else(|| payload.downcast_ref::<String>().cloned())
467        .unwrap_or_else(|| "unknown error".into())
468}
469
470/// The background jobs of one app state `S`.
471pub struct Jobs<S: 'static> {
472    pool: Option<pool::Pool>,
473    entries: Vec<Entry>,
474    stepped: Vec<SteppedEntry<S>>,
475    steps_per_drain: usize,
476    inbox: Arc<Inbox<S>>,
477    history: VecDeque<JobRecord>,
478    next_id: u64,
479    wake: Arc<Mutex<Option<Wake>>>,
480}
481
482impl<S: 'static> Default for Jobs<S> {
483    fn default() -> Self {
484        Self::new()
485    }
486}
487
488impl<S: 'static> Jobs<S> {
489    /// Workers bounded by the machine (cores − 1, at least 1, at most 8); inline on wasm32.
490    pub fn new() -> Self {
491        if cfg!(target_arch = "wasm32") {
492            return Self::inline();
493        }
494        let n = std::thread::available_parallelism().map(|n| n.get().saturating_sub(1)).unwrap_or(1).clamp(1, 8);
495        Self::with_workers(n)
496    }
497
498    /// At most `n` jobs run at once (at least 1).
499    pub fn with_workers(n: usize) -> Self {
500        Self::build(Some(pool::Pool::new(n)))
501    }
502
503    /// No threads: `spawn` runs the job before returning (its apply still waits for
504    /// `drain_ready`); stepped jobs advance in `drain_ready`. What wasm32 uses; handy in tests.
505    pub fn inline() -> Self {
506        Self::build(None)
507    }
508
509    fn build(pool: Option<pool::Pool>) -> Self {
510        Self {
511            pool,
512            entries: vec![],
513            stepped: vec![],
514            steps_per_drain: 4,
515            inbox: Arc::new(Inbox { items: Mutex::new(VecDeque::new()), cv: Condvar::new() }),
516            history: VecDeque::new(),
517            next_id: 1,
518            wake: Arc::default(),
519        }
520    }
521
522    /// The bound on concurrently running jobs (0 when inline).
523    pub fn workers(&self) -> usize {
524        self.pool.as_ref().map_or(0, pool::Pool::workers)
525    }
526
527    /// Steps each inline stepped job takes per [`Jobs::drain_ready`] (at least 1).
528    pub fn set_steps_per_drain(&mut self, n: usize) {
529        self.steps_per_drain = n.max(1);
530    }
531
532    /// The hook called when a job starts, reports progress or finishes (from any thread).
533    pub fn set_wake(&mut self, wake: impl Fn() + Send + Sync + 'static) {
534        *lock(&self.wake) = Some(Arc::new(wake));
535    }
536
537    fn register(&mut self, kind: &'static str, label: String) -> (JobId, Arc<Shared>) {
538        let id = JobId(self.next_id);
539        self.next_id = self.next_id.saturating_add(1);
540        let shared = Arc::new(Shared {
541            cancel: CancelToken::default(),
542            state: Mutex::new(State {
543                status: Status::Queued,
544                fraction: None,
545                message: String::new(),
546                started: None,
547                woken_fraction: 0.0,
548                update_error: None,
549            }),
550            wake: self.wake.clone(),
551        });
552        self.entries.push(Entry { id, kind, label, shared: shared.clone() });
553        (id, shared)
554    }
555
556    /// The id the next job started here gets (for work that needs to name its own job, such as
557    /// an undo merge key).
558    pub fn next_id(&self) -> JobId {
559        JobId(self.next_id)
560    }
561
562    /// Describe a job before starting it: where it runs and how it reports.
563    /// `jobs.job("export", "Exporting").here().spawn(input, work)`.
564    pub fn job(&mut self, kind: &'static str, label: impl Into<String>) -> JobBuilder<'_, S> {
565        JobBuilder { jobs: self, kind, label: label.into(), here: false }
566    }
567
568    /// Start a job. `input` is the snapshot it works on; `work` returns the [`Apply`] that lands
569    /// the result, or an error message. A panic in `work` fails the job, nothing else.
570    pub fn spawn<I: Send + 'static>(
571        &mut self,
572        kind: &'static str,
573        label: impl Into<String>,
574        input: I,
575        work: impl FnOnce(I, &Progress) -> Result<Apply<S>, String> + Send + 'static,
576    ) -> JobId {
577        self.job(kind, label).spawn(input, work)
578    }
579
580    fn launch<I: Send + 'static>(
581        &mut self,
582        kind: &'static str,
583        label: String,
584        here: bool,
585        input: I,
586        work: impl FnOnce(I, &Progress, &Updates<S>) -> Result<Apply<S>, String> + Send + 'static,
587    ) -> JobId {
588        let (id, shared) = self.register(kind, label);
589        let inbox = self.inbox.clone();
590        let updates = Updates { id, inbox: inbox.clone(), shared: shared.clone() };
591        let task = move || {
592            if !shared.begin() {
593                return;
594            }
595            let progress = Progress { shared: shared.clone() };
596            let r = catch_unwind(AssertUnwindSafe(|| work(input, &progress, &updates)));
597            let cancelled = shared.cancel.is_cancelled();
598            let outcome = match r {
599                _ if cancelled => Outcome::Cancelled,
600                Ok(Ok(apply)) => Outcome::Apply(apply),
601                Ok(Err(e)) => Outcome::Failed(e),
602                Err(p) => {
603                    let msg = panic_message(p.as_ref());
604                    log::error!("background job panicked: {msg}");
605                    Outcome::Failed(format!("internal error ({msg}); the job was stopped"))
606                }
607            };
608            inbox.finish(id, outcome, &shared);
609            shared.wake();
610        };
611        match &mut self.pool {
612            Some(pool) if !here => pool.submit(Box::new(task)),
613            _ => task(),
614        }
615        id
616    }
617
618    /// Start a job made of steps (`step` is called until it returns `Done`/`Failed`). On threads it
619    /// runs like [`Jobs::spawn`]; inline (wasm32) [`Jobs::drain_ready`] advances it a few steps per
620    /// call, so long work never freezes a frame. Apps should keep frames coming while
621    /// [`Jobs::needs_frames`] is true.
622    pub fn spawn_stepped<I: Send + 'static>(
623        &mut self,
624        kind: &'static str,
625        label: impl Into<String>,
626        input: I,
627        step: impl FnMut(&mut I, &Progress) -> Step<S> + Send + 'static,
628    ) -> JobId {
629        self.job(kind, label).spawn_stepped(input, step)
630    }
631
632    fn launch_stepped<I: Send + 'static>(
633        &mut self,
634        kind: &'static str,
635        label: String,
636        here: bool,
637        input: I,
638        mut step: impl FnMut(&mut I, &Progress) -> Step<S> + Send + 'static,
639    ) -> JobId {
640        let mut input = input;
641        if self.pool.is_some() || here {
642            return self.launch(kind, label, here, (), move |(), p, _| {
643                loop {
644                    if p.is_cancelled() {
645                        return Err("cancelled".into());
646                    }
647                    match step(&mut input, p) {
648                        Step::Continue => {}
649                        Step::Done(apply) => return Ok(apply),
650                        Step::Failed(e) => return Err(e),
651                        Step::Wait => wait_a_moment(),
652                    }
653                }
654            });
655        }
656        let (id, shared) = self.register(kind, label);
657        self.stepped.push(SteppedEntry { id, shared, step: Box::new(move |p| step(&mut input, p)) });
658        id
659    }
660
661    /// Whether inline stepped jobs are waiting for `drain_ready` calls.
662    pub fn needs_frames(&self) -> bool {
663        !self.stepped.is_empty()
664    }
665
666    /// Whether any job is queued, running or waiting to be applied.
667    pub fn is_busy(&self) -> bool {
668        !self.entries.is_empty()
669    }
670
671    /// Advance inline stepped jobs by up to `steps` steps each, without applying anything. Returns
672    /// whether any of them did work (`false` once all are finished or waiting). Hosts with a frame
673    /// budget call it until the budget is spent, then [`Jobs::drain_ready`].
674    pub fn advance(&mut self, steps: usize) -> bool {
675        self.drive(steps.max(1))
676    }
677
678    /// Advance inline stepped jobs by up to `steps` steps each; whether any did work.
679    fn drive(&mut self, steps: usize) -> bool {
680        let inbox = self.inbox.clone();
681        let mut worked = false;
682        self.stepped.retain_mut(|job| {
683            let status = job.shared.status();
684            if status == Status::Queued && !job.shared.begin() {
685                return false;
686            }
687            if status != Status::Queued && status != Status::Running {
688                return false;
689            }
690            let progress = Progress { shared: job.shared.clone() };
691            for _ in 0..steps {
692                if job.shared.cancel.is_cancelled() {
693                    inbox.finish(job.id, Outcome::Cancelled, &job.shared);
694                    return false;
695                }
696                let outcome = match catch_unwind(AssertUnwindSafe(|| (job.step)(&progress))) {
697                    Ok(Step::Continue) => {
698                        worked = true;
699                        continue;
700                    }
701                    Ok(Step::Wait) => return true,
702                    Ok(Step::Done(apply)) => Outcome::Apply(apply),
703                    Ok(Step::Failed(e)) => Outcome::Failed(e),
704                    Err(p) => Outcome::Failed(format!("internal error ({}); the job was stopped", panic_message(p.as_ref()))),
705                };
706                worked = true;
707                inbox.finish(job.id, outcome, &job.shared);
708                return false;
709            }
710            true
711        });
712        worked
713    }
714
715    /// Call once per frame on the UI thread: advances inline stepped jobs, then applies every
716    /// finished job to `state` in the order they finished (each apply under `catch_unwind`). A
717    /// job cancelled after it finished is not applied. Updates a running job posted
718    /// ([`JobBuilder::spawn_streaming`]) are applied too, in the order they were sent. Returns what
719    /// finished.
720    pub fn drain_ready(&mut self, state: &mut S) -> Vec<JobRecord> {
721        let ready = self.take_ready();
722        let out = ready.apply(state);
723        self.remember(&out);
724        out
725    }
726
727    /// The first half of [`Jobs::drain_ready`], for apps whose `Jobs` lives inside the state the
728    /// applies edit: advances inline stepped jobs and takes everything that is ready, so `self`
729    /// can be released before [`Ready::apply`] borrows the state. Hand the records `apply`
730    /// returns to [`Jobs::remember`]. A job whose outcome was taken is no longer listed.
731    pub fn take_ready(&mut self) -> Ready<S> {
732        if !self.stepped.is_empty() {
733            self.drive(self.steps_per_drain);
734        }
735        let taken: Vec<(JobId, Outcome<S>)> = lock(&self.inbox.items).drain(..).collect();
736        let mut items = Vec::with_capacity(taken.len());
737        for (id, outcome) in taken {
738            let last = !matches!(outcome, Outcome::Update(_));
739            let Some(pos) = self.entries.iter().position(|e| e.id == id) else { continue };
740            let entry = if last {
741                self.entries.remove(pos)
742            } else {
743                let Some(e) = self.entries.get(pos) else { continue };
744                Entry { id: e.id, kind: e.kind, label: e.label.clone(), shared: e.shared.clone() }
745            };
746            items.push(ReadyItem { entry, outcome });
747        }
748        Ready { items }
749    }
750
751    /// Keep finished jobs' records for [`Jobs::recent`] (what [`Ready::apply`] returned).
752    pub fn remember(&mut self, records: &[JobRecord]) {
753        for rec in records {
754            self.history.push_back(rec.clone());
755            if self.history.len() > HISTORY {
756                self.history.pop_front();
757            }
758        }
759    }
760
761    /// Where job `id` is: unfinished jobs from the live list, finished ones from [`Jobs::recent`].
762    pub fn status(&self, id: JobId) -> Option<Status> {
763        if let Some(e) = self.entries.iter().find(|e| e.id == id) {
764            return Some(e.shared.status());
765        }
766        self.history.iter().rev().find(|r| r.id == id).map(|r| r.status)
767    }
768
769    /// Block until job `id` has finished on its worker (it is then ready for
770    /// [`Jobs::drain_ready`]) or `timeout` passes. Returns whether it finished; an unknown or
771    /// already applied job counts as finished. Never call it on a UI thread for long work.
772    pub fn wait(&mut self, id: JobId, timeout: Duration) -> bool {
773        let Some(shared) = self.entries.iter().find(|e| e.id == id).map(|e| e.shared.clone()) else { return true };
774        if self.stepped.iter().any(|j| j.id == id) {
775            let mut budget: u64 = 10_000_000;
776            let deadline = clock::now();
777            while self.stepped.iter().any(|j| j.id == id) && budget > 0 {
778                self.drive(1);
779                budget -= 1;
780                if deadline.is_some_and(|t| t.elapsed() >= timeout) {
781                    break;
782                }
783            }
784        }
785        let deadline = clock::now();
786        let mut items = lock(&self.inbox.items);
787        loop {
788            if !matches!(shared.status(), Status::Queued | Status::Running) {
789                return true;
790            }
791            let left = match deadline {
792                Some(t) => timeout.saturating_sub(t.elapsed()),
793                None => return false,
794            };
795            if left.is_zero() {
796                return false;
797            }
798            items = self.inbox.cv.wait_timeout(items, left.min(Duration::from_millis(50))).unwrap_or_else(PoisonError::into_inner).0;
799        }
800    }
801
802    /// Ask a job to stop. A queued job never starts; a running one stops when it next checks; a
803    /// finished one is not applied. Returns `false` when there is no such unfinished job.
804    pub fn cancel(&mut self, id: JobId) -> bool {
805        let Some(e) = self.entries.iter().find(|e| e.id == id) else { return false };
806        e.shared.cancel.cancel();
807        let was_queued = {
808            let mut s = lock(&e.shared.state);
809            let q = s.status == Status::Queued;
810            if q {
811                s.status = Status::Ready;
812            }
813            q
814        };
815        if was_queued {
816            self.inbox.push(id, Outcome::Cancelled);
817        }
818        e.shared.wake();
819        true
820    }
821
822    /// Cancel every unfinished job.
823    pub fn cancel_all(&mut self) {
824        let ids: Vec<JobId> = self.entries.iter().map(|e| e.id).collect();
825        for id in ids {
826            self.cancel(id);
827        }
828    }
829
830    /// Block until every job has finished on its worker (not yet applied: call
831    /// [`Jobs::drain_ready`] next) or `timeout` passes. Returns whether all finished. For CLIs,
832    /// scripts and tests; never call it on a UI thread.
833    pub fn wait_all(&mut self, timeout: Duration) -> bool {
834        let deadline = clock::now().map(|t| (t, timeout));
835        let mut budget: u64 = 10_000_000;
836        while !self.stepped.is_empty() && budget > 0 {
837            self.drive(1);
838            budget -= 1;
839            if deadline.is_some_and(|(t, d)| t.elapsed() >= d) {
840                break;
841            }
842        }
843        let mut items = lock(&self.inbox.items);
844        loop {
845            let pending = self.entries.iter().any(|e| matches!(e.shared.status(), Status::Queued | Status::Running));
846            if !pending {
847                return true;
848            }
849            let left = match deadline {
850                Some((t, d)) => d.saturating_sub(t.elapsed()),
851                None => return false,
852            };
853            if left.is_zero() {
854                return false;
855            }
856            items = self.inbox.cv.wait_timeout(items, left.min(Duration::from_millis(50))).unwrap_or_else(PoisonError::into_inner).0;
857        }
858    }
859
860    /// Unfinished jobs (queued, running, waiting to be applied), oldest first.
861    pub fn list(&self) -> Vec<JobInfo> {
862        self.entries
863            .iter()
864            .map(|e| {
865                let s = lock(&e.shared.state);
866                let elapsed = s.started.map(|t| t.elapsed().as_secs_f64());
867                let eta = match (elapsed, s.fraction) {
868                    (Some(el), Some(f)) if f > 0.0 && f < 1.0 && el > 0.0 => Some(el * (1.0 - f) / f),
869                    (_, Some(f)) if f >= 1.0 => Some(0.0),
870                    _ => None,
871                };
872                JobInfo {
873                    id: e.id,
874                    kind: e.kind.to_string(),
875                    label: e.label.clone(),
876                    status: s.status,
877                    fraction: s.fraction,
878                    message: s.message.clone(),
879                    elapsed_secs: elapsed,
880                    eta_secs: eta,
881                    cancellable: matches!(s.status, Status::Queued | Status::Running) && !e.shared.cancel.is_cancelled(),
882                }
883            })
884            .collect()
885    }
886
887    /// The last finished jobs, oldest first.
888    pub fn recent(&self) -> impl DoubleEndedIterator<Item = &JobRecord> {
889        self.history.iter()
890    }
891
892    /// `{"active": [JobInfo…], "recent": [JobRecord…]}` for agents and the CLI.
893    pub fn to_json(&self) -> Value {
894        json!({"active": self.list(), "recent": self.history})
895    }
896}
897
898impl<S: 'static> Drop for Jobs<S> {
899    fn drop(&mut self) {
900        for e in &self.entries {
901            e.shared.cancel.cancel();
902        }
903    }
904}