Deterministic Cleanup Calls and Resource Release with Async Scope Guards
Asynchronous programs frequently deal with resources that must be returned after use, such as connections, work slots, and temporary upload sessions. The problem is that when you write acquisition and release as separate ordinary function calls, it is easy to miss the release call on one of the return, exception, or cancellation paths. An Async Scope Guard binds a resource's lifetime to its lexical scope.
· Practice memo · 52 min read · Medium
Asynchronous programs frequently deal with resources that must be returned after use, such as connections, work slots, and temporary upload sessions. The problem is that when acquisition and release are written as separate ordinary function calls, it is easy to miss the release call on one of the return, exception, or cancellation paths.
An Async Scope Guard binds a resource's lifetime to a lexical scope. C#'s await using, TypeScript's await using, and Python's async with all await the asynchronous cleanup when the block exits. C++ is closer to the original synchronous RAII model, but because destructors cannot be coroutines, the coroutine function itself must own the entire acquire -> use -> release sequence for asynchronous cleanup. (In fact, memorizing the acquire->use->release formula is essentially all you need to understand this entire document.) C#'s await using uses IAsyncDisposable, TypeScript calls [Symbol.asyncDispose](), and Python calls __aenter__() and __aexit__().1234
For reference, the word deterministic in this document refers to the scope within which the cleanup entry point is called and its completion is awaited under normal control flow. This does not prevent forced process termination or power loss, nor does it guarantee that a remote release traveling over a network succeeds or is applied exactly once.
The goal of this pattern is not to eliminate try/finally, but rather to let the language encapsulate the recurring try/finally structure as a protocol on the resource type. The caller no longer has to re-implement "when should this be released" each time, while the resource implementor manages "how to release" in a single place.
Asynchronous cleanup is necessary because the return process itself may involve I/O: returning a remote work slot, rolling back a transaction, and flushing a buffered stream all require awaiting completion before the next operation can safely proceed. .NET's IAsyncDisposable.DisposeAsync() likewise returns a ValueTask representing an asynchronous cleanup.
The core formula to remember:
Here, "once" means the guard does not call the broker's cleanup entry point more than once. When a network response is lost, the client cannot know whether the remote release was applied. The broker must provide idempotent release (retryable with the same leaseId) and lease expiration. Attaching the phrase "exactly once" directly to remote effects overstates the guarantee.5
1. The Problem
Suppose a rendering server borrows a limited number of GPU work slots from a central coordinator.
RenderSlotBroker:
- acquireAsync()
- releaseAsync(token)
RenderSlotToken:
- slotId
- leaseIdThe workflow is as follows.
1. Acquire Rendering Slot
2. Execute Rendering
3. Return SlotDuring rendering, any of the following events can occur.
- Normal completion
- Input file parsing failure
- GPU operation failure
- Caller cancellation
- Early return from methodWith manual release, every code path must execute the following.
await broker.releaseAsync(token)If even one path is missed, the slot is not returned, causing subsequent tasks to wait or the coordinator to maintain an incorrect count of active slots.
The desired API looks like this.
await using / async with
Inside the block, `slotId` is used
When the block exits, `releaseAsync` is executed automaticallyThe single domain of this card is binding the acquisition and release of an async lease to a lexical scope.
2. Core Expressions
Common contract:
RenderSlotBroker
- Validates the response format and token before `acquire` returns normally
- Copies the external response into an immutable/plain token value internal to the language
- Transfers token ownership to the caller upon normal return
- For acquisitions with ambiguous results (such as timeouts), recovers the resource using a request id and lease TTL
RenderSlotLease
- `acquire`: asynchronous
- `getSlotId`: usable only while active
- `dispose`/`exit`: asynchronous
- Sequential duplicate release after completion: no-op
- Concurrent `dispose` callers: outside the scope of this example
- Reuse after release has begun: prohibitedThe key point is that the remote coordinator may have already claimed a slot and then responded with an invalid token; if the lease factory throws an exception while validating that token, the scope guard has not yet been created. One of the most common mistakes is that the factory calls only ValidateToken() and the slot leaks. Worse, if an empty leaseId was received, the client has no way of knowing what to release.
Therefore, the broker in the example carries the contract that any token it returns normally is already validated and is a token that can be returned. The broker adapter is responsible for parsing HTTP/gRPC responses, validating tokens, and recovering when the outcome of an acquisition is ambiguous. The lease factory creates the wrapper immediately after a successful return, and it is critical that no operations that could fail are placed between acquisition and wrapper creation.
C++: Where RAII Ends
In C++, resources that can be cleaned up synchronously, such as files, mutexes, and memory, are most naturally managed with RAII. When a local object goes out of scope its destructor runs, so you do not need to separately account for return paths and exception paths.
The remote lease this document covers is a different matter. If returning a slot requires network I/O, co_await is needed to wait for completion, yet a C++ destructor cannot be a coroutine.46
This is the code I mistakenly wrote the first time, and it is a pitfall that many C++ beginners fall into as well.
class RenderSlotLease {
public:
~RenderSlotLease() {
co_await broker_.release(token_); // Error: a destructor cannot be a coroutine.
}
};Blocking the event loop inside a destructor and calling future.get() is also a poor workaround: if the same thread's event loop needs to drive the release to completion, a deadlock occurs, and even without a deadlock you must be aware that a single destructor can introduce an unexpectedly long delay. Dispatching with a fire-and-forget like co_spawn(..., detached) causes the scope to end before cleanup is complete, and errors are detached from the call path.
If a destructor emits an exception during stack unwinding, std::terminate may be called, so hiding a potentially failing async release inside a destructor is fundamentally the wrong approach.4
In C++, therefore, the clearer approach is to close synchronous resources with RAII and let a coroutine-level bracket function own the entire lifecycle of async resources. Because the standard C++ coroutine mechanism defines only the language machinery and provides no general-purpose Task<T> runtime, the examples below use Boost.Asio's boost::asio::awaitable<T>. If you are using an actual task type from Folly, Seastar, Qt, or similar, you only need to change the return type and cancellation API to match that runtime.67
#include <boost/asio.hpp>
#include <exception>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
namespace asio = boost::asio;
struct RenderSlotToken {
std::string slot_id;
std::string lease_id;
};
struct RenderJob {
std::string job_id;
std::string input_key;
};
struct RenderReceipt {
std::string job_id;
std::string output_key;
};
class RenderSlotBroker {
public:
virtual asio::awaitable<RenderSlotToken> acquire() = 0;
// Apply a short timeout decoupled from the caller's cancellation, along with idempotent release, internally.
virtual asio::awaitable<void> release_for_cleanup(
RenderSlotToken token) = 0;
virtual ~RenderSlotBroker() = default;
};
class Renderer {
public:
virtual asio::awaitable<RenderReceipt> render(
RenderJob job,
std::string slot_id) = 0;
virtual ~Renderer() = default;
};
class RenderAndCleanupFailed final : public std::runtime_error {
public:
RenderAndCleanupFailed(
std::exception_ptr body_error,
std::exception_ptr cleanup_error)
: std::runtime_error("render and slot cleanup both failed"),
body_error_(std::move(body_error)),
cleanup_error_(std::move(cleanup_error)) {}
const std::exception_ptr& body_error() const noexcept {
return body_error_;
}
const std::exception_ptr& cleanup_error() const noexcept {
return cleanup_error_;
}
private:
std::exception_ptr body_error_;
std::exception_ptr cleanup_error_;
};
asio::awaitable<RenderReceipt> execute_render_job(
RenderJob job,
RenderSlotBroker& broker,
Renderer& renderer)
{
// A token returned normally has already been validated by the broker.
RenderSlotToken token = co_await broker.acquire();
std::optional<RenderReceipt> receipt;
std::exception_ptr body_error;
try {
receipt.emplace(
co_await renderer.render(
std::move(job),
token.slot_id));
} catch (...) {
// Store the original error so it can be re-thrown after cleanup.
body_error = std::current_exception();
}
std::exception_ptr cleanup_error;
bool was_cancelled = false;
try {
// Asio may throw an exception at the next `co_await` after a coroutine has been cancelled.
// Disable automatic throwing only during cleanup. This setting alone does not remove the
// cancellation that has been propagated to sub-operations, so the broker contract below must
// provide a separate timeout/cancellation boundary.
// This helper assumes the general policy that `throw_if_cancelled(true)` was in effect upon entry.
co_await asio::this_coro::throw_if_cancelled(false);
co_await broker.release_for_cleanup(std::move(token));
} catch (...) {
cleanup_error = std::current_exception();
}
// `throw_if_cancelled(true)` does not re-throw a cancellation that has already occurred.
// Therefore, observe the cancellation state after cleanup, and if necessary,
// produce the cancellation result explicitly below.
try {
auto cancellation_state =
co_await asio::this_coro::cancellation_state;
was_cancelled = cancellation_state.cancelled()
!= asio::cancellation_type::none;
} catch (...) {
if (!cleanup_error) {
cleanup_error = std::current_exception();
}
// If a release failure has already occurred, preserve that error.
}
if (body_error && cleanup_error) {
throw RenderAndCleanupFailed(
std::move(body_error),
std::move(cleanup_error));
}
if (cleanup_error) {
std::rethrow_exception(cleanup_error);
}
if (body_error) {
std::rethrow_exception(body_error);
}
if (was_cancelled) {
throw asio::system_error(asio::error::operation_aborted);
}
co_return std::move(receipt).value();
}The contract of release_for_cleanup() is critical here. It does not propagate the caller's cancellation as-is, but that does not mean it waits indefinitely. The broker must apply a separate short timeout and ensure that resending the same release request using the lease_id is safe. Because a co_await that resumes inside a cancelled Boost.Asio coroutine can throw by default, it is important to apply throw_if_cancelled(false) for the duration of the cleanup.7
This setting only disables the automatic-throw behavior on the next co_await; it does not prevent the already-delivered cancellation slot from cancelling lower-level async operations. The broker must therefore own a separate cleanup execution flow, a timeout, and idempotent release. Simply calling throw_if_cancelled(true) after cleanup does not replay a cancellation that has already been delivered, so the example observes the cancellation_state and then constructs the cancellation result explicitly. Because co_await cannot appear inside a catch handler in C++8, the release and the state observation are each collected inside try blocks.
reset_cancellation_state() is a stronger tool: because this state is shared among coroutines in the same execution chain created by co_spawn, you must be aware that if a lower-level helper resets the cancellation state and returns as-is, the caller's cancellation policy may change. The outer bracket function therefore only briefly disables the automatic-throw behavior instead of resetting the state.
Inside release_for_cleanup(), by contrast, the scope is closed, so a stronger tool is appropriate. If actual operations such as async_write or async_connect must not be interrupted by caller cancellation, the better approach is either to create a short helper inside the broker implementation that applies reset_cancellation_state(disable_cancellation()), or to isolate the release in a new co_spawn execution chain and attach its own timeout. throw_if_cancelled(false) only disables the automatic-throw behavior on the next co_await; it does not prevent the already-delivered cancellation slot from cancelling lower-level async operations. In other words, the core idea of this example is not "ignore caller cancellation and wait forever" but rather "finish the release request under a broker-owned timeout that is independent of caller cancellation." (This kind of example is easier to just memorize in full. I usually apply it by memorizing the example itself.)
The lifetime of coroutine inputs also deserves separate attention: value parameters are moved into the coroutine frame, but reference parameters remain as references. So if you pass a temporary object like execute_render_job(RenderJob{...}, ...) and the coroutine then suspends, the const RenderJob& becomes a dangling reference.6
In the example, RenderJob and slot_id are taken by value so the frame owns them. broker and renderer, on the other hand, are left as references under a service-lifetime contract that the caller keeps them alive until the operation completes.
The C++ implementation also preserves both the body error and the cleanup error as two separate std::exception_ptr values. It is genuinely difficult to report that combination safely with a single ordinary RAII destructor.
For synchronous cleanup, a destructor, Library Fundamentals TS v3's std::experimental::scope_exit, or Boost.Scope's scope guard may be the right fit. Note that std::scope_exit is not the name that made it into the C++23 standard library.9 Fallible async cleanup must be finished at an awaitable orchestration boundary. This example assumes a policy of using exceptions as the error channel. In a project that models all predictable failures as Result values, the same four steps must be composed as values instead, and the concrete form of that is covered in a later section.
A personal note: I'm writing C++ again after a long break, and I'm tracing back the sources of code I had saved and used before by looking through GitHub examples. C++ documentation feels heavily fragmented, and there are far too many things you just have to memorize.
C#
The example uses record struct, so it targets C# 10 or later. The runnable code uses ArgumentNullException.ThrowIfNull(), which requires .NET 6 or later. await using and IAsyncDisposable themselves have been available since C# 8 / .NET Core 3.0.1
using System;
using System.Threading;
using System.Threading.Tasks;
public readonly record struct RenderSlotToken(
string SlotId,
string LeaseId);
public interface IRenderSlotBroker
{
ValueTask<RenderSlotToken> AcquireAsync(
CancellationToken cancellationToken);
ValueTask ReleaseAsync(
RenderSlotToken token);
}
public sealed class RenderSlotLease : IAsyncDisposable
{
private readonly RenderSlotToken token;
private IRenderSlotBroker? broker;
private int disposeStarted;
private RenderSlotLease(
IRenderSlotBroker broker,
RenderSlotToken token)
{
this.broker = broker;
this.token = token;
}
public static async Task<RenderSlotLease> AcquireAsync(
IRenderSlotBroker broker,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(broker);
RenderSlotToken token =
await broker.AcquireAsync(cancellationToken)
.ConfigureAwait(false);
// A token returned normally by the Broker has already been validated,
// and this constitutes a contract that the caller may take on the responsibility of returning it.
return new RenderSlotLease(broker, token);
}
public string GetSlotId()
{
this.EnsureActive();
return this.token.SlotId;
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref this.disposeStarted, 1) != 0)
{
return;
}
IRenderSlotBroker? owner = this.broker;
this.broker = null;
if (owner is null)
{
return;
}
await owner.ReleaseAsync(this.token)
.ConfigureAwait(false);
}
private void EnsureActive()
{
if (Volatile.Read(ref this.disposeStarted) != 0)
{
throw new ObjectDisposedException(
nameof(RenderSlotLease));
}
}
}Usage syntax:
await using RenderSlotLease lease =
await RenderSlotLease.AcquireAsync(
broker,
cancellationToken);
string slotId = lease.GetSlotId();DisposeAsync() is called when execution leaves the scope of an await using block or declaration. C#'s using family is transformed so that the cleanup call is made even if an exception is thrown inside the block or a return is executed.
This type assumes that a single scope exclusively owns the lease. Interlocked.Exchange() merely prevents concurrent dispose calls from starting a duplicate remote release; it does not provide a borrow protocol that is safe for concurrent calls to GetSlotId() and DisposeAsync() from different threads. If the lease has been handed off to another task, you must await that task fully before the scope ends.
The public factory returns Task<RenderSlotLease>. ValueTask<T> is also viable if it is awaited exactly once, but it generally carries a consumption contract that says you must not await it more than once or mix it with AsTask(). For a public acquisition API without a measured allocation bottleneck, Task<T> is simpler for callers.10 Because the first Interlocked.Exchange() on disposeStarted already guarantees single entry, a second atomic exchange when clearing the broker reference was not used.
GC.SuppressFinalize() was not included in this sealed example because there is no finalizer and no unmanaged resource is directly owned. For inheritable types or types with a finalizer, Microsoft's async dispose pattern should be applied separately.1
TypeScript
This targets TypeScript 5.2 or later, which supports the using/await using syntax.2
export type RenderSlotToken = Readonly<{
slotId: string;
leaseId: string;
}>;
export type RenderSlotBroker = Readonly<{
acquireAsync: (
signal?: AbortSignal,
) => Promise<RenderSlotToken>;
releaseAsync: (
token: RenderSlotToken,
) => Promise<void>;
}>;
export class RenderSlotLease implements AsyncDisposable
{
readonly #token: RenderSlotToken;
#broker: RenderSlotBroker | undefined;
#disposeStarted = false;
private constructor(
broker: RenderSlotBroker,
token: RenderSlotToken,
)
{
this.#broker = broker;
this.#token = token;
}
public static async acquireAsync(
broker: RenderSlotBroker,
signal?: AbortSignal,
): Promise<RenderSlotLease>
{
signal?.throwIfAborted();
const token = await broker.acquireAsync(signal);
// A token returned successfully by the broker is a contract that it has already been validated.
return new RenderSlotLease(
broker,
token,
);
}
public getSlotId(): string
{
this.#ensureActive();
return this.#token.slotId;
}
public async [Symbol.asyncDispose](): Promise<void>
{
if (this.#disposeStarted) {
return;
}
this.#disposeStarted = true;
const broker = this.#broker;
this.#broker = undefined;
if (broker === undefined) {
return;
}
await broker.releaseAsync(this.#token);
}
#ensureActive(): void
{
if (this.#disposeStarted) {
throw new Error(
"This RenderSlotLease has already been released.",
);
}
}
}Usage syntax:
await using lease =
await RenderSlotLease.acquireAsync(
broker,
signal,
);
const slotId = lease.getSlotId();The await in await using instructs the runtime to await [Symbol.asyncDispose]() when the scope exits. The await on the right side serves a separate role: it awaits the Promise returned by the acquisition function so that the actual disposable object is assigned to the variable. Therefore, if the acquisition function itself returns a Promise, a separate await on the right side is also required.
The fact that the code compiles does not mean the runtime environment is ready. Verify that the target runtime provides Symbol.asyncDispose, and if not, include a compatible polyfill. Also check SuppressedError separately if you are running native Explicit Resource Management as-is or have code that directly references the global constructor.
That said, the helper TypeScript emits for ES2022 and below includes a fallback for when the SuppressedError global is absent, so a global polyfill is not always required solely for downleveled using and await using. AbortSignal.throwIfAborted() is also a separate runtime API. Do not assume that syntax support, type libraries, and runtime capabilities are all bundled together.2
Python
The code below uses typing.Self, so Python 3.11 or later is required.
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import (
AbstractAsyncContextManager,
asynccontextmanager,
)
from dataclasses import dataclass
from types import TracebackType
from typing import Protocol, Self
@dataclass(frozen=True, slots=True)
class RenderSlotToken:
slot_id: str
lease_id: str
class RenderSlotBroker(Protocol):
async def acquire(
self,
) -> RenderSlotToken:
...
async def release(
self,
token: RenderSlotToken,
) -> None:
...
class RenderSlotLease(
AbstractAsyncContextManager["RenderSlotLease"]
):
def __init__(
self,
broker: RenderSlotBroker,
token: RenderSlotToken,
) -> None:
self._broker: RenderSlotBroker | None = broker
self._token = token
self._dispose_started = False
@classmethod
async def acquire(
cls,
broker: RenderSlotBroker,
) -> Self:
token = await broker.acquire()
# A token that the broker returns successfully is a contract guaranteeing that it has already been validated.
return cls(broker, token)
async def __aenter__(
self,
) -> Self:
self._ensure_active()
return self
async def __aexit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: TracebackType | None,
) -> bool:
del exception_type
del exception
del traceback
await self.aclose()
return False
async def aclose(self) -> None:
if self._dispose_started:
return
self._dispose_started = True
broker = self._broker
self._broker = None
if broker is None:
return
await broker.release(self._token)
def get_slot_id(self) -> str:
self._ensure_active()
return self._token.slot_id
def _ensure_active(self) -> None:
if self._dispose_started:
raise RuntimeError(
"This RenderSlotLease has already been released."
)
@asynccontextmanager
async def render_slot_scope(
broker: RenderSlotBroker,
) -> AsyncIterator[RenderSlotLease]:
# Acquisition and cleanup registration are both completed within a single `__aenter__` boundary.
lease = await RenderSlotLease.acquire(broker)
try:
yield lease
finally:
await lease.aclose()
Usage syntax:
async with render_slot_scope(broker) as lease:
slot_id = lease.get_slot_id()Python's async with awaits __aenter__() and __aexit__(). If __aexit__() returns False, any exception raised inside the block propagates normally.
The current straightforward RenderSlotLease.__aenter__() does not suspend internally, so merely having no code between the acquisition statement and async with does not immediately cause a cancellation leak. However, if the acquired lease is exposed outside the context first and an await is later added between the two statements, or if __aenter__() begins asynchronous validation, an ownership gap arises. For this reason, the recommended entry point in this document is render_slot_scope(). The class's __aenter__()/__aexit__() protocol is kept for lower-level use cases that must directly own an already-acquired lease. Remote boundaries where an acquire response itself can be lost are not resolved by this structure alone; that responsibility falls on the broker's lease TTL and idempotent release protocol.
Note, however, that the fact that async with calls __aexit__() is distinct from saying that the cleanup coroutine cannot be cancelled.
The await broker.release(...) in the code above can also be interrupted if cancellation is delivered to the task again. If a broker requires release to complete without fail, you should create a separate cleanup task, maintain a strong reference to it, and combine asyncio.shield() with a dedicated timeout. Keep in mind that shield() does not make the caller's await itself immune to cancellation, so the broker policy must also cover how to retrieve the outcome (completion or failure) of the internal cleanup task after cancellation is received.11
3. Call Site
The Renderer has no knowledge of how the slot is returned; only the orchestration service manages the lifetime of the lease.
TypeScript call site example
export type RenderJob = Readonly<{
jobId: string;
sourceKey: string;
outputKey: string;
}>;
export type RenderReceipt = Readonly<{
jobId: string;
outputKey: string;
}>;
export type Renderer = Readonly<{
renderAsync: (
job: RenderJob,
slotId: string,
signal?: AbortSignal,
) => Promise<RenderReceipt>;
}>;
export class RenderJobService
{
readonly #broker: RenderSlotBroker;
readonly #renderer: Renderer;
public constructor(
broker: RenderSlotBroker,
renderer: Renderer,
)
{
this.#broker = broker;
this.#renderer = renderer;
}
public async executeAsync(
job: RenderJob,
signal?: AbortSignal,
): Promise<RenderReceipt>
{
validateJob(job);
await using lease =
await RenderSlotLease.acquireAsync(
this.#broker,
signal,
);
return await this.#renderer.renderAsync(
job,
lease.getSlotId(),
signal,
);
}
}
function validateJob(job: RenderJob): void
{
if (!isValidIdentifier(job.jobId)) {
throw new RangeError(
"The jobId format is invalid.",
);
}
if (job.sourceKey.length === 0
|| job.outputKey.length === 0) {
throw new RangeError(
"The input and output keys cannot be empty.",
);
}
}
function isValidIdentifier(value: string): boolean
{
return /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
}The return await at this call site is not a matter of preference.
If you write return this.#renderer.renderAsync(...) and return only the Promise, the function exits the scope and may begin disposal while the renderer's async work is still using the slot. Because the Promise must be settled inside the scope before the lease is returned, the await here is part of the lifetime boundary.
Note also that await using itself does not await the acquisition Promise on the right-hand side on your behalf.2
Separation of concerns:
API Boundary
= RenderJob input validation
RenderJobService
= acquire lease
→ invoke renderer
→ end scope
RenderSlotLease
= represents process-local ownership of a slot
= does not double-start broker cleanup entry point
RenderSlotBroker
= remote coordinator I/O
= handles release timeout and protocol
Renderer
= actual GPU work execution
= no responsibility for slot return
Central Policy Handler
= acquisition failures
= rendering failures
= slot release failures
= classifies cancellation vs. timeout
= preserves both body errors and cleanup errors per language-specific rulesPass the caller's cancellation token to slot acquisition and rendering work. However, immediately aborting the release of an already-acquired slot using that same cancellation token can leave the resource stranded. It is safer to manage the timeout and retry policy for release operations through a separate cleanup policy inside the broker.
There is also a window during the acquisition phase that the scope guard cannot reach.
coordinator completes slot allocation
→ response lost on the network
→ client's acquire times out
→ lease object is never createdThe client holds no token to release, yet the lease remains on the server. This problem cannot be fixed with try/finally in the factory. What is needed instead is an acquire request ID, the coordinator's lease TTL, a heartbeat, and an orphan reaper. The scope guard's protection begins only after ownership transfer has been successfully observed.
4. Reading order
Who acquires the resource?
→ Is the acquisition success point clear?
→ Is the resource owner fixed to a single object?
→ Is the usage scope limited to a lexical block?
→ Is the resource released on normal completion, return, and exceptions?
→ If release is asynchronous, does it await completion?
→ Is double release a no-op?
→ Does it block resource access after release starts?
→ Does caller cancellation NOT invalidate cleanup?
→ Are cleanup failures propagated to the central policy layer?The first thing to verify is not the existence of a close() method, but whether the acquired object is always consumed within a scope protocol.
5. Boundaries and misconceptions
Async scope guards are appropriate for the following resources.
Async DB connection and transaction
Remote task slot
Distributed semaphore permit
Temporary object-storage upload session
Async message consumer
Buffered writer requiring flushPlain in-memory objects do not need a dispose protocol. A garbage collector reclaims object memory, but it does not guarantee timely return of external connections or remote leases at the moment they are no longer needed. Async dispose is a contract for explicit resource cleanup, not for memory deallocation.
Do not mix cleanup with commit
A render slot calls the same release regardless of whether the main body succeeded. Transactions and upload sessions are different.
DB transaction
Success path → explicit CommitAsync
Exception or incomplete scope termination → RollbackAsync or Dispose
Multipart upload
Success path → CompleteAsync
Exception or incomplete scope termination → AbortAsyncIf DisposeAsync() automatically performs a commit that signals business success, it can finalize partial results even on code paths where the main body failed partway through. The safe default is to commit explicitly on success and to ensure that cleanup of an uncommitted scope performs a rollback or abort.
Python's __aexit__() receives the main body's exception information as an argument, but C#'s DisposeAsync() and ECMAScript's [Symbol.asyncDispose]() do not accept an equivalent parameter. If cleanup behavior must vary depending on whether the main body succeeded, do not leave that inference to the language's dispose hook; instead, expose a state transition like CommitAsync() explicitly in the API.
Do not release resources you do not own
Keep in mind that the following two forms have different semantics.
Receives the broker via constructor injection
→ the broker's lifetime is owned by the external composition root
→ the lease must not dispose the broker itself
Acquires a token from the broker
→ the lease owns responsibility for returning the token
→ must release it when the scope exitsIf an individual operation arbitrarily terminates a singleton client or connection pool managed by a DI container, other requests can also fail.
Sequential duplicate Dispose and concurrent Dispose are different things
Under normal scope syntax a dispose is called only once, but a defensive resource implementation is safer if it treats a dispose that arrives after completion as a no-op.
First Dispose
→ Ownership removed
→ Remote release
Second Dispose
→ Ownership already gone
→ No-opThe critical ordering is to deactivate the state before starting the release, not after the release completes. Otherwise, two concurrent calls can both perform the release I/O.
The current example assumes single-owner scope. If a second dispose arrives while the first is awaiting releaseAsync(), the second call returns immediately. This is not a model where both callers jointly observe the remote release's completion or failure. To support concurrent dispose as a public API, store the initial cleanup's Task/Promise/Future in a field and return the same terminal operation to all callers. A single boolean flag only prevents duplicate I/O; it does not solve completion sharing.
The C#/TypeScript/Python lease implementations in the main body remove the owner reference before starting the release. Therefore, even if the first release fails, you cannot retry by calling Dispose again on the same lease object. The lease prohibits further use immediately, and the broker is responsible for bounded retries and idempotency internally. If the broker does not provide that policy, this implementation guarantees only at-most-one client call and does not guarantee that the remote return succeeds.
Do not swallow cleanup failures
Remote releases can also fail.
Rendering succeeded
→ Slot return failed
Rendering failed
→ Slot return also failedIf the resource type simply logs and ignores such failures, the coordinator's state diverges from the actual state. Cleanup failures must be propagated all the way to a central policy handler: as a typed infrastructure exception in modules that use exception policies, and as a structured failure value in Result-First modules. How an original operation failure and a cleanup failure are automatically preserved together also varies by language.
Language | When both the main body and cleanup fail |
|---|---|
C++ coroutine helper | As shown in the example, store the main body error and the cleanup error as separate |
TypeScript / ECMAScript ERM |
|
C# | If dispose fails again inside |
Python | The exception from |
- Language
C++ coroutine helper
- When both the main body and cleanup fail
As shown in the example, store the main body error and the cleanup error as separate
std::exception_ptrvalues, then throw an explicitly composed error. Do not handle this in a destructor.
- Language
TypeScript / ECMAScript ERM
- Language
C#
await using- When both the main body and cleanup fail
If dispose fails again inside
finally, the cleanup exception propagates outward. The two errors are not automatically bundled into anAggregateException. If both are needed, an explicit orchestration helper must capture the body error and combine it with the disposal error.
- Language
Python
async with- When both the main body and cleanup fail
The exception from
__aexit__()becomes the current error, and the body error generally remains in the__context__chain. To treat both errors as equivalent peers, an explicit policy such asExceptionGroup/BaseExceptionGroupfrom Python 3.11 or later is required.
Therefore, you should not assume that "using scope syntax means both errors are visible to the central handler." Also verify that your logging and trace exporters actually traverse aggregate, suppressed, and cause/context chains.
In a Result-First architecture, scope syntax becomes a boundary
In projects that handle predictable failures with Result<Success, Failure>, cleanup failures must also be expressed as values. Directly exposing await using and async with in the application flow becomes awkward in this case, because these constructs provide no place to return a separate result from the cleanup function alongside the block's return value. In typical implementations, a faulted ValueTask from DisposeAsync(), a rejected Promise, or an exception from __aexit__() propagates outward. This creates two error channels: a render failure as a Result, but a release failure as an exception.
There are four states to compose.
Body | Cleanup | Final result |
|---|---|---|
Success | Success |
|
Success | Failure |
|
Failure | Success |
|
Failure | Failure |
|
- Body
Success
- Cleanup
Success
- Final result
Result.Success(receipt)
- Body
Success
- Cleanup
Failure
- Final result
Result.Failure(CleanupFailed)
- Body
Failure
- Cleanup
Success
- Final result
Result.Failure(RenderFailed)
- Body
Failure
- Cleanup
Failure
- Final result
Result.Failure(BodyAndCleanupFailed)
If DisposeAsync() logs a cleanup failure internally and completes normally, the failure result disappears entirely. Conversely, calling ReleaseAsync() separately in addition to dispose creates a duplicate release. To enforce a strict Result-First policy, a single function must own resource acquisition, body execution, and release, and that same function must assemble the final Result.
The C# example below assumes a Result pattern that closes the failure type down to a single AppError within the application.
The detailed implementation of Result<T>, AppError.Cancelled(), AppError.Timeout(), AppError.Unexpected(), and AppError.Combine() can be tailored to fit your project's types.
public readonly record struct Unit;
public interface IResultRenderSlotBroker
{
ValueTask<Result<RenderSlotToken>> AcquireAsync(
CancellationToken cancellationToken);
ValueTask<Result<Unit>> ReleaseAsync(
RenderSlotToken token,
CancellationToken cancellationToken);
}
public static class LeaseExecution
{
public static async ValueTask<Result<T>> ExecuteWithLeaseAsync<T>(
IResultRenderSlotBroker broker,
Func<string, CancellationToken, ValueTask<Result<T>>> body,
TimeSpan cleanupTimeout,
CancellationToken callerToken = default)
{
ArgumentNullException.ThrowIfNull(broker);
ArgumentNullException.ThrowIfNull(body);
if (cleanupTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(cleanupTimeout),
"Cleanup timeout must be positive.");
}
Result<RenderSlotToken> acquired;
try
{
acquired = await broker.AcquireAsync(callerToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException ex)
when (callerToken.IsCancellationRequested)
{
return Result<T>.Fail(
AppError.Cancelled("LEASE_ACQUIRE_CANCELLED", ex));
}
catch (Exception ex)
{
// This is the final boundary for runtime/third-party exceptions that the Adapter failed to convert into values.
return Result<T>.Fail(
AppError.Unexpected("LEASE_ACQUIRE_THROWN", ex));
}
if (acquired.IsFailure)
{
return Result<T>.Fail(acquired.Error);
}
RenderSlotToken token = acquired.Value;
Result<T> bodyResult;
try
{
bodyResult = await body(
token.SlotId,
callerToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException ex)
when (callerToken.IsCancellationRequested)
{
bodyResult = Result<T>.Fail(
AppError.Cancelled("LEASE_BODY_CANCELLED", ex));
}
catch (Exception ex)
{
// Business failures should already arrive as `Result`. What gets caught here are unexpected exceptions.
bodyResult = Result<T>.Fail(
AppError.Unexpected("LEASE_BODY_THROWN", ex));
}
Result<Unit> cleanupResult;
using (var cleanupCts =
new CancellationTokenSource(cleanupTimeout))
{
try
{
cleanupResult = await broker.ReleaseAsync(
token,
cleanupCts.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException ex)
when (cleanupCts.IsCancellationRequested)
{
cleanupResult = Result<Unit>.Fail(
AppError.Timeout("LEASE_CLEANUP_TIMEOUT", ex));
}
catch (Exception ex)
{
cleanupResult = Result<Unit>.Fail(
AppError.Unexpected("LEASE_CLEANUP_THROWN", ex));
}
}
if (bodyResult.IsSuccess && cleanupResult.IsSuccess)
{
return bodyResult;
}
if (bodyResult.IsFailure && cleanupResult.IsSuccess)
{
return bodyResult;
}
if (bodyResult.IsSuccess && cleanupResult.IsFailure)
{
return Result<T>.Fail(cleanupResult.Error);
}
return Result<T>.Fail(
AppError.Combine(
"LEASE_BODY_AND_CLEANUP_FAILED",
bodyResult.Error,
cleanupResult.Error));
}
}The try/catch inside this function has a narrow role. Predictable business failures are captured as Result from the start; the adapter's job is to catch, at the boundary, only those exceptions from the runtime, third-party libraries, and framework callbacks that are unaware of the project's contracts, convert them to AppError, and return only values to the application layer. If try/catch is literally banned across the entire repository, an unexpected exception escaping the body can skip cleanup entirely.
The order of catch clauses and filters carries meaning for cancellation exceptions. An acquire or body cancelled by the caller token is preserved as Cancelled, while a cleanup-only token expiration is preserved as Timeout. Classifying an OperationCanceledException from a different token under the same meaning would obscure the cause, so it is passed through to the unexpected exception boundary that follows.
However, not every exception should be demoted to an Unknown result. Swallowing programmer errors, broken invariants, or states where the process can no longer be trusted as though they were ordinary business failures hides real incidents. Which exceptions get converted to AppError and which get re-thrown after cleanup is a question the project's failure policy must answer. The same applies to cancellation: if user cancellation is a normal outcome, convert it to a Cancelled result; if the framework requires a cancellation exception, convert it back outside the boundary.
C++ follows the same structure. The earlier std::exception_ptr-based example is an implementation for the exception policy. If you are using C++23, you can change the return type to std::expected<RenderReceipt, LeaseExecutionFailure>; for C++20 projects, you can use your own Result<T, E> or a well-tested expected implementation. TypeScript and Python can each provide an orchestration function that returns Promise<Result<T>> or Awaitable[Result[T]]. Regardless of the implementation, every exit path after acquire is merged with the cleanup result and returned to the caller.13
The following criteria are sufficient for choosing between them.
Project error policy | Recommended approach |
|---|---|
Treat cleanup failures as exceptions |
|
Cleanup failures are branching targets for the caller | A Result-composing function such as |
Like a transaction, success requires a separate commit | Explicit commit/rollback state transitions inside the Result-composing function |
- Project error policy
Treat cleanup failures as exceptions
- Recommended approach
await using,async with, C++ coroutine bracket
- Project error policy
Cleanup failures are branching targets for the caller
- Recommended approach
A Result-composing function such as
ExecuteWithLeaseAsync<T>
- Project error policy
Like a transaction, success requires a separate commit
- Recommended approach
Explicit commit/rollback state transitions inside the Result-composing function
Result does not make remote effects any more certain. If a release response is lost, Result.Failure(Timeout) still cannot tell you whether the release was applied on the server. In such cases, the leaseId, idempotent release, timeout, and TTL described earlier remain necessary.
Here, the timeout in CancellationTokenSource(cleanupTimeout) is a time budget based on the assumption that ReleaseAsync() cooperatively observes that token. It does not forcibly terminate operations that ignore the token, and a timeout does not mean the remote release was not applied. The server-side TTL and idempotent request key must handle the final recovery boundary.
A personal note: since the 2020s, explicit error handling centered on
Resulthas been spreading rapidly even in mainstream languages. This is less a newly invented idea than the popularization of sum types long used in functional languages and Rust, applied to error handling. In Korea, it does tend to be consumed as something of a passing trend.Swift 5.0 added
Result<Success, Failure>, and C++23 introducedstd::expected<T, E>for a similar purpose. TypeScript's discriminated unions and Kotlin's sealed classes can also be used for error modeling in the same direction, so they are worth learning.For reusable libraries or domain boundaries, it is better to use
Result<T, E>to make the error kinds explicit. In contrast, at a concrete application assembly layer, aResult<T>alias withEfixed to a commonAppErrormay be sufficient. However, flattening error types into one too early makes it harder for callers to distinguish between domain failures they can handle and system faults they cannot.This is really just for interviews; saying you use it because everyone else does is fine enough.
Scope guard cannot prevent process termination
In the following situations, the cleanup code either does not execute at all, or executes but cannot confirm the remote effect.
- Forced process termination
- Device power cutoff
- runtime crash
- Network disconnection: cleanup was invoked, but whether it took effect on the remote side is unknownFor remote leases, the coordinator also needs an expiry time, heartbeat, or orphan cleanup policy. A scope guard only makes normal control flow safe; it does not replace fault recovery in distributed systems.
Do not accidentally suppress exceptions in Python
If __aexit__() returns a truthy value, it is treated as having handled the exception from the body. A context manager responsible only for resource cleanup should generally return False so that the original exception continues to propagate. Python's async with semantics also determine whether to re-raise an exception based on the return value of __aexit__().
Ways production can fail:
Calling `release` only on the success path
Fire-and-forgetting `cleanup` without awaiting it
Canceling all the way through `release` immediately via caller cancellation
Allowing the token to remain accessible externally after `dispose`
Duplicate `dispose` calls triggering remote `release` repeatedly
A lease shutting down the broker it does not own
Swallowing cleanup errors with only a log entry
Expecting the finalizer or GC to reclaim resources in a timely manner
`acquire` returns a token, then validation in the factory fails so the guard is never created
Assuming the remote `release` has exactly-once semantics
Sharing a lease across multiple tasks while one side disposes it first
Accidentally returning `True` from `__aexit__` in Python
Not verifying `Symbol.asyncDispose` support in the TypeScript runtime
Blocking the event loop in a C++ destructor to wait for async cleanup
Launching a cleanup coroutine as detached in a C++ destructor, losing its completion and any errors
Passing reference parameters to a C++ coroutine, causing temporaries to dangle
Attempting `co_await` inside a C++ `catch` handler, making cleanup recovery fail to compile
Placing the `co_await` that rolls back cleanup state outside the error-collection block, causing body errors to be lost
In Result-First code, capturing only body failures as values while leaving cleanup failures as exceptions
`DisposeAsync` swallowing cleanup failures, making it appear as though the Result flow has been honored6. Incorrect Example
async function renderJobBadAsync(
job: RenderJob,
broker: RenderSlotBroker,
renderer: Renderer,
signal?: AbortSignal,
): Promise<RenderReceipt>
{
const token = await broker.acquireAsync(signal);
const receipt = await renderer.renderAsync(
job,
token.slotId,
signal,
);
await broker.releaseAsync(token);
return receipt;
}If rendering fails, the following flow occurs.
acquire succeeded
→ render failed
→ function exits immediately
→ release never executesWhy this is bad:
Release exists only on the success path.
Every new `return` site introduces the risk of a missed release.
The caller must manage ownership of the token directly.
There is no guard against double-release.
Access to the token after release cannot be prevented.
The acquisition and use scope are not reflected in the type system or syntax.You can fix it with a manual try/finally.
const token = await broker.acquireAsync(signal);
try {
return await renderer.renderAsync(
job,
token.slotId,
signal,
);
} finally {
await broker.releaseAsync(token);
}However, if this pattern is repeated across multiple call sites, the release policy and duplicate-defense logic become scattered. It is better for the resource type itself to implement AsyncDisposable and have call sites use only await using, which is easier to maintain.
7. Production Scaling
The core contracts to automate inside the process are as follows.
- `broker.release` called once after successful completion
- `broker.release` called once even after task failure
- Sequential re-calls after `dispose` completes still result in exactly one `broker.release` call total
- Release failure is observable by the caller
- On `acquire` failure, the guard does not fabricate a `release` call
- Lease use is rejected after `dispose` has startedThis test does not prove that release was applied exactly once to the remote coordinator. It only verifies the calling contract up to the fake broker boundary. The actual protocol, including duplicate requests, lost responses, retries after timeout, and lease expiry, must be validated separately with integration tests.
TypeScript Automated Tests
import assert from "node:assert/strict";
import test from "node:test";
class FakeRenderSlotBroker implements RenderSlotBroker
{
readonly #token: RenderSlotToken = {
slotId: "slot-1",
leaseId: "lease-1",
};
#acquireCount = 0;
#releaseCount = 0;
public constructor(
readonly failRelease = false,
) {}
public async acquireAsync(): Promise<RenderSlotToken>
{
this.#acquireCount += 1;
return this.#token;
}
public async releaseAsync(
token: RenderSlotToken,
): Promise<void>
{
assert.deepEqual(token, this.#token);
this.#releaseCount += 1;
if (this.failRelease) {
throw new Error("Slot release failure");
}
}
public getAcquireCount(): number
{
return this.#acquireCount;
}
public getReleaseCount(): number
{
return this.#releaseCount;
}
}
test("Releases the slot once on normal termination", async () =>
{
const broker = new FakeRenderSlotBroker();
{
await using lease =
await RenderSlotLease.acquireAsync(broker);
assert.equal(
lease.getSlotId(),
"slot-1",
);
}
assert.equal(broker.getAcquireCount(), 1);
assert.equal(broker.getReleaseCount(), 1);
});
test("Releases the slot even if the body fails", async () =>
{
const broker = new FakeRenderSlotBroker();
await assert.rejects(
async () =>
{
await using lease =
await RenderSlotLease.acquireAsync(broker);
assert.equal(
lease.getSlotId(),
"slot-1",
);
throw new Error("Rendering failure");
},
/Rendering failure/,
);
assert.equal(broker.getReleaseCount(), 1);
});
test("Sequential dispose after completion does not repeat the remote release", async () =>
{
const broker = new FakeRenderSlotBroker();
const lease =
await RenderSlotLease.acquireAsync(broker);
await lease[Symbol.asyncDispose]();
await lease[Symbol.asyncDispose]();
assert.equal(broker.getReleaseCount(), 1);
});
test("Rejects slot access after disposal", async () =>
{
const broker = new FakeRenderSlotBroker();
const lease =
await RenderSlotLease.acquireAsync(broker);
await lease[Symbol.asyncDispose]();
assert.throws(
() =>
{
lease.getSlotId();
},
/already disposed/,
);
});
test("Propagates release failure to the caller", async () =>
{
const broker = new FakeRenderSlotBroker(true);
await assert.rejects(
async () =>
{
await using lease =
await RenderSlotLease.acquireAsync(broker);
assert.equal(lease.getSlotId(), "slot-1");
},
/Slot release failure/,
);
assert.equal(broker.getReleaseCount(), 1);
});The TypeScript compiler configuration requires a disposable type library.
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"ESNext.Disposable"
],
"types": [
"node"
],
"strict": true,
"noImplicitReturns": true
}
}The configuration above is based on a Node.js example, so @types/node is required. For browser projects, include the DOM library for Web API types such as AbortSignal, adjusting to match your runtime environment.
In production, monitor the following metrics.
render_slot.acquire.count
render_slot.acquire.duration_ms
render_slot.release.count
render_slot.release.duration_ms
render_slot.release.failed.count
render_slot.active.count
render_slot.orphan_expired.countPutting raw leaseId or slotId values into metric labels increases cardinality. Limit per-lease tracking to structured logs or trace fields, and record only aggregated state in metrics.
Keep the cleanup timeout separate from caller cancellation.
Task cancellation
= a request to abort rendering
cleanup timeout
= additional time allowed to return the acquired slotThe broker's releaseAsync() implementation should use a short, dedicated timeout and an idempotent release protocol. For release endpoints that support retries, keep the leaseId identical across attempts so that duplicate returns are harmless.
8. Comparison Notes: C++ / C# / TypeScript / Python
Language | Idiomatic Pattern | Key Caveats |
|---|---|---|
C++ | Synchronous RAII + coroutine-level async bracket | Destructors cannot |
C# |
| In the dispose implementation, atomically remove ownership first |
TypeScript |
| The acquisition Promise requires a separate |
Python | async context manager + | Check the return value of |
- Language
C++
- Idiomatic Pattern
Synchronous RAII + coroutine-level async bracket
- Key Caveats
Destructors cannot
co_await. Do not work around this with blocks or detached cleanup.
- Language
C#
- Idiomatic Pattern
IAsyncDisposable+await using- Key Caveats
In the dispose implementation, atomically remove ownership first
- Language
TypeScript
- Idiomatic Pattern
AsyncDisposable+await using- Key Caveats
The acquisition Promise requires a separate
await
- Language
Python
- Idiomatic Pattern
async context manager +
async with- Key Caveats
Check the return value of
__aexit__(), re-cancellation during cleanup, and exception context together
The choices shown in the table apply when using each language's built-in exception channel. In Result-First applications, all four languages are better served by placing acquire, body, and cleanup inside a single orchestration function and composing failures as values.
A sealed resource type in C# can implement cleanup directly in DisposeAsync(). For inheritable types, follow Microsoft's async dispose pattern and consider a separate virtual core cleanup method.
TypeScript's await using calls and awaits [Symbol.asyncDispose]() when the scope exits. If multiple disposables are declared, they are cleaned up in reverse order when the scope closes.
Python supports both class-based __aenter__()/__aexit__() and generator-based asynccontextmanager(). For resources that require state and protection against double-release, a class is clearer; for simple acquire/yield/release adapters, the decorator approach is more concise.
When multiple resources must be acquired in stages, consider each language's stack-based tool as well. TypeScript has AsyncDisposableStack and Python has contextlib.AsyncExitStack.214 Both tools let you register cleanup immediately after acquisition, so that if a later acquisition fails, any already-registered resources are cleaned up in reverse order. This makes partial acquisition failures easier to handle than manually nesting multiple await using/async with blocks.
Synchronous resources in C++ are best managed with destructor-based RAII. However, because async cleanup cannot be automatically awaited through standard syntax, a coroutine combinator or framework-level async scope must own the resource from acquisition through release.
There is no need to force the same closeAsync() name across C#, TypeScript, and Python. The common contract is as follows.
The scope object holds ownership of the acquired resource,
and scope exit waits until async release completes.9. Further Considerations
When caller cancellation occurs, should cleanup receive the same cancellation signal, or should it use a separate timeout?
Does the resource wrapper own the broker itself, or only the token borrowed from the broker?
When both the main operation and cleanup fail simultaneously, in what form should both errors be preserved up to the observation layer?
What idempotency contract is needed so that the remote coordinator can safely receive a release request more than once?
If dispose is never executed due to a forced process termination, who is responsible for lease expiration and orphan cleanup?
When acquiring multiple resources in a nested fashion, is a global acquisition order needed to avoid deadlock?
If the server has allocated a resource but the acquire response was lost, what request ID is used to locate the orphan?
If a lease can be transferred to another task, what ownership rules prevent a race between dispose and active use?
10. Key Takeaways
Async resources must bind acquisition and release to a lexical scope.
Synchronous resources in C++ should be cleaned up with RAII, while async release is awaited not in the destructor but in a coroutine-level bracket.
In C++ coroutines, only value parameters are owned by the frame, and
co_awaitcannot appear inside a catch handler; cleanup recovery must also be collected in a separatetryblock.C# uses
await using, TypeScript usesawait using, and Python usesasync with.Make sequential Dispose calls after completion a no-op, and deactivate the resource before disposal begins.
To support concurrent Dispose calls, all callers must share the Task/Promise/Future of the first cleanup invocation.
Caller cancellation must not unconditionally abort cleanup for resources that have already been acquired.
Do not swallow cleanup failures; propagate them to a central policy handler.
Under a Result-First policy, an orchestration function must combine all four combinations of body and cleanup outcomes into a single
Result.Even when predictable failures are handled as values, you still need an infrastructure boundary that transforms or re-propagates unexpected exceptions after cleanup.
A scope guard protects normal control flow, but it does not substitute for process crashes or distributed lease expiration.
A scope guard does not guarantee exactly-once semantics for remote release; that requires idempotency on the broker side, along with retries and a TTL.
Token validation must complete before the broker returns normally, or else you risk a leak before the scope is created.
Memory rules:
Once you acquire a resource, don't try to remember to release it.
Use it only within a scope where release follows automatically.Footnotes
- Microsoft Learn, "Implement a DisposeAsync method" and
IAsyncDisposableAPI documentation. https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-disposeasync ↩ - TypeScript 5.2 Release Notes, "
usingDeclarations and Explicit Resource Management." https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html ↩ - Python Language Reference, "The
async withstatement." https://docs.python.org/3/reference/compound_stmts.html#the-async-with-statement ↩ - cppreference, "Destructors." Destructors cannot be coroutines, and exceptions thrown from destructors during stack unwinding can lead to
std::terminate. https://en.cppreference.com/cpp/language/destructor ↩ - AWS Well-Architected Framework, "Make all responses idempotent." https://docs.aws.amazon.com/wellarchirected/2022-03-31/framework/relpreventinteractionfailureidempotent.html ↩
- cppreference, "Coroutines (C++20)." Language transformations and restrictions of C++ coroutines. https://en.cppreference.com/cpp/language/coroutines ↩
- Boost.Asio, "C++20 Coroutines Support." Covers
awaitable, per-operation cancellation,reset_cancellation_state, andthrow_if_cancelled. https://www.boost.org/doc/libs/latest/doc/html/boostasio/overview/composition/cpp20coroutines.html ↩ - ISO C++ working draft, [expr.await]. An await-expression may appear only inside the compound-statement of a function body, outside of a handler. https://eel.is/c++draft/expr.await ↩
- WG21 N4939, "C++ Extensions for Library Fundamentals, Version 3."
scope_exit,scope_fail, andscope_successare specified as Library Fundamentals TS v3 features in<experimental/scope>. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4939.html ↩ - Microsoft Learn,
ValueTask<TResult>. Describes usage constraints against awaiting a single instance multiple times or consuming it redundantly alongsideAsTask(). https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1 ↩ - Python Standard Library,
asyncio.shield()cancellation semantics. https://docs.python.org/3/library/asyncio-task.html#shielding-from-cancellation ↩ - TC39, "ECMAScript Explicit Resource Management," specification for
AsyncDisposableandSuppressedError. https://tc39.es/proposal-explicit-resource-management/ ↩ - cppreference,
std::expected(C++23). A standard vocabulary type that holds either a success valueTor an error valueE. https://en.cppreference.com/cpp/utility/expected ↩ - Python Standard Library,
contextlib.asynccontextmanagerandAsyncExitStack. https://docs.python.org/3/library/contextlib.html ↩