Skip to content
Code Card

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.

Jeong Dongwoo · 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:

Loading animated diagram...

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.

Text
RenderSlotBroker:
- acquireAsync()
- releaseAsync(token)

RenderSlotToken:
- slotId
- leaseId

The workflow is as follows.

Text
1. Acquire Rendering Slot
2. Execute Rendering
3. Return Slot

Loading animated diagram...

During rendering, any of the following events can occur.

Text
- Normal completion
- Input file parsing failure
- GPU operation failure
- Caller cancellation
- Early return from method

With manual release, every code path must execute the following.

Text
await broker.releaseAsync(token)

Loading animated diagram...

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.

Text
await using / async with
Inside the block, `slotId` is used
When the block exits, `releaseAsync` is executed automatically

The single domain of this card is binding the acquisition and release of an async lease to a lexical scope.


2. Core Expressions

Common contract:

Text
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: prohibited

Loading animated diagram...

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

C++
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

C++
#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

C#
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:

C#
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

TypeScript
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:

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

Python
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:

Python
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

TypeScript
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:

Text
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 rules

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

Text
coordinator completes slot allocation
→ response lost on the network
→ client's acquire times out
→ lease object is never created

The 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

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

Text
Async DB connection and transaction
Remote task slot
Distributed semaphore permit
Temporary object-storage upload session
Async message consumer
Buffered writer requiring flush

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

Text
DB transaction
  Success path → explicit CommitAsync
  Exception or incomplete scope termination → RollbackAsync or Dispose

Multipart upload
  Success path → CompleteAsync
  Exception or incomplete scope termination → AbortAsync

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

Text
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 exits

Loading animated diagram...

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

Loading animated diagram...

Text
First Dispose
→ Ownership removed
→ Remote release

Second Dispose
→ Ownership already gone
→ No-op

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

Text
Rendering succeeded
→ Slot return failed

Rendering failed
→ Slot return also failed

If 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 std::exception_ptr values, then throw an explicitly composed error. Do not handle this in a destructor.

TypeScript / ECMAScript ERM

SuppressedError places the cleanup error in error and the preceding body error in suppressed.212

C# await using

If dispose fails again inside finally, the cleanup exception propagates outward. The two errors are not automatically bundled into an AggregateException. If both are needed, an explicit orchestration helper must capture the body error and combine it with the disposal error.

Python async with

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 as ExceptionGroup/BaseExceptionGroup from Python 3.11 or later is required.

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_ptr values, then throw an explicitly composed error. Do not handle this in a destructor.

Language

TypeScript / ECMAScript ERM

When both the main body and cleanup fail

SuppressedError places the cleanup error in error and the preceding body error in suppressed.212

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 an AggregateException. 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 as ExceptionGroup/BaseExceptionGroup from 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

Result.Success(receipt)

Success

Failure

Result.Failure(CleanupFailed)

Failure

Success

Result.Failure(RenderFailed)

Failure

Failure

Result.Failure(BodyAndCleanupFailed)

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.

C#
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

await using, async with, C++ coroutine bracket

Cleanup failures are branching targets for the caller

A Result-composing function such as ExecuteWithLeaseAsync<T>

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 Result has 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 introduced std::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, a Result<T> alias with E fixed to a common AppError may 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.

Text
- Forced process termination
- Device power cutoff
- runtime crash
- Network disconnection: cleanup was invoked, but whether it took effect on the remote side is unknown

For 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:

Text
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 honored

6. Incorrect Example

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

Text
acquire succeeded
→ render failed
→ function exits immediately
→ release never executes

Why this is bad:

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

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

Text
- `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 started

This 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

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

JSON
{
  "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.

Text
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.count

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

Text
Task cancellation
= a request to abort rendering

cleanup timeout
= additional time allowed to return the acquired slot

The 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 co_await. Do not work around this with blocks or detached cleanup.

C#

IAsyncDisposable + await using

In the dispose implementation, atomically remove ownership first

TypeScript

AsyncDisposable + await using

The acquisition Promise requires a separate await

Python

async context manager + async with

Check the return value of __aexit__(), re-cancellation during cleanup, and exception context together

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.

Text
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_await cannot appear inside a catch handler; cleanup recovery must also be collected in a separate try block.

  • C# uses await using, TypeScript uses await using, and Python uses async 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:

Text
Once you acquire a resource, don't try to remember to release it.
Use it only within a scope where release follows automatically.


Footnotes

  1. Microsoft Learn, "Implement a DisposeAsync method" and IAsyncDisposable API documentation. https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-disposeasync
  2. TypeScript 5.2 Release Notes, "using Declarations and Explicit Resource Management." https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html
  3. Python Language Reference, "The async with statement." https://docs.python.org/3/reference/compound_stmts.html#the-async-with-statement
  4. 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
  5. AWS Well-Architected Framework, "Make all responses idempotent." https://docs.aws.amazon.com/wellarchirected/2022-03-31/framework/relpreventinteractionfailureidempotent.html
  6. cppreference, "Coroutines (C++20)." Language transformations and restrictions of C++ coroutines. https://en.cppreference.com/cpp/language/coroutines
  7. Boost.Asio, "C++20 Coroutines Support." Covers awaitable, per-operation cancellation, reset_cancellation_state, and throw_if_cancelled. https://www.boost.org/doc/libs/latest/doc/html/boostasio/overview/composition/cpp20coroutines.html
  8. 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
  9. WG21 N4939, "C++ Extensions for Library Fundamentals, Version 3." scope_exit, scope_fail, and scope_success are specified as Library Fundamentals TS v3 features in <experimental/scope>. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4939.html
  10. Microsoft Learn, ValueTask<TResult>. Describes usage constraints against awaiting a single instance multiple times or consuming it redundantly alongside AsTask(). https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1
  11. Python Standard Library, asyncio.shield() cancellation semantics. https://docs.python.org/3/library/asyncio-task.html#shielding-from-cancellation
  12. TC39, "ECMAScript Explicit Resource Management," specification for AsyncDisposable and SuppressedError. https://tc39.es/proposal-explicit-resource-management/
  13. cppreference, std::expected (C++23). A standard vocabulary type that holds either a success value T or an error value E. https://en.cppreference.com/cpp/utility/expected
  14. Python Standard Library, contextlib.asynccontextmanager and AsyncExitStack. https://docs.python.org/3/library/contextlib.html