Cooperative cancellation in gpui-query What QuerySignal is, why cancellation in Rust has to be cooperative, and where your fetchers should check the flag so abandoned queries stop early. Author: hmziqrs ยท Date: 2026-04-08 Here's a bug that shows up in almost every async UI eventually. A component kicks off a request. The user navigates away. Two seconds later the response lands and writes to state nobody is rendering anymore, or worse, overwrites fresher data. Web frameworks paper over this with abort signals and effect cleanup. In a Rust and GPUI app you can do it with less machinery. gpui-query handles it with cooperative cancellation via QuerySignal. What QuerySignal actually is #[derive(Debug, Clone)] pub struct QuerySignal { cancelled: Arc, } That's the whole type: a shared atomic flag in an Arc. Clones share the same underlying flag, so cancelling any clone cancels all of them: use gpui_query::core::QuerySignal; let signal = QuerySignal::new(); let clone = signal.clone(); signal.cancel(); assert!(signal.is_cancelled()); assert!(clone.is_cancelled()); Both the store and the load use Ordering::SeqCst, so cancellation is observable across threads without a lock. The type is deliberately not serializable. A cancellation flag has no meaningful persisted form, so it doesn't get one. Why cooperative rather than preemptive Rust won't let you kill a future from the outside, and gpui-query doesn't own your futures anyway. Cancellation has to be opt-in: the running task checks a flag at points where stopping is safe. QuerySignal gives fetchers a cheap, allocation-free way to do exactly that. The contract is short: - The client hands your fetcher a QuerySignal clone when a request starts. - When the request is superseded (a newer request, a component unmount, a manual cancel), the client calls signal.cancel(). - Your fetcher checks signal.is_cancelled() periodically and returns early. Where to check the signal The most important place is between retry attempts. RetryPolicy will keep retrying a failing request up to max_retries times, and without a cancellation check that means a doomed query can keep working for seconds after nobody wants the result. use gpui_query::core::{QuerySignal, RetryPolicy}; async fn fetch_users(signal: QuerySignal) -> Result, MyError> { let policy = RetryPolicy::default(); // 3 retries, exponential backoff for attempt in 0..=policy.max_retries { if signal.is_cancelled() { return Err(MyError::Cancelled); } match do_request().await { Ok(users) => return Ok(users), Err(e) if attempt == policy.max_retries => return Err(e), Err(_) => sleep(backoff(attempt)).await, } } unreachable!() } Long-running scans and paginated fetches should also poll is_cancelled() at their natural chunk boundaries. How LatestWins uses it RequestPolicy::LatestWins is built on this primitive. When a new request arrives for a key while an older one is still in flight, the older request's signal is cancelled and the client waits only on the newest result. That closes the classic query-cache race where stale data clobbers fresh data, and it does so without the framework aborting futures it doesn't own. Takeaways QuerySignal is an Arc: cheap to clone and lock-free to read. Check is_cancelled() at retry and chunk boundaries inside your fetcher. Component unmount and LatestWins both flow through the same signal, so the crate has one teardown story instead of three. If your fetcher never checks the signal, nothing breaks. It just won't stop early. Treat the signal as a contract between your fetcher and the client: honor it, and abandoned queries cost you almost nothing.