Async Reactive Context
An async context is a separate reactive surface for computations whose
values are produced by async/future-returning functions. It is not an
overload of the synchronous or thread-safe context; it is a distinct graph with
its own handles, because futures introduce in-flight state, cancellation, stale
completion, and dependency tracking across suspension points that the
synchronous graph does not have.
This chapter fixes the cross-language contract. An async context is compute, not protocol — only resolved slot values cross IPC/FFI as ordinary cell payloads, exactly like the synchronous graph.
Why a separate surface
A synchronous reactive context tracks dependencies through a thread-local stack
touched on every read, and a slot’s value is either present or unset. An async
computation can be in-flight (suspended at an .await) when its inputs
change, can complete after those inputs are gone, and can be canceled mid-flight.
Those states require:
- an explicit per-slot state machine (not just present/absent),
- revision tracking so a stale completion is discarded,
- dependency edges registered before the read is awaited, and
- a cancellation contract that is safe under waiter drop, supersession, and context disposal.
A binding gates this surface behind a separate feature flag so downstream users do not accidentally accept the larger semantic surface.
Handles
An async context exposes its own copyable, id-only handles, distinct from the synchronous handles:
| Handle | Wraps |
|---|---|
AsyncSource<T> | A mutable input cell (the synchronous input layer) |
AsyncComputed<T> | A computed/memoized async slot |
AsyncEffectHandle | An async effect |
Handles are id-only and copyable; they are usable only with the owning async context.
AsyncSource and AsyncComputed are the canonical public value kinds in every
binding. Published pre-v2 spellings such as AsyncCellHandle and
AsyncSlotHandle may remain only as deprecated aliases of those exact types.
Constructing through an old spelling must still return the canonical type.
API surface
| Method | Description |
|---|---|
source(value) | Create a mutable AsyncSource (value type equality-comparable, cloneable, Send + Sync) |
get(source) | Read an AsyncSource value synchronously |
set(source, value) | Update an AsyncSource and invalidate dependents |
computed / computed_async(compute) | Create an AsyncComputed; the exact spelling follows the binding’s async conventions |
computed_with_equals(compute, equals) | Create a guarded AsyncComputed with an explicit equality policy |
get(computed) -> Option<T> | Read an AsyncComputed cache synchronously; Some(T) if resolved, None otherwise (warm-path fast path) |
get_async(computed) -> T | Await an AsyncComputed; uses get() for resolved values, otherwise spawns async compute |
memo / memo_async(compute, equals) | Deprecated compatibility constructor forwarding to guarded computed; memo is not a third node kind |
effect_async(effect) | Create an async effect with an async cleanup |
dispose_async_effect(handle) | Dispose an async effect and await its cleanup |
batch(run) | Synchronous batch boundary; schedules async reruns at batch exit |
Sources are the synchronous input layer: source, get, and set are
synchronous. Only computed evaluation and effects are async. The constructor and
read/write vocabulary is deliberately the same as the local and thread-safe
contexts; only the async handle types and get_async are execution-model-specific.
The public state projection is AsyncComputedState. The formal theorem/module
name remains AsyncSlotState, because it describes the storage state machine,
not a public value-handle kind. Bindings with a published AsyncSlotState API
keep it as a deprecated alias of AsyncComputedState; formal references are not
renamed mechanically.
Async computed state machine (formal AsyncSlotState)
Each async slot tracks its state through a finite state machine:
first get_async / future Ok, dependency
Empty ──────────────────► Computing ──────► Resolved ──────► Computing
▲ (spawn) │ │ │
│ │ │ future Err │
│ │ ▼ │
│ │ Error ──────────────────────────┘
│ │ retry get_async
│ ▼
│ dependency invalidation revision mismatch on
└──── during in-flight compute ──► (stale) Computing ──► complete discards;
hard clear new future spawned
| State | Meaning |
|---|---|
Empty | No cached value, no in-flight computation. Entered on creation and after a hard clear. |
Computing | A handle tracks the in-flight future for the current revision. Concurrent get_async callers attach as waiters to the same in-flight result instead of spawning duplicate futures. |
Resolved | The cached value is fresh, until dependency invalidation transitions back to Computing. |
Error | The last computation failed. Every caller waiting on that attempt receives its error. The error is not a cached value: the next get_async MUST re-spawn (Error → Computing) rather than replay the stored error. |
Revision tracking is load-bearing: a computation records the slot revision at start; at publish time the graph accepts the value only if the revision is still current. This is what makes stale completion safe.
Transitions:
Empty → Computing— firstget_asyncor invalidation with no cached value.Computing → Resolved— future completesOkand the recorded revision still matches.Computing → Error— future completesErrand the recorded revision still matches.Computing → Computing (stale)— invalidation advances the slot revision during an in-flight computation. The completing future finds its revision no longer matches and discards the result; a new future is spawned for the updated revision.Resolved → Computing— invalidation marks the cached value stale and spawns a new computation.Error → Computing—get_asyncretry after an error. This transition is mandatory, not optional: a slot inErrorholds no cached result, so the next read re-spawns for the current revision. A binding that replays the stored error to every later reader is non-conforming — it makes a transient failure (a timed-out fetch, a disconnected peer) permanent for the lifetime of the slot, with no read path that can recover it.invalidateon anErrorslot is therefore a no-op rather than a repair: the retry is owned by the read, not by a dependency change. The executable form isLazilyFormal.AsyncSlotState.step … SlotEvent.retry, which mapserror → computingand is a no-op from every other state.
Cancellation contract
A conforming async context MUST honor all five of:
-
Waiter cancellation is safe. Dropping one
get_asyncfuture does not cancel the shared in-flight computation while other waiters still need it. Each waiter holds a shared handle; dropping a receiver does not abort the in-flight task. -
Stale completion is discarded, not published. When invalidation advances the slot revision during an in-flight computation, the completing future finds its recorded revision no longer matches and discards the result. Waiting callers are retried against the new revision or attached to the newly spawned future.
-
Explicit cancellation. A hard clear, invalidation, or context disposal may mark the in-flight revision as canceled; if an abort handle is available, the task is aborted. User futures MUST be cancellation-safe, because aborting drops them at an
.awaitboundary. -
Context disposal. Dropping the async context cancels all in-flight computations via their abort handles and awaits completion of all active cleanup futures before returning.
-
Effect cleanup is triggered by rerun or dispose, and completes before the next body. An effect’s cleanup future MUST run only when the effect reruns or is disposed — the trigger fixed by
reactive-graph.md§ Conformance (“cleanup runs before each rerun and on dispose”) — and when it runs on a rerun it MUST complete before the next body starts. Disposal removes pending reruns before awaiting cleanup.A binding MUST NOT run cleanup at the end of the flush that ran the body. This clause previously stated only the ordering (“cleanup completes before the next body starts”) and left the trigger to
reactive-graph.md, which turned out to be enough rope:lazily-py’s async effect awaited cleanup at flush end whenever no rerun was queued, and satisfied the ordering vacuously because there was no next body to be ordered against. Found 2026-07-19 bydisarm_disposes_nothing, which assertscleanup_order: []at a step where nothing was disposed or invalidated, and observed a cleanup.The reason the trigger matters rather than just the ordering: the canonical effect acquires a resource in the body and releases it in the cleanup. Running cleanup at flush end releases the resource while the effect is still live and will rerun later — an effect that subscribes and returns an unsubscribe would unsubscribe itself immediately.
lazily-goandlazily-dartboth retain the cleanup between runs and execute it at the start of the next one; that is the family behavior.
get_async re-resolve contract
get_async MUST treat the slot state as authoritative and re-resolve rather
than assert, because the slot can change between its lock acquisitions and a
notifier can close under it. It runs an outer loop that, each pass, re-reads the
slot via the get() fast path and then re-locks to attach to or spawn a
computation. Two concurrency windows are benign (not data inconsistencies — the
published value is always correct):
- Resolved-since-
get(): the slot can transitionComputing → Resolvedbetween the fast-pathget()(which releases the lock) and the re-lock. ObservingResolvedat the re-lock is expected; the cached value is read directly. It is not an unreachable state. - Notifier dropped: the per-computation waiters can all close without a
final
Resolvedsignal when an in-flight compute is superseded by a newer revision (the staleComputing → Computingtransition early-returns) or the slot is invalidated. A “the world changed” error means re-resolve from current slot state — return the now-published value, attach to the new in-flight compute, or respawn. It MUST NOT panic.
Dependency tracking
Async compute and effect callbacks do not use a thread-local tracking stack
(a thread-local does not survive executor thread migration or suspension/resume
across .await). Instead each callback receives a compute context:
| Method | Tracking |
|---|---|
get_async(computed) | Records the accessed computed as a dependency before awaiting its value |
get(source) | Records the accessed source as a dependency synchronously |
Edges register immediately, so source invalidation while the future is suspended can cancel or supersede the in-flight computation before it publishes stale data. On rerun, stale dependencies are removed and new ones registered; the dependency set is carried by the compute context, not a thread-local.
Async effects
An async effect runs an effect body returning an optional async cleanup:
- Serialized reruns: reruns are serialized per effect — a rerun does not start until the previous cleanup future completes.
- Cleanup trigger and ordering: cleanup runs on rerun or dispose and at no other time — not at the end of the flush that ran the body. When it runs on a rerun, the previous run’s cleanup completes before the next body starts; disposal awaits the current cleanup before removing the node. The cleanup is therefore retained between runs rather than executed eagerly. See § “Conformance” item 5 for why the trigger is normative and not merely the ordering.
- Auto-tracking: the body receives a compute context and tracks dependencies
through
get_async/get. - Scheduled, not inline: dependency invalidation schedules an async rerun
after the current invalidation pass; the rerun runs on the runtime executor,
not inline within
send/batch. - Disposal: removes pending scheduled reruns, awaits the current cleanup future, and unsubscribes dependency edges.
Batch support
batch(run) is a synchronous boundary. Cell updates queue invalidation
roots; at batch exit, queued roots trigger propagation. Async slots and effects
are scheduled for rerun but do not execute inside the batch callback — async
reruns execute after the batch returns, on the runtime executor. Mutation
semantics stay synchronous at the graph boundary: invalidations schedule async
reruns only after the outermost batch exits.
In-flight deduplication & fast path
- One in-flight computation per revision: each async slot has one published
cache and at most one in-flight computation for the current revision.
Concurrent
get_asynccallers await the same in-flight result instead of spawning duplicate futures. - Synchronous fast path:
get()returns the cached value synchronously when the slot isResolved, avoiding async overhead.get_async()callsget()first; only unresolved or dirty slots enter the async spawn path.
Async state machine
A flat State Machine over the async context keeps send and
state synchronous (sources are the synchronous input layer) while reactive
observers use the async APIs: on_transition returns an async effect handle and
state_is returns an async signal handle. Because resolution is asynchronous,
eager recomputation settles on the runtime rather than synchronously within
send.
Conformance
An async context conforms when:
- The slot state machine (
Empty/Computing/Resolved/Error) and its transitions, including the staleComputing → Computingdiscard, are implemented exactly. - Revision tracking discards every stale completion; a stale value is never published.
- All five cancellation properties hold, and disposal awaits cleanup.
get_asyncre-resolves through both benign-race windows without panicking.- Dependencies are tracked through the compute context (not a thread-local) and registered before the awaited read.
- Async effect reruns are serialized, cleanup-before-body ordered, and executor-scheduled rather than inline.
- Batching is synchronous at the mutation boundary; async reruns fire only after the outermost batch exits.
Implementation status
The async surface is required of any binding whose platform exposes an
async/future runtime (async/await, promises, coroutines, a suspending
executor) — see Wire Protocol § Concurrency layers are required.
A binding on a platform with no notion of suspendable async computation declares
the async capability as none and advertises it, never silently. A binding
that ships the async context MUST honor the full cancellation and re-resolve
contract above. API-shape checks for the canonical async-v2 names are
informational only: they do not admit a peer to transport, CRDT, queue, or sync
feature groups. Network-suite membership continues to follow the binding’s
advertised production capabilities, channels, codecs, and variants.
Concurrency-window coverage is pinned by targeted deterministic tests rather than
exhaustive interleaving exploration, because the async resolve loop runs on a
real async executor whose primitives a synchronization-model checker cannot shim.