The Resource Problem — Cleanup in Async
RAII in synchronous code:
#![allow(unused)] fn main() { { let file = File::open("data.txt")?; process(&file)?; } // file.drop() runs here, always, unconditionally }
Reliable. Simple. The drop happens when the scope ends — no exceptions (unless you have exceptions).
The Async Complication
#![allow(unused)] fn main() { async fn process_data() -> Result<(), Error> { let conn = open_connection().await?; let data = fetch_data(&conn).await?; // What if this is cancelled? transform_and_save(data).await?; // Or this? conn.close().await?; // May never reach here Ok(()) } }
Three problems:
- Cancellation: If this async function is cancelled mid-execution,
conn.close()never runs. - Panic: If
transform_and_savepanics, the async task is dropped.conn.close()is skipped. - Async Drop:
impl Drop for Connectioncan only do synchronous cleanup. If closing a connection requires.await, you can't do it inDrop.
conn.close() must be an async call, but Drop can't be async. This is a fundamental mismatch.
The Root Cause
RAII relies on Drop running synchronously when a value goes out of scope. In async code, "going out of scope" and "running cleanup" can be decoupled — by cancellation, by executor scheduling, or by the fact that async closures are state machines that might never reach certain states.
The Solution Preview
id_effect solves this with:
Scope— a region where finalizers are registered and guaranteed to run (even on cancellation or panic)acquire_release— a combinator that pairs acquisition with its cleanupPool— for long-lived resources that need controlled reuse
All three run cleanup effects (not just synchronous Drop), and all three run them unconditionally — success, failure, or interruption.