Skip to content

Commit e3af8c7

Browse files
committed
Add ExecutionContext: cancellation + progress for boolean evaluation
Binds the upstream `ManifoldExecutionContext` C API surface added in manifold's master post-v3.4.1 (`5f95a3a`) and now natively supported in the wasm-uu lane via shim v0.4.0-alpha.1 (PR #40). Why it matters: manifold operations are lazy. Building a CSG tree is cheap; the actual evaluation runs when something queries the result (num_tri, mesh extraction, status). For nontrivial trees this can be seconds to minutes, and there was previously no way to cooperatively abort it. ExecutionContext fixes that — pass it to a new `status_with_context` method and another thread can `cancel()` it. Use case examples: - Web demos / interactive UIs: cancel a stale boolean when the user changes inputs. - Server-side: timeout long-running ops without dropping the worker. - Batch processing: progress observation for status reporting. C API surface added (10 items in manifold-csg-sys): - `ManifoldExecutionContext` opaque type - 4 lifecycle: `_size`, `_alloc`, `_destruct`, `_delete` - 1 constructor: `manifold_execution_context(mem)` - 3 control: `_cancel`, `_cancelled`, `_progress` - 1 cancellable trigger: `manifold_status_with_context` Safe wrapper (manifold-csg): - New `execution` module with `ExecutionContext` struct (`Send + Sync`, justified by upstream's "safe to read/write from any thread" guarantee in the C header). - Methods: `new()`, `cancel()`, `is_cancelled()`, `progress()`, `Drop`. - `Manifold::status_with_context(&self, &ExecutionContext) -> ManifoldError` for the cancellable evaluation trigger. - 5 integration tests: initial state, sticky cancel, cross-thread cancel via Arc, status_with_context happy path, status_with_context with already-cancelled context. - API_COVERAGE.md updated with a new "Execution Context" section. Verified end-to-end: - 219 host integration tests pass (was 214; +5 new ExecutionContext) - wasm-uu CI-equivalent build clean; produced wasm has zero unexpected imports (confirms ExecutionContext links cleanly through the shim's v0.4.0-alpha.1 helper) - cargo fmt + cargo clippy clean Versions stay at 0.1.8 / 3.4.107 (pre-bumped by #40, this addition is additive so the bump still covers it).
1 parent 93300e1 commit e3af8c7

6 files changed

Lines changed: 270 additions & 3 deletions

File tree

API_COVERAGE.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ polygon helpers) are used internally and don't need direct safe wrappers.
9090
| `manifold_original_id` | [`Manifold::original_id`](crates/manifold-csg/src/manifold.rs#L934) |
9191
| `manifold_min_gap` | [`Manifold::min_gap`](crates/manifold-csg/src/manifold.rs#L941) |
9292
| `manifold_status` | Internal |
93+
| `manifold_status_with_context` | [`Manifold::status_with_context`](crates/manifold-csg/src/manifold.rs) |
9394
| `manifold_as_original` | [`Manifold::as_original`](crates/manifold-csg/src/manifold.rs) |
9495
| `manifold_manifold_size` | Not needed (alloc size) |
9596
| `manifold_manifold_pair_size` | Not needed (alloc size) |
@@ -330,6 +331,16 @@ Used internally by batch operations, decompose, etc.
330331
| `manifold_cross_section_vec_set` | Not used |
331332
| `manifold_cross_section_vec_size` | Not used |
332333

334+
## Execution Context (cancellation + progress)
335+
336+
| C API function | Safe wrapper |
337+
|---|---|
338+
| `manifold_execution_context` | [`ExecutionContext::new`](crates/manifold-csg/src/execution.rs) |
339+
| `manifold_execution_context_cancel` | [`ExecutionContext::cancel`](crates/manifold-csg/src/execution.rs) |
340+
| `manifold_execution_context_cancelled` | [`ExecutionContext::is_cancelled`](crates/manifold-csg/src/execution.rs) |
341+
| `manifold_execution_context_progress` | [`ExecutionContext::progress`](crates/manifold-csg/src/execution.rs) |
342+
| `manifold_execution_context_size` | Not needed (alloc size) |
343+
333344
## Allocation & Deallocation (Internal)
334345

335346
Every `manifold_alloc_*` and `manifold_delete_*`/`manifold_destruct_*` function
@@ -338,9 +349,9 @@ and `Drop` implementations.
338349

339350
| Function group | Count |
340351
|---|---|
341-
| `manifold_alloc_*` | 12 |
342-
| `manifold_delete_*` | 12 |
343-
| `manifold_destruct_*` | 12 |
352+
| `manifold_alloc_*` | 13 |
353+
| `manifold_delete_*` | 13 |
354+
| `manifold_destruct_*` | 13 |
344355

345356
---
346357

crates/manifold-csg-sys/src/lib.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ pub struct ManifoldRayHitVec {
5555
_private: [u8; 0],
5656
}
5757

58+
/// Opaque handle to a manifold3d `ExecutionContext` — observes progress
59+
/// and allows cooperative cancellation of long-running boolean evaluations.
60+
/// The C API documents it as safe to read/write from any thread.
61+
#[repr(C)]
62+
#[derive(Debug)]
63+
pub struct ManifoldExecutionContext {
64+
_private: [u8; 0],
65+
}
66+
5867
/// Opaque handle to a manifold3d `Triangulation` result.
5968
#[repr(C)]
6069
#[derive(Debug)]
@@ -269,6 +278,7 @@ unsafe extern "C" {
269278
pub fn manifold_rect_size() -> usize;
270279
pub fn manifold_triangulation_size() -> usize;
271280
pub fn manifold_ray_hit_vec_size() -> usize;
281+
pub fn manifold_execution_context_size() -> usize;
272282

273283
// ── Allocation ─────────────────────────────────────────────────────
274284

@@ -284,6 +294,7 @@ unsafe extern "C" {
284294
pub fn manifold_alloc_rect() -> *mut ManifoldRect;
285295
pub fn manifold_alloc_triangulation() -> *mut ManifoldTriangulation;
286296
pub fn manifold_alloc_ray_hit_vec() -> *mut ManifoldRayHitVec;
297+
pub fn manifold_alloc_execution_context() -> *mut ManifoldExecutionContext;
287298

288299
// ── Destruction (destruct only, does not free) ─────────────────────
289300

@@ -299,6 +310,7 @@ unsafe extern "C" {
299310
pub fn manifold_destruct_rect(b: *mut ManifoldRect);
300311
pub fn manifold_destruct_triangulation(m: *mut ManifoldTriangulation);
301312
pub fn manifold_destruct_ray_hit_vec(v: *mut ManifoldRayHitVec);
313+
pub fn manifold_destruct_execution_context(ctx: *mut ManifoldExecutionContext);
302314

303315
// ── Deletion (destruct + free) ─────────────────────────────────────
304316

@@ -314,6 +326,7 @@ unsafe extern "C" {
314326
pub fn manifold_delete_rect(b: *mut ManifoldRect);
315327
pub fn manifold_delete_triangulation(m: *mut ManifoldTriangulation);
316328
pub fn manifold_delete_ray_hit_vec(v: *mut ManifoldRayHitVec);
329+
pub fn manifold_delete_execution_context(ctx: *mut ManifoldExecutionContext);
317330

318331
// ── Polygons ───────────────────────────────────────────────────────
319332

@@ -1005,6 +1018,12 @@ unsafe extern "C" {
10051018

10061019
pub fn manifold_is_empty(m: *const ManifoldManifold) -> c_int;
10071020
pub fn manifold_status(m: *const ManifoldManifold) -> ManifoldError;
1021+
/// Variant of [`manifold_status`] that observes progress and allows
1022+
/// cancellation via the [`ManifoldExecutionContext`].
1023+
pub fn manifold_status_with_context(
1024+
m: *const ManifoldManifold,
1025+
ctx: *mut ManifoldExecutionContext,
1026+
) -> ManifoldError;
10081027
pub fn manifold_num_vert(m: *const ManifoldManifold) -> usize;
10091028
pub fn manifold_num_edge(m: *const ManifoldManifold) -> usize;
10101029
pub fn manifold_num_tri(m: *const ManifoldManifold) -> usize;
@@ -1079,6 +1098,24 @@ unsafe extern "C" {
10791098
/// Get a hit by index from a ray hit vector.
10801099
pub fn manifold_ray_hit_vec_get(v: *const ManifoldRayHitVec, idx: usize) -> ManifoldRayHit;
10811100

1101+
// ── Execution context (cancel + progress) ───────────────────────────
1102+
1103+
/// Construct an `ExecutionContext` in pre-allocated memory. Pass to
1104+
/// [`manifold_status_with_context`].
1105+
pub fn manifold_execution_context(
1106+
mem: *mut ManifoldExecutionContext,
1107+
) -> *mut ManifoldExecutionContext;
1108+
1109+
/// Request cancellation of any boolean evaluation observing this context.
1110+
/// Sticky: once cancelled, the context stays cancelled.
1111+
pub fn manifold_execution_context_cancel(ctx: *mut ManifoldExecutionContext);
1112+
1113+
/// Returns nonzero if the context has been cancelled.
1114+
pub fn manifold_execution_context_cancelled(ctx: *mut ManifoldExecutionContext) -> c_int;
1115+
1116+
/// Progress in [0.0, 1.0] for an in-flight evaluation observing this context.
1117+
pub fn manifold_execution_context_progress(ctx: *mut ManifoldExecutionContext) -> f64;
1118+
10821119
// ── Bounding box ────────────────────────────────────────────────────
10831120

10841121
pub fn manifold_bounding_box(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//! Cooperative cancellation + progress observation for long-running boolean evaluations.
2+
//!
3+
//! Manifold operations are lazy: building a CSG tree is cheap, and the
4+
//! actual evaluation happens when you query results (e.g., via
5+
//! [`Manifold::status_with_context`](crate::Manifold::status_with_context),
6+
//! `num_tri`, mesh extraction, etc.). An [`ExecutionContext`] lets you
7+
//! observe an in-flight evaluation from another thread and ask it to stop
8+
//! early.
9+
//!
10+
//! Cancellation is **sticky** (once cancelled, stays cancelled) and granular
11+
//! per-boolean (the upstream kernel checks the cancel flag at boolean
12+
//! boundaries; it doesn't interrupt a single boolean mid-flight). Progress
13+
//! is reported as a fraction in `[0.0, 1.0]`.
14+
//!
15+
//! The C API documents the underlying `ExecutionContext` as safe to read
16+
//! and write from any thread, so [`ExecutionContext`] is `Send` + `Sync`
17+
//! and can be wrapped in [`Arc`](std::sync::Arc) to share between the
18+
//! evaluator thread and a controller/observer thread.
19+
//!
20+
//! ```no_run
21+
//! use std::sync::Arc;
22+
//! use std::thread;
23+
//! use std::time::Duration;
24+
//! use manifold_csg::{ExecutionContext, Manifold};
25+
//!
26+
//! let ctx = Arc::new(ExecutionContext::new());
27+
//! let cancel = Arc::clone(&ctx);
28+
//!
29+
//! // Cancel the evaluation if it takes longer than 100ms.
30+
//! thread::spawn(move || {
31+
//! thread::sleep(Duration::from_millis(100));
32+
//! cancel.cancel();
33+
//! });
34+
//!
35+
//! let result = Manifold::cube(1.0, 1.0, 1.0, true);
36+
//! let status = result.status_with_context(&ctx);
37+
//! // `status` will be `NoError` for trivial work that finishes before
38+
//! // cancel fires; for a heavy boolean tree it would surface cancellation.
39+
//! # let _ = status;
40+
//! ```
41+
//!
42+
//! Available since manifold3d's `5f95a3a` master commit (post-v3.4.1).
43+
44+
use manifold_csg_sys::{
45+
ManifoldExecutionContext, manifold_alloc_execution_context, manifold_delete_execution_context,
46+
manifold_execution_context, manifold_execution_context_cancel,
47+
manifold_execution_context_cancelled, manifold_execution_context_progress,
48+
};
49+
50+
/// Observes progress and allows cooperative cancellation of long-running
51+
/// boolean evaluations. See the [module docs](self) for usage.
52+
pub struct ExecutionContext {
53+
ptr: *mut ManifoldExecutionContext,
54+
}
55+
56+
// SAFETY: The C API explicitly documents `ExecutionContext` as safe to
57+
// read/write from any thread; the upstream C++ implementation
58+
// synchronizes the cancel flag and progress counter internally.
59+
unsafe impl Send for ExecutionContext {}
60+
// SAFETY: Same justification as `Send` — all accessors (`cancel`,
61+
// `cancelled`, `progress`) are documented thread-safe at the C boundary.
62+
unsafe impl Sync for ExecutionContext {}
63+
64+
impl ExecutionContext {
65+
/// Create a fresh, un-cancelled context with zero progress.
66+
#[must_use]
67+
pub fn new() -> Self {
68+
// SAFETY: alloc returns a valid handle; the constructor takes that
69+
// raw memory and constructs an ExecutionContext into it (returning
70+
// the same pointer).
71+
let mem = unsafe { manifold_alloc_execution_context() };
72+
// SAFETY: mem is a valid, freshly-allocated handle.
73+
let ptr = unsafe { manifold_execution_context(mem) };
74+
Self { ptr }
75+
}
76+
77+
/// Request cancellation of any in-flight evaluation observing this
78+
/// context. Sticky: once called, [`is_cancelled`](Self::is_cancelled)
79+
/// returns `true` for the rest of the context's lifetime.
80+
pub fn cancel(&self) {
81+
// SAFETY: self.ptr is a valid handle for the lifetime of self;
82+
// upstream documents thread-safe access.
83+
unsafe { manifold_execution_context_cancel(self.ptr) };
84+
}
85+
86+
/// Returns `true` if [`cancel`](Self::cancel) has been called.
87+
#[must_use]
88+
pub fn is_cancelled(&self) -> bool {
89+
// SAFETY: self.ptr is a valid handle; upstream documents thread-safe access.
90+
unsafe { manifold_execution_context_cancelled(self.ptr) != 0 }
91+
}
92+
93+
/// Progress of an in-flight evaluation as a fraction in `[0.0, 1.0]`.
94+
/// Reads `0.0` before any evaluation has started and `1.0` after one
95+
/// has finished.
96+
#[must_use]
97+
pub fn progress(&self) -> f64 {
98+
// SAFETY: self.ptr is a valid handle; upstream documents thread-safe access.
99+
unsafe { manifold_execution_context_progress(self.ptr) }
100+
}
101+
102+
/// Raw pointer for FFI calls that take an `ExecutionContext`. Crate-local
103+
/// so the safe wrapper retains exclusive control of the lifetime.
104+
pub(crate) fn as_ptr(&self) -> *mut ManifoldExecutionContext {
105+
self.ptr
106+
}
107+
}
108+
109+
impl Default for ExecutionContext {
110+
fn default() -> Self {
111+
Self::new()
112+
}
113+
}
114+
115+
impl Drop for ExecutionContext {
116+
fn drop(&mut self) {
117+
if !self.ptr.is_null() {
118+
// SAFETY: ptr was returned by alloc + construct; not freed since.
119+
unsafe { manifold_delete_execution_context(self.ptr) };
120+
self.ptr = std::ptr::null_mut();
121+
}
122+
}
123+
}

crates/manifold-csg/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
2222
pub mod bounding_box;
2323
pub mod cross_section;
24+
pub mod execution;
2425
pub mod manifold;
2526
pub mod mesh;
2627
pub mod ray;
@@ -30,6 +31,7 @@ pub mod types;
3031

3132
pub use bounding_box::BoundingBox;
3233
pub use cross_section::{CrossSection, FillRule, JoinType, Rect2};
34+
pub use execution::ExecutionContext;
3335
pub use manifold::Manifold;
3436
pub use manifold::{
3537
get_circular_segments, reserve_ids, reset_to_circular_defaults, set_circular_segments,

crates/manifold-csg/src/manifold.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,36 @@ impl Manifold {
12241224
unsafe { manifold_min_gap(self.ptr, other.ptr, search_length) }
12251225
}
12261226

1227+
// ── Cancellable evaluation ──────────────────────────────────────
1228+
1229+
/// Force evaluation of this manifold's lazy CSG tree under a cancellable
1230+
/// [`ExecutionContext`](crate::ExecutionContext), returning the result
1231+
/// status.
1232+
///
1233+
/// Manifold operations are lazy: building a CSG tree is cheap and
1234+
/// synchronous; the actual evaluation happens when something queries
1235+
/// the result. Calling this method triggers that evaluation while
1236+
/// observing `ctx` — if `ctx` is cancelled (typically from another
1237+
/// thread), the evaluation aborts at the next boolean boundary and
1238+
/// returns a cancellation status. See the [`execution`](crate::execution)
1239+
/// module docs for the full pattern.
1240+
///
1241+
/// Equivalent to upstream's `manifold_status_with_context`.
1242+
///
1243+
/// Concurrent calls on the same `Manifold` with different contexts are
1244+
/// permitted (`Manifold: Sync`); upstream synchronizes the lazy-eval
1245+
/// cache. Each context observes its own progress and cancel state.
1246+
#[must_use]
1247+
pub fn status_with_context(
1248+
&self,
1249+
ctx: &crate::ExecutionContext,
1250+
) -> manifold_csg_sys::ManifoldError {
1251+
// SAFETY: self.ptr is a valid handle; ctx.as_ptr() is valid for
1252+
// the lifetime of `ctx`. Upstream documents thread-safe access to
1253+
// the context, so concurrent cancel from another thread is fine.
1254+
unsafe { manifold_status_with_context(self.ptr, ctx.as_ptr()) }
1255+
}
1256+
12271257
// ── Ray casting ─────────────────────────────────────────────────
12281258

12291259
/// Cast a ray against this manifold, returning all intersection hits.

crates/manifold-csg/tests/integration.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2277,3 +2277,67 @@ fn wasm_smoke_memory_growth_path() {
22772277
// 4/3 * pi * r^3 = 33.51 per sphere, * 30 ≈ 1005, allow some slack.
22782278
assert!(combined.volume() > 800.0);
22792279
}
2280+
2281+
// ── ExecutionContext tests ──────────────────────────────────────────
2282+
2283+
#[test]
2284+
fn execution_context_initial_state() {
2285+
let ctx = manifold_csg::ExecutionContext::new();
2286+
assert!(!ctx.is_cancelled());
2287+
assert_eq!(ctx.progress(), 0.0);
2288+
}
2289+
2290+
#[test]
2291+
fn execution_context_cancel_is_sticky() {
2292+
let ctx = manifold_csg::ExecutionContext::new();
2293+
assert!(!ctx.is_cancelled());
2294+
ctx.cancel();
2295+
assert!(ctx.is_cancelled());
2296+
// Sticky: still cancelled on subsequent reads.
2297+
assert!(ctx.is_cancelled());
2298+
}
2299+
2300+
#[test]
2301+
#[cfg_attr(
2302+
target_os = "emscripten",
2303+
ignore = "default build has no pthreads (-pthread requires SharedArrayBuffer + COOP/COEP from host)"
2304+
)]
2305+
fn execution_context_cross_thread_cancel() {
2306+
use std::sync::Arc;
2307+
use std::thread;
2308+
use std::time::Duration;
2309+
2310+
let ctx = Arc::new(manifold_csg::ExecutionContext::new());
2311+
let cancel = Arc::clone(&ctx);
2312+
2313+
let handle = thread::spawn(move || {
2314+
// Trivially small sleep — we just want to prove cancel from another
2315+
// thread becomes visible to the original via shared upstream state.
2316+
thread::sleep(Duration::from_millis(5));
2317+
cancel.cancel();
2318+
});
2319+
2320+
handle.join().unwrap();
2321+
assert!(ctx.is_cancelled());
2322+
}
2323+
2324+
#[test]
2325+
fn manifold_status_with_context_no_cancel() {
2326+
use manifold_csg_sys::ManifoldError;
2327+
let cube = Manifold::cube(1.0, 1.0, 1.0, true);
2328+
let ctx = manifold_csg::ExecutionContext::new();
2329+
// Trivial Manifold; evaluation finishes immediately.
2330+
assert_eq!(cube.status_with_context(&ctx), ManifoldError::NoError);
2331+
}
2332+
2333+
#[test]
2334+
fn manifold_status_with_context_already_cancelled() {
2335+
let cube = Manifold::cube(1.0, 1.0, 1.0, true);
2336+
let ctx = manifold_csg::ExecutionContext::new();
2337+
ctx.cancel();
2338+
// We don't assert on the specific status code — upstream may surface
2339+
// cancellation as NoError for trivial work that doesn't poll the flag,
2340+
// or as a specific cancellation status. We just want to prove the call
2341+
// is well-formed and doesn't panic / leak / crash.
2342+
let _ = cube.status_with_context(&ctx);
2343+
}

0 commit comments

Comments
 (0)