Uni20
Asynchronous tensor and runtime library.
Loading...
Searching...
No Matches
Exceptions and Cancellation

This document explains how failures move through async coroutines and epoch contexts.

The key distinction is:

  • access errors (uninitialized read/write)
  • cancellation signals
  • unhandled exceptions escaping coroutine bodies

Exception Hierarchy

Defined in src/uni20/async/async_errors.hpp.

Type Meaning
async_error base class for async runtime failures
async_state_error invalid state/access class
async_storage_missing storage metadata unexpectedly missing
async_value_uninitialized direct async value access before initialization
async_cancellation cancellation-related base class
task_cancelled coroutine/task cancellation signal
buffer_error buffer-specific state failure
buffer_cancelled read buffer cancelled intentionally
buffer_read_uninitialized read attempted before any value was constructed
buffer_write_uninitialized mutable write ref requested before construction

Access-Path Behavior

Read side

  • normal read path on missing value: buffer_read_uninitialized
  • or_cancel() path on missing value: task_cancelled
  • every read path propagates a stored writer exception unchanged; or_cancel() only changes the missing-value case

Write side

  • awaiting an unconstructed writer still returns WriteAccessProxy<T>
  • requesting get(), operator->, or conversion to T& from that proxy: buffer_write_uninitialized
  • preferred fix: co_await writer = value (or proxy emplace(...))

Suspension Registration

Uni20 awaiters that receive BasicTask or TaskFactory in await_suspend(...) must make that function noexcept. At that point the coroutine is suspended and its ownership is being transferred to the awaiter. If registration threw after moving one or more ownership claims, unwinding could not reliably undo waiter registration or recover every claim.

Perform fallible preparation before acquiring task ownership when possible. Failures discovered after the task becomes ready should be reported by await_resume(), where they follow normal coroutine exception propagation. The TaskAwaitable, TaskFactoryChildAwaitable, and TaskFactoryAwaitable concepts enforce the non-throwing registration rule.

try_await(...) changes only unavailable readiness into an empty optional. It does not suppress failures: exceptions from the readiness probe or the child's await_resume() propagate through the awaiting coroutine normally.

Unhandled Exception Flow in Coroutines

When a coroutine throws and does not catch:

  1. promise catches in unhandled_exception()
  2. if exception is task_cancelled, promise sets cancel-on-resume flag
  3. otherwise the debug registry marks the task failed and, when enabled, captures live exception diagnostics before the task leaves the registry
  4. promise stores std::exception_ptr
  5. promise forwards exception to registered epoch sinks

Set UNI20_DEBUG_DAG_DUMP_ON_EXCEPTION=1, or use TaskRegistry::set_coroutine_exception_diagnostics_options(...), to emit the live registry report and optional DOT artifact. This policy is off by default because an exception escaping one coroutine may still be expected and observed at a downstream Async<T> boundary.

At continuation/final-suspend boundaries, stored exceptions are forwarded to continuation promises.

Exception Sink Registration

Automatic path

WriteBuffer coroutine parameters are auto-registered as sinks.

This covers the normal kernel pattern where output buffers should receive failures from the coroutine computing them.

Explicit path

Use:

co_await propagate_exceptions_to(buf1, buf2, ...);

This is useful when errors should be routed to additional buffers (including read buffers in advanced flows).

Explicit sink lifetime guard

If an explicit sink is destroyed during stack unwinding before promise unhandled_exception() runs, runtime aborts intentionally (CHECK).

This fail-fast behavior prevents silent loss of exception routing in hard-to-debug cases.

Cancellation Semantics

Cancellation state is represented through promise flagging (set_cancel_on_resume()).

Effects:

  • cancelled tasks follow cancellation cleanup path at final suspend
  • continuations can be destroyed recursively when cancellation is active
  • downstream await paths may throw task_cancelled depending on awaiter mode

Cancellation and failure are deliberately distinct:

  • an absent value observed through or_cancel() cancels the current task
  • a genuine exception from an upstream epoch remains an exception, including when the consumer used or_cancel()
  • cancelling a task releases its unwritten output buffers, allowing absence to propagate through further cancellation-aware consumers
  • destroying an unobserved Async<T> result discards its value or stored failure once no buffers or aliases retain the state; destruction does not turn the result into cancellation

This last rule permits demand-driven graph pruning. In particular, an unseeded reverse-mode gradient branch may disappear without reporting an error, while a failure on a branch that is actually consumed remains observable.

Practical Guidelines

  • use proxy assignment (co_await writer = value) for first write to default Async<T>
  • treat or_cancel() as a control-flow choice, not just a convenience API
  • do not use cancellation to suppress a genuine upstream failure
  • register explicit sinks only when routing intent is clear
  • keep explicit sink objects alive until coroutine completion

Testing Focus Areas

Important tests in tests/async/test_async_basic.cpp cover:

  • handled vs unhandled exception propagation
  • automatic routing to write parameters
  • explicit routing with propagate_exceptions_to(...)
  • duplicate explicit registration no-op behavior
  • fail-fast path for destroyed explicit sinks during unwind

Related References