Skip to main content

Crate async_safe_defer

Crate async_safe_defer 

Source
Expand description

§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.

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.

use async_safe_defer::async_scope;

let value = async_scope!(scope, {
    scope.defer(|| async {
        close_remote_resource().await;
    });

    do_work().await
})
.await?;

assert_eq!(value, 42);

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

use async_safe_defer::async_scope;
use core::cell::Cell;

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.

use async_safe_defer::send_async_scope;

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;

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.

Capabilityasync-safe-deferscopeguard 1.2
Always-run synchronous deferyesyes
Guarded value with mutable access and extractionyesyes
Success and unwind strategiesstd featuredefault use_std feature
Custom drop strategyyesyes
Awaited sequential LIFO cleanupyesno
Borrowed non-'static async cleanupyesno
Allocator-free fixed async registryyesno
Normal dependenciesnonenone
MSRVRust 1.83Rust 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.

EventAsync cleanup behavior
Body completes normallyall actions run
Body returns Result::Err through ?all actions run, then the error is returned
return inside the macro bodyall actions run, then the value is returned
A borrowed heap or fixed run() future is dropped while pendingthe 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 droppedremaining registrations are discarded; caller-owned fixed futures remain owned by the caller
Body panicremaining async actions are discarded
Cleanup panicthe 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

FeatureDefaultAPI
allocyesLocalAsyncScope, SendAsyncScope, async scope macros
stdnosuccess and unwind strategies, constructors, and macros
no featuresnosync 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.

Modules§

fixed
Allocator-free asynchronous cleanup with fixed-capacity registries.

Macros§

async_scopealloc
Runs a body with a local asynchronous cleanup scope.
defer
Binds an armed DeferGuard to the surrounding lexical scope.
defer_on_successstd
Binds a deferred action that runs on a non-panicking scope exit.
defer_on_unwindstd
Binds a deferred action that runs while unwinding from a panic.
send_async_scopealloc
Runs a body with a Send asynchronous cleanup scope.

Structs§

AsyncScopealloc
Convenience name for LocalAsyncScope.
DeferGuard
Holds a synchronous action for at-most-once deferred execution.
LocalAsyncScopealloc
A LIFO stack of local asynchronous cleanup actions.
LocalRunalloc
Drains a LocalAsyncScope without consuming it.
ScopeGuard
Owns a value and conditionally passes it to an action when dropped.
SendAsyncScopealloc
A LIFO stack of Send asynchronous cleanup actions.
SendRunalloc
The Send counterpart to LocalRun.

Enums§

Always
Selects the action whenever an armed guard is dropped.
OnSuccessstd
Selects the action when an armed guard is dropped outside panic unwinding.
OnUnwindstd
Selects the action when an armed guard is dropped during panic unwinding.

Traits§

Strategy
Decides whether a deferred action runs when its guard is dropped.

Functions§

defer
Arms a new DeferGuard with the Always strategy.
defer_on_successstd
Arms a new DeferGuard that runs on a non-panicking drop.
defer_on_unwindstd
Arms a new DeferGuard that runs while unwinding from a panic.
guard
Owns value and passes it to action when the guard is dropped.
guard_on_successstd
Owns value and passes it to action on a non-panicking drop.
guard_on_unwindstd
Owns value and passes it to action while unwinding from a panic.