almost-enough 0.4.5

Batteries-included ergonomic extensions for the `enough` cooperative cancellation crate
Documentation

almost-enough

Batteries-included ergonomic extensions for the enough cooperative cancellation crate.

While enough provides only the minimal Stop trait, this crate provides all concrete implementations, combinators, and helpers. It re-exports everything from enough for convenience.

Install

[dependencies]
almost-enough = "0.4.5"

The default std feature pulls in everything (Arc-based stoppers, timeouts, guards). For no_std, disable default features and opt into alloc for the Arc-based types: almost-enough = { version = "0.4.5", default-features = false, features = ["alloc"] }. See Features.

Start Here: Stopper

If you're not sure which to use, reach for Stopper. It's the default: clones share one flag, and cancel() on any of them stops them all. The others, one line each:

  • ChildStopper, from stop.child(), builds a tree: cancelling a parent stops its children, and cancelling a child leaves its parent and siblings running.
  • OrStop, from a.or(b), stops when either one does, such as a cancel button or a shutdown signal.
  • StopSource is a flag on the stack or in a static, with no allocation; it lends out StopRefs with as_ref() and works in no_std.
  • .with_timeout(Duration::from_secs(30)), from TimeoutExt, adds a deadline to any stop.
  • StopToken is an owned stop to store or move into another thread; it clones with a reference-count bump.

The type table below lists the rest.

Quick Start

use almost_enough::{Stopper, Stop};

let stop = Stopper::new();
let stop2 = stop.clone();  // Clone to share the same flag

// Check it (any clone)
assert!(!stop2.should_stop());

// Cancel it (any clone)
stop.cancel();
assert!(stop2.should_stop());

The two methods you need:

  • stop.cancel() — flip the flag. cancel(&self) is an inherent method on Stopper (it takes &self, not self, so it never consumes the stopper), and it's idempotent. Construct with Stopper::new().
  • stop.should_stop() -> bool — true once cancelled. This comes from the Stop trait (almost_enough::Stop), which Stopper implements directly, so the Stop trait must be in scope to call it.

Prefer the ? idiom in fallible code: stop.check()? returns Result<(), StopReason> — Ok(()) while running, Err(StopReason::Cancelled) once cancelled. check is also a Stop-trait method, and should_stop() is just self.check().is_err().

Cancel a Worker Thread

Stopper is Send + Sync (it's an Arc<AtomicBool> under the hood, and the Stop trait requires Send + Sync), so a clone can move into another thread and the original can cancel it from across the thread boundary:

use almost_enough::{Stopper, Stop};
use std::thread;

// `Stopper` is Send + Sync, so a clone is safe to move into a worker thread.
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Stopper>();

let stop = Stopper::new();
let worker_stop = stop.clone(); // clone shares the same cancellation flag

let handle = thread::spawn(move || {
    let mut iterations = 0u64;
    // Loop until the parent thread asks us to stop.
    while !worker_stop.should_stop() {
        // ... do a chunk of work ...
        iterations += 1;
        if iterations > 10_000_000 {
            break; // safety valve for the doctest
        }
    }
    iterations
});

// Cancel from the parent thread (or a signal handler, a timeout, etc.).
stop.cancel();

let done = handle.join().unwrap();
let _ = done;

Inside a fallible worker, use check()? instead of the while loop:

use almost_enough::{Stopper, Stop, StopReason};
use std::thread;

let stop = Stopper::new();
let worker_stop = stop.clone();

let handle = thread::spawn(move || -> Result<(), StopReason> {
    for _ in 0..10_000_000u64 {
        worker_stop.check()?; // returns Err(StopReason::Cancelled) once cancelled
        // ... do work ...
    }
    Ok(())
});

stop.cancel();
// The worker returns Err(StopReason::Cancelled) (or Ok if it finished first).
let _ = handle.join().unwrap();

Accepting a stop in your own functions

Library code usually accepts an Option<&dyn Stop>; a caller passes Some(&stop), or None when nothing should stop the work. To store the stop or move it into another thread, accept a StopToken instead (see below):

use almost_enough::{Stopper, Stop, StopReason};

fn run(stop: Option<&dyn Stop>) -> Result<(), StopReason> {
    for _ in 0..1000 {
        stop.check()?; // Err(StopReason::Cancelled) once cancelled
        // ... work ...
    }
    Ok(())
}

let stop = Stopper::new();
run(Some(&stop)).unwrap();
run(None).unwrap();

Check every 10 ms of work or so: often enough that cancelling feels immediate, rarely enough that the call through &dyn costs nothing measurable.

Type Overview

Type Feature Use Case
Unstoppable core Zero-cost "never stop"
StopSource / StopRef core Stack-based, borrowed, zero-alloc
FnStop core Wrap any closure
OrStop core Combine multiple stops
Stopper alloc Default choice - Arc-based, clone to share
SyncStopper alloc Like Stopper, but every check is an Acquire load
ChildStopper alloc Hierarchical parent-child cancellation
StopToken alloc Type-erased dynamic dispatch - Arc-based, Clone
BoxedStop alloc Deprecated: use StopToken
WithTimeout std Add deadline to any Stop (reads the clock every check)
DebouncedTimeout std Like WithTimeout, reads the clock every N ≤ 64 checks, N timed from two checks

Features

  • std (default) - Full functionality including timeouts
  • alloc - Arc-based types, into_token(), child(), guards
  • None - Core trait and stack-based types only (no_std compatible)

Extension Traits

The StopExt trait adds combinator methods to any Stop:

use almost_enough::{StopSource, Stop, StopExt};

let timeout = StopSource::new();
let cancel = StopSource::new();

// Combine: stop if either stops
let combined = timeout.as_ref().or(cancel.as_ref());
assert!(!combined.should_stop());

cancel.cancel();
assert!(combined.should_stop());

Hierarchical Cancellation

Create child stops that inherit cancellation from their parent:

use almost_enough::{Stopper, Stop, StopExt};

let parent = Stopper::new();
let child = parent.child();

// Child cancellation doesn't affect parent
child.cancel();
assert!(!parent.should_stop());

// But parent cancellation propagates to children
let child2 = parent.child();
parent.cancel();
assert!(child2.should_stop());

Stop Guards (RAII)

Automatically cancel on scope exit unless explicitly disarmed:

use almost_enough::{Stopper, StopDropRoll};

fn do_work(source: &Stopper) -> Result<(), &'static str> {
    let guard = source.stop_on_drop();

    // If we return early or panic, source is stopped
    risky_operation()?;

    // Success! Don't stop.
    guard.disarm();
    Ok(())
}

fn risky_operation() -> Result<(), &'static str> {
    Ok(())
}

Keeping a stop: StopToken

A &dyn Stop is borrowed for one call. To store a stop or move it into another thread, take a StopToken: it's owned, and clones with a reference-count bump. Stopper and SyncStopper convert to StopToken via Into without allocating — the existing Arc is reused. A Stopper is then checked directly, a SyncStopper through the vtable. into_token() converts any other Stop + 'static:

use almost_enough::{Stop, StopExt, StopToken, Stopper, TimeoutExt};
use std::thread;
use std::time::Duration;

struct Worker {
    stop: StopToken,
}

impl Worker {
    fn spawn(&self) -> thread::JoinHandle<()> {
        let stop = self.stop.clone(); // cheap Arc increment, no allocation
        thread::spawn(move || {
            while !stop.should_stop() {
                thread::yield_now();
            }
        })
    }
}

let stopper = Stopper::new();
let worker = Worker { stop: stopper.clone().into() }; // reuses the Stopper's Arc
let handle = worker.spawn();
stopper.cancel();
handle.join().unwrap();

// Any other stop, such as one with a deadline:
let worker = Worker {
    stop: Stopper::new().with_timeout(Duration::from_secs(30)).into_token(),
};

Optimizing Hot Loops with dyn Stop

Call live() once to skip overhead for no-op stops behind &dyn Stop:

use almost_enough::{Stop, StopReason, Unstoppable};

fn process(stop: &dyn Stop) -> Result<(), StopReason> {
    let stop = stop.live(); // Option<&dyn Stop>: None if it can never stop
    for i in 0..1_000_000 {
        stop.check()?; // None → Ok(()), Some → one vtable dispatch
    }
    Ok(())
}

// Unstoppable can never stop, so stop is None — zero overhead
assert!(process(&Unstoppable).is_ok());

StopToken automatically optimizes away no-op stops — when wrapping Unstoppable, check() short-circuits without any vtable dispatch:

use almost_enough::{StopToken, Stopper, Unstoppable, Stop, StopReason};

fn hot_loop(stop: &StopToken) -> Result<(), StopReason> {
    for i in 0..1_000_000 {
        stop.check()?; // Unstoppable: no-op. Stopper: one dispatch.
    }
    Ok(())
}

hot_loop(&StopToken::new(Unstoppable)).unwrap();    // zero overhead
hot_loop(&StopToken::new(Stopper::new())).unwrap(); // one dispatch per check

See Also

  • enough - Minimal core trait (for library authors)
  • enough-tokio - Tokio CancellationToken bridge
  • enough-ffi - FFI helpers for C#, Python, Node.js

License

MIT OR Apache-2.0