async-safe-defer 0.2.0

Runtime-independent LIFO cleanup scopes for synchronous and asynchronous Rust
Documentation

async-safe-defer

CI crates.io docs.rs

Runtime-independent scope guards for synchronous and explicitly awaited asynchronous cleanup.

  • zero normal dependencies; no executor is selected or spawned
  • #![no_std] synchronous guards and fixed-capacity async scopes
  • local and Send heap-backed async scopes behind alloc
  • always, success, unwind, and custom synchronous drop strategies
  • no unsafe Rust, enforced with #![forbid(unsafe_code)]

The word "safe" means that this crate contains no unsafe Rust. It does not mean cancellation-safe async drop: asynchronous cleanup must still be driven to completion by the caller.

Synchronous cleanup

The defer! macro binds a guard to the surrounding lexical scope. Its action runs on normal return and during panic unwinding whenever destructors run. The synchronous defer macros require their cleanup body to evaluate to (); they do not silently discard non-unit or fallible results. Handle such results inside the body explicitly.

use async_safe_defer::defer;
use std::cell::Cell;

let cleaned = Cell::new(false);
{
    defer!(cleaned.set(true));
    assert!(!cleaned.get());
}
assert!(cleaned.get());

DeferGuard can be disarmed or run early. Once initialized inside a future, a guard also runs when that future is cancelled and dropped. If cancellation happens before execution reaches the guard binding, no guard exists yet.

A synchronous cleanup panic propagates normally. If cleanup panics while another panic is already unwinding, Rust's double-panic behavior can abort the process.

Guarded values

ScopeGuard owns a protected value and exposes it through Deref and DerefMut. Its action receives the final value on drop.

use async_safe_defer::guard;
use std::cell::RefCell;

let finalized = RefCell::new(Vec::new());
{
    let mut values = guard(vec![1], |values| {
        finalized.replace(values);
    });
    values.push(2);
    assert_eq!(&*values, &[1, 2]);
}
assert_eq!(finalized.into_inner(), vec![1, 2]);

Use into_inner to recover the value without running the action, into_parts to recover both pieces, or run_now to execute immediately.

The crate provides no manual or unsafe Send/Sync implementations for its guards. Their auto traits follow the stored action and, for ScopeGuard, the guarded value; a guard is Sync only when those fields are Sync.

Conditional drop strategies

The optional std feature provides success-only and unwind-only guards and macros.

# #[cfg(feature = "std")]
# {
use async_safe_defer::{defer_on_success, defer_on_unwind};
use std::cell::Cell;

let committed = Cell::new(false);
let rolled_back = Cell::new(false);
{
    defer_on_unwind!(rolled_back.set(true));
    defer_on_success!(committed.set(true));
}

assert!(committed.get());
assert!(!rolled_back.get());
# }

For these policies, success means that the thread is not unwinding. Returning Result::Err still counts as success. Implement Strategy for a custom drop policy.

Asynchronous cleanup

async_scope! accepts ordinary || async { ... } closures, preserves the body value, and runs cleanup before propagating ? or an inner return. Closures and futures may borrow values outside the scope invocation.

# #[cfg(feature = "alloc")]
# mod local_scope_example {
use async_safe_defer::async_scope;

# async fn close_remote_resource() {}
# async fn do_work() -> Result<u32, &'static str> { Ok(42) }
# async fn example() -> Result<u32, &'static str> {
let value = async_scope!(scope, {
    scope.defer(|| async {
        close_remote_resource().await;
    });

    do_work().await
})
.await?;

assert_eq!(value, 42);
# Ok(value)
# }
# }

A cleanup stack can act as rollback by clearing it only after commit:

# #[cfg(feature = "alloc")]
# mod rollback_example {
use async_safe_defer::async_scope;
use core::cell::Cell;

# async fn update() -> Result<(), &'static str> { Err("update failed") }
# async fn rollback(flag: &Cell<bool>) { flag.set(true); }
# async fn transaction(flag: &Cell<bool>) -> Result<(), &'static str> {
async_scope!(scope, {
    scope.defer(|| rollback(flag));
    update().await?;
    scope.clear();
    Ok(())
})
.await
# }
# }

The local scope accepts Rc, RefCell, and non-Send futures. Use send_async_scope! when registered cleanup closures and futures must be Send. Whether the complete returned future is Send also depends on its body, output, and captures.

# #[cfg(feature = "alloc")]
# mod send_scope_example {
use async_safe_defer::send_async_scope;

# async fn flush() {}
# async fn example() {
send_async_scope!(scope, {
    scope.defer(|| async { flush().await });
})
.await;
# }
# }

For manual lifecycle control, run(&mut self) borrows the scope and is resumable if its returned future is dropped while pending. finish(self) consumes the scope and releases captured borrows. A run poll continues through LIFO actions until one returns Pending, so one poll may complete every immediately-ready action.

Allocator-free asynchronous cleanup

FixedAsyncScope and FixedSendAsyncScope store caller-owned pinned future references in an inline array. The registry itself performs no heap allocation and accepts heterogeneous futures.

use async_safe_defer::fixed::FixedAsyncScope;
use core::pin::pin;

# async fn close_socket() {}
# async fn flush_log() {}
# async fn example() {
let mut close = pin!(close_socket());
let mut flush = pin!(flush_log());
let mut scope = FixedAsyncScope::<2>::new();

scope.try_defer(close.as_mut()).expect("slot available");
scope.try_defer(flush.as_mut()).expect("slot available");
scope.finish().await;
# }

The pinned futures must outlive the scope, so create them first. try_defer returns the rejected reference in CapacityError instead of panicking. A registered future may allocate internally; only the registry is guaranteed allocator-free.

Use FixedAsyncScope for local cleanup futures, including non-Send borrows. Use FixedSendAsyncScope when every registered future must be Send and the fixed scope's run or finish future may need to cross thread boundaries.

Compared with scopeguard

scopeguard remains a focused, low-MSRV synchronous guard crate. async-safe-defer provides the same central synchronous patterns and extends them with explicit async cleanup.

Capability async-safe-defer scopeguard 1.2
Always-run synchronous defer yes yes
Guarded value with mutable access and extraction yes yes
Success and unwind strategies std feature default use_std feature
Custom drop strategy yes yes
Awaited sequential LIFO cleanup yes no
Borrowed non-'static async cleanup yes no
Allocator-free fixed async registry yes no
Normal dependencies none none
MSRV Rust 1.83 Rust 1.20

Choose scopeguard when only synchronous cleanup and a much older compiler are required. Choose this crate when one dependency should cover synchronous guards and runtime-neutral async finalization.

Execution guarantees

The normal-completion guarantees below assume the returned future is polled to completion. Run futures poll LIFO actions until one returns Pending, so a single poll can execute every immediately-ready action.

Event Async cleanup behavior
Body completes normally all actions run
Body returns Result::Err through ? all actions run, then the error is returned
return inside the macro body all actions run, then the value is returned
A borrowed heap or fixed run() future is dropped while pending the current action remains registered; a later run resumes it after newer LIFO entries
A heap or fixed finish() future, or an async-scope macro future is dropped remaining registrations are discarded; caller-owned fixed futures remain owned by the caller
Body panic remaining async actions are discarded
Cleanup panic the current registration is lost; borrowed heap and fixed run retain older entries, while consuming finish calls and macros discard them

Rust has no stable, runtime-independent way to await work from a normal Drop implementation. Do not use async cleanup to restore memory-safety invariants or release mandatory resources under arbitrary task cancellation. Use a synchronous guard for the cancellation fallback when one is required.

Features

Feature Default API
alloc yes LocalAsyncScope, SendAsyncScope, async scope macros
std no success and unwind strategies, constructors, and macros
no features no sync always-strategy guards and fixed::{FixedAsyncScope, FixedSendAsyncScope}
cargo check --no-default-features
cargo check --no-default-features --features std

Cost model and limits

  • LocalAsyncScope and SendAsyncScope use one boxed wrapper per action plus amortized Vec storage.
  • FixedAsyncScope and FixedSendAsyncScope perform no heap allocation for registry storage but require explicit pinning and lifetimes.
  • Async actions return (). Logging, retry, and error handling happen inside each action; errors are not aggregated.
  • Cleanup is sequential, not parallel, and no timeout policy is imposed.

Migrating from 0.1

  • Breaking: the misleading no_alloc feature, no_alloc module, AsyncScopeNoAlloc, DeferredFn, and no_alloc_async_scope! were removed. Use fixed::FixedAsyncScope or fixed::FixedSendAsyncScope for a fixed registry that performs no heap allocation.
  • AsyncScope::defer accepts ordinary FnOnce() -> Future factories without caller-side boxing.
  • Breaking: heap-backed AsyncScope::run now borrows &mut self and is resumable. Use finish(self) when the cleanup future must consume and own the scope; there is no run_mut alias.
  • A dependency using default-features = false must enable alloc explicitly to access heap-backed scopes.
  • Breaking: async scope macros now return the body value and do not let an inner return or ? bypass cleanup; internally they consume their scope with finish.
  • Breaking: synchronous defer macros no longer discard non-unit or fallible cleanup results. Handle or explicitly discard those results in the cleanup body so it evaluates to ().
  • The correct Rust import has always been async_safe_defer; older README examples used the wrong name.

The minimum supported Rust version is 1.83. The crate is licensed under MIT.