This document explains how to implement async-aware nodes in NodeBox's Rust codebase, including proper cancellation support for long-running operations.
NodeBox uses cooperative cancellation to allow users to stop long-running renders. When implementing nodes that perform I/O operations (file reading, network requests) or expensive computations, you should check for cancellation at appropriate boundaries.
- CancellationToken: A thread-safe token shared between the main thread and render worker
- Cooperative checks: Nodes check
is_cancelled()at iteration boundaries - Early return: When cancelled, evaluation stops and returns cached partial results
- RAII cleanup: Resources are automatically cleaned up via Rust's Drop trait
The evaluation engine automatically checks for cancellation at:
- Before each node: Before starting to evaluate any node
- During list-matching iterations: Between iterations in list-matching loops
For standard nodes, this provides < 500ms response time for typical workloads.
For nodes that perform expensive operations:
fn execute_expensive_node(
node: &Node,
inputs: &HashMap<String, NodeOutput>,
cancel_token: &CancellationToken,
) -> EvalResult {
// Check cancellation before starting
if cancel_token.is_cancelled() {
return Err(EvalError::Cancelled);
}
// Do work in chunks, checking periodically
let mut results = Vec::new();
for item in items {
// Check at iteration boundaries
if cancel_token.is_cancelled() {
return Err(EvalError::Cancelled);
}
let result = process_item(item);
results.push(result);
}
Ok(NodeOutput::from(results))
}For nodes that perform async I/O (file reading, network requests), use the smol runtime:
use smol::fs;
use smol::future::block_on;
fn execute_file_read_node(
node: &Node,
inputs: &HashMap<String, NodeOutput>,
cancel_token: &CancellationToken,
) -> EvalResult {
let path = get_string(inputs, "path", "");
// Use smol for async file operations
let content = block_on(async {
// Check cancellation before I/O
if cancel_token.is_cancelled() {
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"Cancelled",
));
}
fs::read_to_string(&path).await
});
match content {
Ok(text) => Ok(NodeOutput::String(text)),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
Err(EvalError::Cancelled)
}
Err(e) => Err(EvalError::ProcessingError(format!(
"{}: {}",
node.name, e
))),
}
}use smol::net::TcpStream;
use smol::io::AsyncReadExt;
async fn fetch_url(url: &str, cancel_token: &CancellationToken) -> Result<String, EvalError> {
// Periodically check cancellation during long operations
if cancel_token.is_cancelled() {
return Err(EvalError::Cancelled);
}
// Use async-net or smol's networking
let stream = TcpStream::connect(url).await
.map_err(|e| EvalError::ProcessingError(e.to_string()))?;
// Read with cancellation checks
let mut buffer = Vec::new();
// ... read in chunks, checking cancel_token between reads
Ok(String::from_utf8_lossy(&buffer).to_string())
}- Too few checks: User waits too long for cancellation (> 500ms)
- Too many checks: Performance overhead from atomic reads
- Rule of thumb: Check every ~100ms of work, or at natural iteration boundaries
Use Rust's RAII pattern for automatic cleanup:
struct TempFile {
path: PathBuf,
}
impl Drop for TempFile {
fn drop(&mut self) {
// Automatically cleaned up even on cancellation
let _ = std::fs::remove_file(&self.path);
}
}When implementing caching, ensure partial results are preserved:
// Good: Cache results as you go
for (i, item) in items.iter().enumerate() {
if cancel_token.is_cancelled() {
// partial_cache already contains results [0..i)
return Err(EvalError::Cancelled);
}
let result = process(item);
partial_cache.insert(i, result.clone());
results.push(result);
}
// Bad: Only cache at the end
let results: Vec<_> = items.iter().map(process).collect();
cache.insert_all(results); // Lost if cancelled#[test]
fn test_node_respects_cancellation() {
let token = CancellationToken::new();
let mut cache = HashMap::new();
// Pre-cancel
token.cancel();
let result = evaluate_network_cancellable(&library, &token, &mut cache);
assert!(matches!(result, EvalOutcome::Cancelled));
}#[test]
fn test_cancellation_response_time() {
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = thread::spawn(move || {
let mut cache = HashMap::new();
evaluate_network_cancellable(&library, &token_clone, &mut cache)
});
thread::sleep(Duration::from_millis(100));
let cancel_time = Instant::now();
token.cancel();
let _result = handle.join().unwrap();
assert!(
cancel_time.elapsed() < Duration::from_millis(500),
"Cancellation should respond within 500ms"
);
}The stop button in the address bar becomes prominent after 3 seconds of rendering:
- Idle: SLATE_700 (subtle, near background)
- Rendering < 3s: SLATE_700 (subtle)
- Rendering >= 3s: SLATE_300 (prominent)
Keyboard shortcut: Cmd+. (macOS) or Ctrl+. (Windows/Linux)
crates/nodebox-desktop/src/render_worker.rs- CancellationToken, worker loopcrates/nodebox-desktop/src/eval.rs- evaluate_network_cancellable()crates/nodebox-desktop/src/address_bar.rs- Stop button UIcrates/nodebox-desktop/tests/cancellation_tests.rs- Integration tests