Skip to main content

brep_cam/
cancel.rs

1//! Cancellation and progress at bounded work chunks.
2//!
3//! Long-running generation runs off the UI thread. It polls a [`CancelToken`]
4//! and publishes [`Progress`] through a [`ProgressSink`] only at chunk
5//! boundaries counted in WORK (samples, fibers, passes), never in seconds:
6//! there is no clock here, so the same code runs identically on native and
7//! `wasm32-unknown-unknown`.
8
9use serde::{Deserialize, Serialize};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12
13/// Shared cancellation flag. Clones observe the same flag.
14#[derive(Clone, Debug, Default)]
15pub struct CancelToken {
16    flag: Arc<AtomicBool>,
17}
18
19impl CancelToken {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Request cancellation; every clone reads it at its next checkpoint.
25    pub fn cancel(&self) {
26        self.flag.store(true, Ordering::SeqCst);
27    }
28
29    pub fn is_cancelled(&self) -> bool {
30        self.flag.load(Ordering::SeqCst)
31    }
32
33    /// `Err(Cancelled)` once cancellation was requested.
34    pub fn check(&self) -> Result<(), Cancelled> {
35        if self.is_cancelled() {
36            Err(Cancelled)
37        } else {
38            Ok(())
39        }
40    }
41}
42
43/// The generation was cancelled at a chunk boundary. The caller keeps the
44/// previous accepted result; nothing partial is published.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct Cancelled;
47
48impl std::fmt::Display for Cancelled {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "cancelled")
51    }
52}
53
54impl std::error::Error for Cancelled {}
55
56/// A progress event, published at stage and chunk boundaries.
57#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct Progress {
60    /// Stable stage identifier, e.g. `index-build`, `drop-cutter`, `linking`.
61    pub phase: String,
62    /// Human-readable one-liner.
63    pub message: String,
64    /// Optional detail; a fallback path names itself here.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub detail: Option<String>,
67    /// Work units done so far in this phase.
68    pub current: u64,
69    /// Total work units in this phase (`0` when unknown).
70    pub total: u64,
71    /// The operation this progress belongs to, when there is one.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub operation_id: Option<String>,
74}
75
76/// Receives progress events. Implemented by the worker boundary.
77pub trait ProgressSink {
78    fn progress(&mut self, progress: &Progress);
79}
80
81/// A sink that discards everything.
82#[derive(Clone, Copy, Debug, Default)]
83pub struct NullSink;
84
85impl ProgressSink for NullSink {
86    fn progress(&mut self, _progress: &Progress) {}
87}
88
89/// A sink that records every event, for tests and for batch logs.
90#[derive(Clone, Debug, Default)]
91pub struct RecordingSink {
92    pub events: Vec<Progress>,
93}
94
95impl ProgressSink for RecordingSink {
96    fn progress(&mut self, progress: &Progress) {
97        self.events.push(progress.clone());
98    }
99}
100
101/// Counts work units and tells the caller when a chunk boundary was crossed.
102///
103/// `chunk` is the number of work units between two cancellation checks and
104/// progress publications. The default suits per-sample loops; index builds
105/// use a larger chunk, per-pass loops a smaller one.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct WorkBudget {
108    chunk: u64,
109    used: u64,
110    next_boundary: u64,
111}
112
113impl Default for WorkBudget {
114    fn default() -> Self {
115        Self::new(Self::DEFAULT_CHUNK)
116    }
117}
118
119impl WorkBudget {
120    pub const DEFAULT_CHUNK: u64 = 1024;
121
122    /// A budget that reports a boundary every `chunk` units (`0` is treated as 1).
123    pub fn new(chunk: u64) -> Self {
124        let chunk = chunk.max(1);
125        Self { chunk, used: 0, next_boundary: chunk }
126    }
127
128    pub fn chunk(&self) -> u64 {
129        self.chunk
130    }
131
132    /// Work units consumed since construction.
133    pub fn used(&self) -> u64 {
134        self.used
135    }
136
137    /// Record `units` of work. Returns `true` when a chunk boundary was crossed,
138    /// i.e. the caller should checkpoint now.
139    pub fn step(&mut self, units: u64) -> bool {
140        self.used = self.used.saturating_add(units);
141        if self.used >= self.next_boundary {
142            let crossed = (self.used - self.next_boundary) / self.chunk + 1;
143            self.next_boundary = self.next_boundary.saturating_add(crossed * self.chunk);
144            true
145        } else {
146            false
147        }
148    }
149}
150
151/// Everything an algorithm needs to cooperate with the runner: the token,
152/// the sink and a budget. One per generation call; algorithms borrow it.
153pub struct Runner<'a> {
154    token: CancelToken,
155    sink: &'a mut dyn ProgressSink,
156    budget: WorkBudget,
157    operation_id: Option<String>,
158}
159
160impl<'a> Runner<'a> {
161    pub fn new(token: CancelToken, sink: &'a mut dyn ProgressSink, budget: WorkBudget) -> Self {
162        Self { token, sink, budget, operation_id: None }
163    }
164
165    /// A runner for synchronous callers: never cancels, discards progress.
166    pub fn detached(sink: &'a mut dyn ProgressSink) -> Self {
167        Self::new(CancelToken::new(), sink, WorkBudget::default())
168    }
169
170    pub fn with_operation(mut self, operation_id: impl Into<String>) -> Self {
171        self.operation_id = Some(operation_id.into());
172        self
173    }
174
175    pub fn token(&self) -> &CancelToken {
176        &self.token
177    }
178
179    pub fn budget(&self) -> &WorkBudget {
180        &self.budget
181    }
182
183    /// Publish a stage boundary (always) and check cancellation.
184    pub fn stage(&mut self, phase: &str, message: &str, current: u64, total: u64) -> Result<(), Cancelled> {
185        self.publish(phase, message, None, current, total);
186        self.token.check()
187    }
188
189    /// Publish a stage boundary carrying a detail line (e.g. the fallback used).
190    pub fn stage_detail(
191        &mut self,
192        phase: &str,
193        message: &str,
194        detail: &str,
195        current: u64,
196        total: u64,
197    ) -> Result<(), Cancelled> {
198        self.publish(phase, message, Some(detail.to_string()), current, total);
199        self.token.check()
200    }
201
202    /// Record `units` of work; at a chunk boundary publish progress and check
203    /// cancellation. Cheap between boundaries: one add and one compare.
204    pub fn work(&mut self, units: u64, phase: &str, current: u64, total: u64) -> Result<(), Cancelled> {
205        if self.budget.step(units) {
206            self.publish(phase, phase, None, current, total);
207            self.token.check()?;
208        }
209        Ok(())
210    }
211
212    fn publish(&mut self, phase: &str, message: &str, detail: Option<String>, current: u64, total: u64) {
213        let progress = Progress {
214            phase: phase.to_string(),
215            message: message.to_string(),
216            detail,
217            current,
218            total,
219            operation_id: self.operation_id.clone(),
220        };
221        self.sink.progress(&progress);
222    }
223}
224