Compare commits

...

2 Commits

Author SHA1 Message Date
bherbruck 4c5e19a67e Stop tokens: a copy never stops its design; a stopped job can't decide a refusal
A copy replacing its own design stopped the token it shares with the design
it came from, so that design's later work would be refused. Only the design
itself stops it now. Which face a refusal names no longer depends on whether
the job that found it had been stopped: the refusal is kept with the body,
and a stopped job's probes were all refused, which named the wrong face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:04:45 -05:00
bherbruck 4e8a462777 Desktop: closing a design stops its kernel work; same result on any kernel
A design's jobs now run under a stop token that closing or replacing the
design fires: an operation under it refuses to start, and one running ends
at solidcore's next check. The kernel it ran on is not used again. Calls
that answer from a body itself always finish, since a stopped call can leave
what it borrows half-changed, and what fills a body's shared cells is not
stopped.

Every kernel call starts clean (the same random rays, no stations from
earlier calls), so a design builds the same on any kernel of the pool, down
to its B-rep records. The pool is bounded: at most 8 kernels wait between
calls, and at 256 running calls a new call fails rather than grow or wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:08:42 -05:00
15 changed files with 566 additions and 46 deletions
+44 -4
View File
@@ -349,16 +349,20 @@ impl Ahead {
/// [`Ahead::evaluate`], setting `step` to the number of the command running (from 1), so a
/// front end can say how far a batch has got.
pub fn evaluate_counting(self, commands: &[(String, Value)], step: &std::sync::atomic::AtomicUsize) -> Result<Evaluated> {
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
after.world_state()?.warm_display();
Ok(Evaluated(after.model))
let stop = self.0.stop.clone();
stop.within(|| {
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
after.world_state()?.warm_display();
Ok(Evaluated(after.model))
})
}
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
/// in the shared evaluation cache): the checks a front end makes before a command, such as
/// what a delete takes with it (#2678), computed off its window thread.
pub fn replies(mut self, commands: &[(String, Value)]) -> Vec<Result<Value>> {
commands.iter().map(|(id, params)| self.0.execute(id, params)).collect()
let stop = self.0.stop.clone();
stop.within(|| commands.iter().map(|(id, params)| self.0.execute(id, params)).collect())
}
}
@@ -534,6 +538,10 @@ pub struct Session {
/// not a move of the design's, so the placements the design reports and Capture Position
/// keeps leave it out ([`Session::uncaptured_moves`]).
pub exploded: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
/// Stops the work computing off the window thread for this design, shared with its copies.
stop: solidcraft_kernel::Stop,
/// A copy of a design ([`Session::scratch`]), sharing its token.
is_copy: bool,
world_cache: std::sync::Mutex<Option<WorldCache>>,
/// The design the joints were last solved for, as the solve left it, and the model it read
/// (see [`Session::solve_joints`]).
@@ -794,6 +802,8 @@ impl Session {
/// A session on `doc` evaluated as `model`, with what it hides.
pub(crate) fn with_model(doc: Arc<Document>, visibility: Visibility, model: Model) -> Self {
Session {
stop: Default::default(),
is_copy: false,
saved: doc.clone(),
doc,
model,
@@ -874,6 +884,11 @@ impl Session {
/// design before the batch, which is what the batch promised (#3242). The design is what a
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
pub fn replace_design(&mut self, fresh: Session) {
// Nothing still computing for the design that leaves has anywhere to go. A copy leaves
// the design it came from going, and its token with it.
if !self.is_copy {
self.stop_computing();
}
let auto_project = self.auto_project;
let validation = self.validation();
let single_surface_analysis = self.single_surface_analysis;
@@ -905,6 +920,19 @@ impl Session {
self.replacements = replacements;
}
/// The token over the work running off the window thread for this design, on any of its
/// copies: a job started from a copy runs [`solidcraft_kernel::Stop::within`] it.
pub fn stop_token(&self) -> solidcraft_kernel::Stop {
self.stop.clone()
}
/// This design is going away: the work still computing for it stops at the kernel's next
/// check, and its results go nowhere (they are only ever for this design's evaluation cache).
/// A kernel call that doesn't check runs to its end, on a kernel of its own, holding up nothing.
pub fn stop_computing(&self) {
self.stop.stop();
}
/// Install (or with `None` remove) the check [`Session::execute`] runs before every command,
/// for an app that embeds the engine and decides which commands may run. A refusal comes back
/// as [`EngineError::Refused`] and leaves the session as it was.
@@ -1871,6 +1899,8 @@ impl Session {
/// commands on without touching this one.
pub fn scratch(&self) -> Session {
Session {
stop: self.stop.clone(),
is_copy: true,
doc: self.doc.clone(),
model: self.model.clone(),
undo: Vec::new(),
@@ -1972,6 +2002,16 @@ impl Session {
commands: &[(String, Value)],
marker: Option<Option<usize>>,
original: Option<Session>,
) -> Result<ComputedCommands> {
let stop = self.stop.clone();
stop.within(|| self.compute_commands_under(commands, marker, original))
}
fn compute_commands_under(
self,
commands: &[(String, Value)],
marker: Option<Option<usize>>,
original: Option<Session>,
) -> Result<ComputedCommands> {
let editor_original = original.as_ref().map(|session| session.doc.clone());
let mut computed = self.scratch();
+1
View File
@@ -410,6 +410,7 @@ mod curved_seam_round;
mod flush_join_loft;
mod gltf_import;
mod import_component;
mod kernel_pool;
mod large_step_import;
mod loft_ring;
mod loft_spacing;
+181
View File
@@ -0,0 +1,181 @@
//! A design's bodies are made and used on whichever kernel of the pool is free
//! (`solidcraft_smlib`'s `core::Pool`). What it builds must not depend on which: to the last
//! number, on a new kernel or one that has done much, while another is busy, with bodies made on
//! one kernel going into operations on another, through the evaluation cache, undo and redo.
use super::*;
/// A hole, a round and a chamfer, each building on the last.
fn features() -> Vec<(&'static str, Value)> {
vec![
("solid.box", json!({"length": 40, "width": 30, "height": 20})),
("solid.cylinder", json!({"base": [12, 15, -1], "axis": [0, 0, 1], "radius": 3.4, "height": 30, "operation": "cut"})),
("solid.fillet", json!({"edges": [[0, 0, 10]], "radius": 2})),
("solid.chamfer", json!({"edges": [[20, 0, 20]], "distance": 1})),
]
}
fn join() -> (&'static str, Value) {
("solid.box", json!({"corner": [25, 10, 20], "length": 10, "width": 10, "height": 5, "operation": "join"}))
}
/// Everything a design built, exactly: each body's name, whole B-rep record and triangle count.
fn built(s: &Session) -> Vec<String> {
let st = s.model.state();
st.bodies
.iter()
.map(|b| {
let record = serde_json::to_string(b.body.record()).unwrap();
format!("{} {} {record}", b.name, b.mesh().triangles.len())
})
.collect()
}
fn design(features: &[(&'static str, Value)]) -> Session {
let mut s = Session::default();
for (id, p) in features {
run(&mut s, id, p.clone());
}
s
}
fn assert_same(what: &str, a: &[String], b: &[String]) {
assert_eq!(a.len(), b.len(), "{what}: how many bodies");
assert!(a == b, "{what}: the design built differently");
}
#[test]
fn a_design_rebuilds_the_same_on_any_kernel() {
let f = features();
let first = built(&design(&f));
assert!(!first.is_empty());
// On a new kernel, the one that built it above busy elsewhere.
let held = solidcraft_kernel::hold_a_kernel();
assert_same("a new kernel", &first, &built(&design(&f)));
// And another new one, with both of those out of use.
let held_too = solidcraft_kernel::hold_a_kernel();
assert_same("another new kernel", &first, &built(&design(&f)));
drop((held, held_too));
// On one that has built plenty since.
for _ in 0..3 {
design(&f);
}
assert_same("a kernel that did much before", &first, &built(&design(&f)));
}
/// A curvature round, whose procedural blends take their serials from the kernel: one body made
/// on each of two kernels, the serials of neither mistaken for the other's in the kernel that
/// solves stations for both.
#[test]
fn a_procedural_blend_builds_the_same_on_any_kernel() {
let f = vec![
("solid.box", json!({"length": 40, "width": 30, "height": 20})),
("solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 3, "type": "curvature"})),
];
let first = design(&f);
let held = solidcraft_kernel::hold_a_kernel();
let second = design(&f);
let b = built(&second);
assert_same("a new kernel", &built(&first), &b);
let v = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let (mut first, mut second) = (first, second);
let beside = v(&mut second);
drop(held);
assert_eq!(v(&mut first).to_bits(), beside.to_bits(), "one measured right after the other on one kernel");
}
/// A modeled thread, a screw sweep and its boolean, on a short hole.
#[test]
fn a_thread_builds_the_same_on_any_kernel() {
let f = vec![
("solid.box", json!({"length": 20, "width": 20, "height": 4})),
("solid.cylinder", json!({"base": [10, 10, -1], "axis": [0, 0, 1], "radius": 3.4, "height": 10, "operation": "cut"})),
("solid.thread", json!({"face": [13.4, 10, 2], "modeled": true})),
];
let first = built(&design(&f));
let held = solidcraft_kernel::hold_a_kernel();
assert_same("a new kernel", &first, &built(&design(&f)));
drop(held);
}
/// Bodies from the evaluation cache and the undo stack go on into operations on another kernel:
/// the join's boolean takes a body the held kernel made, undo restores it, redo brings back the
/// join's, and each is what the whole design built in one go on a single kernel gives.
#[test]
fn bodies_from_another_kernel_go_on_through_the_cache_undo_and_redo() {
let f = features();
let mut all = f.clone();
all.push(join());
let (before, after) = (built(&design(&f)), built(&design(&all)));
let mut s = design(&f);
let held = solidcraft_kernel::hold_a_kernel();
let (id, p) = join();
run(&mut s, id, p);
assert_same("joined on another kernel", &after, &built(&s));
run(&mut s, "edit.undo", json!({}));
assert_same("undone", &before, &built(&s));
drop(held);
let held = solidcraft_kernel::hold_a_kernel();
run(&mut s, "edit.redo", json!({}));
assert_same("redone", &after, &built(&s));
// Rebuilt without the cache, every feature again, while that kernel stays out of use.
s.model.no_cache = true;
*s.model.cache.lock().unwrap() = Default::default();
run(&mut s, "timeline.roll_to", json!({"position": 0}));
run(&mut s, "timeline.roll_to", json!({}));
assert_same("rebuilt from the start", &after, &built(&s));
drop(held);
// And the body goes on from there, back on the first kernel.
run(&mut s, "solid.box", json!({"corner": [-5, 0, 0], "length": 5, "width": 5, "height": 5, "operation": "join"}));
let mut more = all.clone();
more.push(("solid.box", json!({"corner": [-5, 0, 0], "length": 5, "width": 5, "height": 5, "operation": "join"})));
assert_same("a further join", &built(&design(&more)), &built(&s));
}
/// A design rebuilt while other work keeps kernels of the pool busy on other threads builds what
/// it builds alone, whichever kernels its calls happen to get.
#[test]
fn a_design_rebuilt_beside_other_work_builds_the_same() {
let f = features();
let alone = built(&design(&f));
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let busy: Vec<_> = (0..3)
.map(|_| {
let (done, f) = (done.clone(), f.clone());
std::thread::spawn(move || {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
design(&f);
}
})
})
.collect();
for _ in 0..3 {
assert_same("beside other work", &alone, &built(&design(&f)));
}
done.store(true, std::sync::atomic::Ordering::Relaxed);
for b in busy {
b.join().unwrap();
}
}
/// A copy that replaces its own design leaves the design it was copied from going: the token over
/// that design's work is stopped only when that design itself closes or is replaced.
#[test]
fn a_copy_replacing_its_design_does_not_stop_the_design_it_came_from() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let token = s.stop_token();
let new = [("file.new".to_string(), json!({}))];
assert!(s.ahead().replies(&new).iter().all(Result::is_ok));
assert!(!token.is_stopped(), "a copy evaluating ahead");
let _ = s.scratch().compute_commands(&new, None);
assert!(!token.is_stopped(), "a copy computing a dialog's commands");
assert!(s.preview(&new).is_ok());
assert!(!token.is_stopped(), "a preview");
// It still works, and the design's own replacement does stop it.
run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 10, "height": 10}));
run(&mut s, "file.new", json!({}));
assert!(token.is_stopped(), "the design itself replaced");
assert!(!s.stop_token().is_stopped());
}
+12 -6
View File
@@ -454,15 +454,18 @@ const MAX_REFUSAL_PROBES: usize = 20_000;
impl Refusal {
/// The refusal of `record` for `error`, with the face SMLib refuses on its own.
fn of(record: &Brep, error: &solidcraft_smlib::Error) -> Refusal {
pub(crate) fn of(record: &Brep, error: &solidcraft_smlib::Error) -> Refusal {
if let solidcraft_smlib::Error::Internal(why) = error {
return Refusal { face: None, surface: None, why: why.clone(), internal: true };
}
let _span = solidcraft_profile::span("kernel", "smlib_refusal_face");
let alone = |f: usize| crate::surfaces::Parts::of(record, &[(f, false)]).and_then(|p| p.canonical()).map(|c| c.brep);
let face = (record.faces.len() <= MAX_REFUSAL_PROBES)
.then(|| (0..record.faces.len()).find(|&f| alone(f).is_ok_and(|r| solidcraft_smlib::Brep::from_record(&r, true).is_err())))
.flatten();
// It is kept with the body for every later reader: a stopped job's probes must not decide it.
let face = solidcraft_smlib::stop::unstoppable(|| {
let alone = |f: usize| crate::surfaces::Parts::of(record, &[(f, false)]).and_then(|p| p.canonical()).map(|c| c.brep);
(record.faces.len() <= MAX_REFUSAL_PROBES)
.then(|| (0..record.faces.len()).find(|&f| alone(f).is_ok_and(|r| solidcraft_smlib::Brep::from_record(&r, true).is_err())))
.flatten()
});
let surface = face.and_then(|f| record.faces.get(f)).map(|f| SurfaceKind::of(&f.surface));
Refusal { face, surface, why: error.to_string(), internal: false }
}
@@ -545,7 +548,10 @@ impl Body {
pub fn smlib(&self) -> Result<&solidcraft_smlib::Brep> {
self.require_brep("an SMLib operation")?;
self.smlib
.get_or_init(|| solidcraft_smlib::Brep::from_record(&self.record, true).map_err(|e| Refusal::of(&self.record, &e)))
.get_or_init(|| {
solidcraft_smlib::stop::unstoppable(|| solidcraft_smlib::Brep::from_record(&self.record, true))
.map_err(|e| Refusal::of(&self.record, &e))
})
.as_ref()
.map_err(
|r| if r.internal { KernelError::Internal(format!("SMLib: {}", r.why)) } else { KernelError::Failed(format!("SMLib: {}", r.why)) },
+2
View File
@@ -215,6 +215,8 @@ pub use prism_curve::{PlanarPiece, PlanarSpline};
pub use prism_measure::{PrismSection, certified_prism_equivalent, certified_prism_section};
pub use repair::{Hole, RebuiltFace, RefusedFace, RepairOptions, RepairReport, SewnGap, repair};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching};
pub use solidcraft_smlib::hold_a_kernel;
pub use solidcraft_smlib::stop::Stop;
pub use solidcraft_smlib::trace as smlib_trace;
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
+10 -5
View File
@@ -28,9 +28,10 @@ fn pool() -> Option<&'static rayon::ThreadPool> {
/// thread. Waking the pool's workers costs tens of microseconds, so callers ask for it only
/// when the items carry more work than that (decided from the work, not the item count).
pub(crate) fn map<T: Sync, R: Send>(what: &'static str, items: &[T], parallel: bool, f: impl Fn(&T) -> Result<R> + Sync) -> Vec<Result<R>> {
// The workers run under the caller's validation mode (`crate::validation`).
// The workers run under the caller's validation mode (`crate::validation`) and stop token.
let scope = crate::validation::current();
let one = |item: &T| crate::validation::within(scope.clone(), || guard(what, || f(item)));
let stop = solidcraft_smlib::stop::current();
let one = |item: &T| solidcraft_smlib::stop::scoped(stop.clone(), || crate::validation::within(scope.clone(), || guard(what, || f(item))));
// The browser build has no worker pool: everything runs on the calling thread.
#[cfg(target_arch = "wasm32")]
let _ = parallel;
@@ -87,7 +88,8 @@ pub fn map_with_local_pools<T: Sync, R: Send>(items: &[T], max_threads: usize, f
}
let pool = cell.get().ok_or_else(|| crate::KernelError::Failed("missing mesh worker".into()))?;
let scope = crate::validation::current();
pool.install(|| crate::validation::within(scope, || guard("mesh worker", || f(item))))
let stop = solidcraft_smlib::stop::current();
pool.install(|| solidcraft_smlib::stop::scoped(stop, || crate::validation::within(scope, || guard("mesh worker", || f(item)))))
})
};
#[cfg(target_arch = "wasm32")]
@@ -101,9 +103,12 @@ pub fn map_with_local_pools<T: Sync, R: Send>(items: &[T], max_threads: usize, f
/// `f` over `items` as [`for_each_on_threads`] runs it, results in item order (`None` where an
/// item panicked).
pub fn map_on_threads<T: Sync, R: Send>(items: &[T], max_threads: usize, f: impl Fn(&T) -> R + Sync) -> Vec<Option<R>> {
// The threads run under the caller's validation mode (`crate::validation`).
// The threads run under the caller's validation mode (`crate::validation`) and stop token.
let scope = crate::validation::current();
let one = |item: &T| crate::validation::within(scope.clone(), || guard("parallel item", || Ok(f(item))).ok());
let stop = solidcraft_smlib::stop::current();
let one = |item: &T| {
solidcraft_smlib::stop::scoped(stop.clone(), || crate::validation::within(scope.clone(), || guard("parallel item", || Ok(f(item))).ok()))
};
#[cfg(not(target_arch = "wasm32"))]
{
use std::sync::atomic::{AtomicUsize, Ordering};
+14
View File
@@ -2439,3 +2439,17 @@ fn a_box_cut_round_a_small_hole_of_a_long_plate_keeps_the_hole() {
}
}
}
/// A refusal is kept with its body for every later reader: which face it names doesn't depend on
/// whether the job that found it had been stopped, whose probes would all be refused.
#[test]
fn a_stopped_job_does_not_decide_which_face_a_refusal_names() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let why = solidcraft_smlib::Error::Argument;
let plain = crate::body::Refusal::of(body.record(), &why);
assert_eq!(plain.face, None, "every face of a box is taken on its own");
let stop = crate::Stop::new();
stop.stop();
let stopped = stop.within(|| crate::body::Refusal::of(body.record(), &why));
assert_eq!(stopped.face, plain.face);
}
+105 -23
View File
@@ -11,7 +11,7 @@ use std::sync::{Mutex, PoisonError};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::vector3d::Vec3;
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
use solidcore::kernel::{CancelToken, Kernel, KernelOptions, Rng, UpstreamFixes};
use solidcore::mass::integrator::properties::{PropertyAttributes, brep_volume};
use solidcore::primitive::api_primitives as primitives;
@@ -412,7 +412,8 @@ const HELD_BACK: &[&str] = &["fillet_refuse_rounds_breaking_through"];
/// How far apart two kernels' procedural blend serials start. A body keeps the serials of the
/// kernel that made it and may be used on any other, whose memo of solved stations is keyed by
/// serial alone: the ranges keep one definition's stations from answering for another's.
/// serial alone: the ranges keep one definition's stations from answering for another's. A serial
/// only names a definition, so which range one comes from changes no answer.
pub(crate) const BLEND_SERIALS_PER_KERNEL: u64 = 1 << 40;
/// A new kernel. Each takes the next range of blend serials, the first starting at 0.
@@ -424,30 +425,85 @@ fn fresh() -> Kernel {
k
}
/// Most kernels kept for later calls; those over it are dropped when their call ends.
const MOST_IDLE: usize = 8;
/// The kernels no call is using, the last used on top. The lock is only held to take or put back
/// a kernel, never during a call: a call that runs long (or never returns) holds up nothing but
/// its own body, so one design's long operation never delays another's. Calls one after another
/// all get the same kernel, as a single kernel would serve them; one that starts while another
/// runs gets one of its own (solidcore keeps no global state, and a body's identities are valid
/// only in the kernel that issued them, so any kernel takes any body).
static IDLE: Mutex<Vec<Kernel>> = Mutex::new(Vec::new());
fn take_kernel() -> Kernel {
IDLE.lock().unwrap_or_else(PoisonError::into_inner).pop().unwrap_or_else(fresh)
/// The kernels calls run on. The lock is only held to take or put back a kernel, never during a
/// call: a call that runs long (or never returns) holds up nothing but its own body, so one
/// design's long operation never delays another's. Calls one after another all get the same
/// kernel; one that starts while another runs gets one of its own (solidcore keeps no global
/// state, and a body's identities are valid only in the kernel that issued them, so any kernel
/// takes any body).
///
/// A call stuck for good keeps its kernel and the thread it runs on, and costs nothing more: at
/// most `most_idle` kernels wait between calls, however many calls once ran side by side. A job
/// that starts again and sticks again holds one more each time, so at `most_running` calls a new
/// call fails, saying so, rather than grow without end or wait for calls that may never return.
pub(crate) struct Pool<K> {
pub(crate) idle: Vec<K>,
pub(crate) running: usize,
pub(crate) most_idle: usize,
pub(crate) most_running: usize,
}
fn put_kernel(k: Kernel) {
let mut idle = IDLE.lock().unwrap_or_else(PoisonError::into_inner);
if idle.len() < MOST_IDLE {
idle.push(k);
impl<K> Pool<K> {
/// A kernel for a call, the last used first; `None` when too many are running.
pub(crate) fn take(&mut self, make: impl FnOnce() -> K) -> Option<K> {
if self.running >= self.most_running {
return None;
}
self.running += 1;
Some(self.idle.pop().unwrap_or_else(make))
}
/// A call ended, handing back its kernel if it is fit to use again.
pub(crate) fn put(&mut self, kernel: Option<K>) {
self.running = self.running.saturating_sub(1);
if let Some(kernel) = kernel
&& self.idle.len() < self.most_idle
{
self.idle.push(kernel);
}
}
}
/// Most calls at once. Far above what any work needs: an operation runs on at most 16 threads.
const MOST_RUNNING: usize = 256;
static POOL: Mutex<Pool<Kernel>> = Mutex::new(Pool { idle: Vec::new(), running: 0, most_idle: 8, most_running: MOST_RUNNING });
fn take_kernel() -> Option<Kernel> {
POOL.lock().unwrap_or_else(PoisonError::into_inner).take(fresh)
}
fn put_kernel(k: Option<Kernel>) {
POOL.lock().unwrap_or_else(PoisonError::into_inner).put(k);
}
/// What a call must not find from the calls before it: the same operation on the same bodies
/// answers the same on any kernel, however much that kernel did first. (Its draws of random rays,
/// the stations it remembers; marks, identities and what is only kept to reuse memory change no
/// answer. Tolerances and fixes are the same on every kernel.)
fn start_clean(k: &mut Kernel) {
k.rng = Rng::new(KernelOptions::default().seed);
*k.blend_station_memo.borrow_mut() = Default::default();
k.fillet_point_step.set(None);
k.upstream_ub_passed.clear();
}
/// A kernel kept out of use until dropped ([`crate::hold_a_kernel`]).
pub struct HeldKernel(Option<Kernel>);
impl Drop for HeldKernel {
fn drop(&mut self) {
put_kernel(self.0.take());
}
}
pub(crate) fn hold_a_kernel() -> HeldKernel {
HeldKernel(take_kernel())
}
/// Runs `f` on a kernel of its own. A panic in solidcore is caught: the kernel, which it may have
/// left half-updated, is replaced by a fresh one and the call fails with [`Error::Internal`].
/// left half-updated, is dropped and the call fails with [`Error::Internal`]. A token over this
/// thread's work ([`crate::stop`]) stops it.
pub(crate) fn run<T: dump::Answer>(call: &'static str, f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>) -> Result<T> {
run_on(call, &[], f)
}
@@ -470,7 +526,26 @@ pub(crate) fn run_on<T: dump::Answer>(
inputs: dump::Inputs<'_>,
f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>,
) -> Result<T> {
let mut k = take_kernel();
let stop = crate::stop::token();
if stop.as_ref().is_some_and(CancelToken::is_cancelled) {
return Err(Error::Solidcore(format!("{call}: cancelled")));
}
call_on(call, inputs, stop, f)
}
/// [`run_on`], stopped by `stop`.
fn call_on<T: dump::Answer>(
call: &'static str,
inputs: dump::Inputs<'_>,
stop: Option<CancelToken>,
f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>,
) -> Result<T> {
let Some(mut k) = take_kernel() else {
log::warn!(target: "smlib", "{call}: refused, {MOST_RUNNING} kernel calls are still running");
return Err(Error::Solidcore(format!("{call}: {MOST_RUNNING} kernel calls are still running")));
};
start_clean(&mut k);
k.cancel = stop.clone();
// A profiler span per call: where the kernel's time goes.
let _span = solidcraft_profile::span("solidcore", call);
let in_flight = dump::in_flight(call);
@@ -495,6 +570,11 @@ pub(crate) fn run_on<T: dump::Answer>(
}
};
drop(in_flight);
// Stopped, it is no failure to look into, and solidcore may have left the kernel part way.
if stop.is_some_and(|s| s.is_cancelled()) {
put_kernel(None);
return r;
}
let why = match &r {
Ok(v) => v.refusal(),
Err(e) => Some(e.to_string()),
@@ -503,7 +583,8 @@ pub(crate) fn run_on<T: dump::Answer>(
// The kernel the call ran on, or the fresh one that replaced it after a panic.
dump::failed(&k, call, &why, inputs);
}
put_kernel(k);
k.cancel = None;
put_kernel(Some(k));
r
}
@@ -530,7 +611,8 @@ pub(crate) fn with_body<T: dump::Answer>(
if body.0.is_poisoned() {
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
}
run_on(call, &[("brep", body)], |k| f(k, &mut body.0.lock().unwrap_or_else(PoisonError::into_inner)))
// No token stops it: stopped part way, solidcore may leave the body it borrows half-changed.
call_on(call, &[("brep", body)], None, |k| f(k, &mut body.0.lock().unwrap_or_else(PoisonError::into_inner)))
}
/// An axis-aligned box from its lower corner (`SmApiCreateBox`, as `smlib_make_box` checks it:
+8
View File
@@ -19,6 +19,7 @@ pub mod pcurves;
mod sheets;
mod split;
pub mod ssi;
pub mod stop;
pub mod sweeps;
pub mod trace;
pub mod trim;
@@ -232,6 +233,13 @@ pub fn current_call() -> Option<String> {
core::dump::current()
}
/// Keeps one of the kernels calls run on out of use until dropped: the next calls run on another.
/// For tests of what happens when a design's bodies come from a kernel that is busy elsewhere.
#[doc(hidden)]
pub fn hold_a_kernel() -> core::HeldKernel {
core::hold_a_kernel()
}
/// Is this thread inside a solidcore call? A panic there is caught and becomes
/// [`Error::Internal`]; crash reports mark it recovered.
pub fn in_call() -> bool {
+1 -1
View File
@@ -129,7 +129,7 @@ impl Spec {
impl Surface {
/// solidcore's surface, made the first time it is needed.
fn core(&self) -> Result<&crate::core::split::Tool> {
self.core.get_or_init(|| crate::core::ssi::surface(&self.spec)).as_ref().map_err(Clone::clone)
self.core.get_or_init(|| crate::stop::unstoppable(|| crate::core::ssi::surface(&self.spec))).as_ref().map_err(Clone::clone)
}
pub(crate) fn spec(&self) -> &Spec {
+71
View File
@@ -0,0 +1,71 @@
//! Stopping the kernel calls of work nobody waits for any more: the jobs of a design that closed.
//! Work runs [`Stop::within`] its design's token; once the token is stopped, each operation under
//! it refuses to start, and one running stops when solidcore next checks ([`solidcore::kernel::Kernel::cancel`]).
//!
//! A stopped operation is an error like any other, and the caller's bodies are as they were: only
//! operations that build on copies stop. Calls that answer from a body itself ([`crate::core::with_body`],
//! measuring, say) always run to their end, since solidcore may leave what a stopped call borrows
//! half-changed.
use std::cell::RefCell;
use solidcore::kernel::CancelToken;
thread_local! {
static CURRENT: RefCell<Option<Stop>> = const { RefCell::new(None) };
}
/// Stops the kernel work running under it, on every thread it reached.
#[derive(Clone, Debug, Default)]
pub struct Stop(CancelToken);
impl Stop {
pub fn new() -> Stop {
Stop::default()
}
/// Stop the work under this token, now and from now on.
pub fn stop(&self) {
self.0.cancel();
}
pub fn is_stopped(&self) -> bool {
self.0.is_cancelled()
}
/// Run `f` with this token over the kernel work it does on this thread.
pub fn within<R>(&self, f: impl FnOnce() -> R) -> R {
scoped(Some(self.clone()), f)
}
}
/// The token over this thread's work, to hand to the worker threads it starts.
pub fn current() -> Option<Stop> {
CURRENT.with(|c| c.borrow().clone())
}
/// Run `f` under `stop` (`None`: under none), as [`current`] was on the thread that started it.
pub fn scoped<R>(stop: Option<Stop>, f: impl FnOnce() -> R) -> R {
let previous = CURRENT.with(|c| c.replace(stop));
struct Restore(Option<Option<Stop>>);
impl Drop for Restore {
fn drop(&mut self) {
if let Some(previous) = self.0.take() {
CURRENT.with(|c| *c.borrow_mut() = previous);
}
}
}
let _restore = Restore(Some(previous));
f()
}
/// Run `f` so that no token stops it: for what fills a cell every later user of a body reads,
/// which must not keep a stopped job's error.
pub fn unstoppable<R>(f: impl FnOnce() -> R) -> R {
scoped(None, f)
}
/// solidcore's token of the work on this thread.
pub(crate) fn token() -> Option<CancelToken> {
CURRENT.with(|c| c.borrow().as_ref().map(|s| s.0.clone()))
}
+81 -1
View File
@@ -78,7 +78,7 @@ fn calls_from_many_threads_are_serialised() {
static DUMP_DIR_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Entry points that ask no kernel, so have nothing to say here.
const NO_KERNEL: [&str; 13] = [
const NO_KERNEL: [&str; 14] = [
"inertia_tensor",
"is_valid",
"failed_tests",
@@ -92,6 +92,7 @@ const NO_KERNEL: [&str; 13] = [
"word",
"words",
"in_call",
"hold_a_kernel",
];
const ANSWERING: [&str; 63] = [
@@ -510,3 +511,82 @@ fn a_panic_beside_a_running_call_costs_only_its_own_kernel() {
release.send(()).unwrap();
held.join().unwrap().unwrap();
}
/// A stuck call keeps its kernel and nothing more: the pool never holds more than its limit of
/// idle kernels however many calls ran side by side, and once as many calls as it allows are
/// stuck, a new call is refused rather than growing it or waiting.
#[test]
fn the_kernel_pool_stays_bounded_when_calls_stick() {
let mut pool = core::Pool { idle: Vec::new(), running: 0, most_idle: 1, most_running: 3 };
let mut made = 0;
let mut make = || {
made += 1;
made
};
let (a, b, c) = (pool.take(&mut make), pool.take(&mut make), pool.take(&mut make));
assert_eq!((a, b, c), (Some(1), Some(2), Some(3)), "side by side, each on its own");
assert_eq!(pool.take(&mut make), None, "refused, not waiting, not one more");
pool.put(a);
assert_eq!(pool.take(&mut make), Some(1), "room again, its kernel used again");
// The other two never come back here: they still count, and hold no idle slot.
pool.put(Some(1));
pool.put(b);
assert_eq!(pool.idle, [1], "no more idle than allowed");
assert_eq!(pool.running, 1, "one still stuck");
// A stopped call's kernel is not used again.
pool.put(None);
assert_eq!(pool.running, 0);
assert_eq!(pool.idle, [1]);
}
/// A stop reaches a running call at solidcore's next check and later calls before they start;
/// the kernel it ran on is not used again; calls of other work and answers from a body go on.
#[test]
fn a_stopped_call_ends_at_its_next_check_and_holds_up_nothing() {
let stop = stop::Stop::new();
let (started, is_started) = std::sync::mpsc::channel();
let running = {
let stop = stop.clone();
std::thread::spawn(move || {
stop.within(|| {
let first: Result<()> = core::run("stopped_call", |k| {
let _ = started.send(());
// A long solidcore loop, until it is stopped.
loop {
k.check_cancelled("stopped_call")?;
std::thread::yield_now();
}
});
let next = Brep::make_box([0.0; 3], [1.0; 3]);
(first, next.map(|_| ()))
})
})
};
is_started.recv().unwrap();
let body = answered(|| Brep::make_box([0.0; 3], [10.0; 3]).unwrap());
stop.stop();
let (first, next) = answered(move || running.join().unwrap());
assert!(first.is_err_and(|e| e.to_string().contains("cancel")));
assert!(next.is_err_and(|e| e.to_string().contains("cancelled")), "refused before it starts");
// Under a stopped token, a body still answers, and is as it was.
let v = stop.within(|| body.volume(1e-7)).unwrap();
assert!((v - 1000.0).abs() < 1e-6, "{v}");
assert!(stop.within(|| body.boolean(&body, BooleanOp::Union)).is_err(), "an operation is refused");
// What fills a shared cell is not stopped.
assert!(stop.within(|| stop::unstoppable(|| Brep::make_box([0.0; 3], [1.0; 3]))).is_ok());
// And nothing else is affected.
let v = body.boolean(&body, BooleanOp::Union).unwrap().unwrap().volume(1e-7).unwrap();
assert!((v - 1000.0).abs() < 1e-6, "{v}");
}
/// Every call starts with the same draws of random rays, whatever its kernel did before: what
/// depends on them answers the same on any kernel of the pool.
#[test]
fn every_call_starts_clean_on_any_kernel() {
let draw = |k: &mut solidcore::kernel::Kernel| Ok(k.rng.next_f64());
let first = core::run("draw", draw).unwrap();
assert_eq!(core::run("draw", draw).unwrap(), first, "the same kernel, later");
let held = hold_a_kernel();
assert_eq!(core::run("draw", draw).unwrap(), first, "another kernel");
drop(held);
}
+5 -4
View File
@@ -572,10 +572,11 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
let next = if i + 1 < n { i + 1 } else { i - 1 };
switch(app, next);
}
if let Some(slot) = app.docs.slots.remove(i)
&& let Some(a) = slot.autosave
{
a.discard();
if let Some(slot) = app.docs.slots.remove(i) {
slot.session.stop_computing();
if let Some(a) = slot.autosave {
a.discard();
}
}
if app.docs.active > i {
app.docs.active -= 1;
@@ -725,6 +725,33 @@ fn a_design_closed_while_its_history_computes_holds_up_nothing() {
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.extrude"));
}
/// Closing a design or replacing it stops the kernel work still computing for it; showing another
/// tab doesn't, since that design is still there to come back to.
#[test]
fn closing_a_design_stops_what_it_has_computing_and_a_tab_switch_does_not() {
let mut h = block_boss_hole();
let first = h.app.session.stop_token();
let copy = h.app.session.ahead();
crate::documents::new_tab(&mut h.app);
assert!(!first.is_stopped(), "only another tab shown");
let second = h.app.session.stop_token();
assert!(!second.is_stopped());
crate::documents::close(&mut h.app, 0, true);
assert!(first.is_stopped(), "the first design closed");
assert!(!second.is_stopped(), "this design goes on");
assert!(
copy.replies(&[("solid.box".to_string(), json!({"length": 1, "width": 1, "height": 1}))]).iter().all(Result::is_err),
"its job gets nowhere"
);
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.app.run("file.new", json!({})).unwrap();
assert!(second.is_stopped(), "replaced in its tab");
assert!(!h.app.session.stop_token().is_stopped());
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
crate::documents::close(&mut h.app, 0, true);
assert!(!h.app.session.stop_token().is_stopped(), "the blank design that took the last tab's place");
}
/// A design's history command stays with its tab: another tab doesn't wait for it and doesn't get
/// its result, even once it is in. Shown again, the design picks it up.
#[test]
+4 -2
View File
@@ -438,8 +438,10 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
// Watched by `progress`, so a job left behind for other inputs skips its remaining features.
let watched = progress.clone();
let spawned = std::thread::Builder::new().name("preview".into()).spawn(move || {
let job = solidcraft_engine::doc::progress::watch(watched, || {
if tool { compute_tool(&scratch, &cmds, colors) } else { compute(&scratch, &cmds, colors) }
let job = scratch.stop_token().within(|| {
solidcraft_engine::doc::progress::watch(watched, || {
if tool { compute_tool(&scratch, &cmds, colors) } else { compute(&scratch, &cmds, colors) }
})
});
let _ = tx.send(job);
});