RFC-0043: @@[async] — single-driver gate via layered casing/machine
- Status: Accepted — implemented in framec 4.4.0 (this release)
- Author: Mark Truluck mark.truluck@cogiton.com
- Created: 2026-05-31
- Builds on: RFC-0015 (factory construction), RFC-0017 (init decouple), RFC-0020 (runtime kernel)
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
Summary
A system that declares any async member (async on an interface method, action,
or operation) MUST carry the @@[async] attribute on its header. Framec
emits async systems as a two-class layered structure: a public casing
with the user-declared name (e.g. Counter) that guards external entry against
concurrent dispatch, and a private machine (_<Name>Machine) holding the
existing async dispatch core. The gate is a single boolean — set on first
external entry, cleared on return — that raises E703 if the system is
re-entered externally while a dispatch is in flight.
This is a hard cut from the previous release. There is no warning grace period:
async members without @@[async] raise the validator error E720, and a
non-async system that composes an async system as a domain field raises E721.
Motivation
Frame’s async semantics, prior to this RFC, were a single-class architecture: the system class was both the public boundary and the internal dispatch core. External callers entered the same methods the kernel used internally during event dispatch — there was no architectural distinction between “outside” and “inside.” This was deliberately simple, and it worked for the single-threaded single-driver case the runtime kernel is built around.
It broke down in two ways. Concurrent external calls were silently undefined.
A second external await foo.fetch(...) while the first one was still suspended
re-entered the kernel through the same surface; the second call mutated state
the first call assumed stable, and the bug surfaced as a transition into the
wrong state — or a corrupted compartment stack — far from the cause. Internal
self-calls and external calls were indistinguishable. A handler’s
@@:self.method() (RFC-0006) and an external caller’s await sys.method()
both arrived at the same boundary. A gate that rejected the second case would
also reject the first — internal self-calls would deadlock against themselves.
Either constraint admits one architectural fix: separate the external surface from the internal dispatch core, so each can have different semantics. The external surface is the casing; the internal dispatch core is the machine. The casing’s wrappers enforce single-driver entry; the machine’s methods don’t — self-calls and kernel-internal dispatch never touch the gate.
Specification
Triggering attribute
@@[async]
@@system Counter {
interface:
async fetch(key: String): String
machine:
$S { fetch(key: String): String { @@:(key) } }
}
@@[async] appears on a line immediately preceding @@system. It opts the
system into the layered codegen architecture. The attribute takes no arguments.
The attribute is required for any system whose interface, actions, or
operations declare an async member. A system that declares no async members
MAY still carry @@[async] to opt into the gate (a sync-dispatch system
that still wants single-driver entry).
Layered emission shape
For an async system <Name> framec emits two classes:
-
<Name>(casing) — the user-facing class. Owns three internal fields: the embedded machine, abusyboolean, and anin_flightmethod-name marker. Each interface method is a gated wrapper: checkbusy→ throwE703if set; otherwise setbusy = true, setin_flight, await the machine’s method, clear both on the way out (viatry/finally,defer,try/catch+rethrow, or an equivalent per-language idiom — whichever ensures cleanup runs on both happy and error paths). Operations and persist save/load pass through to the machine without the gate — they’re explicitly non-dispatching. Operations honor the user’sasyncdeclaration: a user-sync op produces a sync delegate that delegates to a sync machine method; a user-asyncop produces a coroutine delegate that awaits the machine’s coroutine. The domain block stays private to the machine — external callers reach state through interface methods, never by reading the casing’s fields directly. -
_<Name>Machine(machine) — the existing async dispatch core, byte-for- byte the same as the previous-release single-class emission, minus the public name. Holds__kernel,__router, state methods, transition loop, lifecycle cascades. Self-calls and kernel-internal dispatch operate against this class directly; they never touch the gate.
The machine is internal (private to the file / module / namespace, depending
on the target language’s privacy unit). User code MUST NOT name
_<Name>Machine directly — its surface is unstable and may change shape
between releases without notice.
E703 — concurrent external dispatch (runtime)
When a casing wrapper finds busy == true on entry, it throws the target
language’s idiomatic unrecoverable-error type with a message of the form:
E703: system busy: cannot enter '<method>' while '<in-flight method>' is in flight
| Target | Raise mechanism |
|---|---|
| Python | RuntimeError |
| TypeScript / JavaScript | Error |
| Rust | panic! (RAII guard drops on unwind) |
| Java | RuntimeException |
| C# | InvalidOperationException |
| Kotlin | IllegalStateException |
| Swift | fatalError |
| Dart | StateError |
| GDScript | assert(false, ...) |
| C++ | std::runtime_error |
E703 is a programming error, not a recoverable condition. The contract is
single-driver: at most one external dispatch in flight at a time.
E720 — async members without @@[async] (validator, hard cut)
A system with an async interface method, action, or operation that lacks the
@@[async] header attribute raises:
E720: @@system '<Name>' declares async member(s) but lacks the `@@[async]`
system-header attribute (RFC-0043).
Add `@@[async]` on a line immediately before `@@system`, or run
`framec project add-async-attr <path>` (or `migrate_async_attr`
from framec-wasm) to insert it mechanically across a tree.
RFC-0043 is a hard cut — there is no warning grace period.
E721 — sync system composes async system (validator, same-file)
A non-@@[async] system whose domain field’s type names an @@[async] system
declared in the same compilation unit raises:
E721: sync @@system '<Holder>' cannot compose async @@system '<Held>' as
domain field '<f>' (RFC-0043). An async system's wrappers return
Future/Promise/Task; a sync holder cannot await them without itself
becoming async. Add `@@[async]` to the '<Holder>' header, or break
the field out so '<Held>' is held by an async parent.
Detection tokenizes the field’s user-written type text on non-identifier
characters and checks each token against the set of known async system names.
This catches direct composition (f: Fetcher), nullable (f: Fetcher?), and
container-wrapped (Vec<Fetcher>, Option<Fetcher>, List<Fetcher>) cases.
E721 is same-file only. Frame doesn’t have full cross-file type
resolution today — an async system arriving via @@import (RFC-0040) from
another file is invisible to E721. When cross-file resolution lands, E721
extends naturally.
Internal-only call detection (escape hatch)
A sufficiently determined user can call _<Name>Machine directly, bypassing
the gate. This is deliberately not prevented:
- The composition machinery (
@@<Other>()instantiation,@@importcross-file resolution) only ever references the casing’s user-declared name. Framec’s own machinery cannot accidentally bypass the gate. - Users who name
_<Name>Machineare deliberately reaching past the architecture. The_prefix is the convention for “internal — do not use directly”; that convention is the contract.
Migration
The codemod adds @@[async] to every system that declares an async member.
Available as a CLI subcommand and as a WASM export for the playground:
framec project add-async-attr path/to/source-tree
import { migrate_async_attr } from "@frame-lang/framec-wasm";
const migrated = migrate_async_attr(originalSource);
The codemod is purely textual — it inserts a single @@[async] line
above each @@system header whose body declares an async member. It does
not modify behavior; it just adds the now-required attribute.
Per-backend status
All 11 async-capable backends are layered. Post-implementation,
each backend’s E703 surface is recoverable (caller can catch /
?-chain / try?) — see D2/D3/D5 below.
| Backend | Phase | E703 surface | Gate idiom |
|---|---|---|---|
| Python | 4 | RuntimeError("E703: …") |
try/finally |
| Rust | 5 | Err(FrameE703Error) (D5) |
_GateGuard RAII |
| TypeScript | 6.1 | Error("E703: …") |
try/finally |
| JavaScript | 6.2 | Error("E703: …") |
try/finally |
| Java | 6.3 | CompletableFuture.failedFuture(IllegalStateException) |
try/finally |
| C# | 6.4 | InvalidOperationException("E703: …") |
try/finally |
| Kotlin | 6.5 | IllegalStateException("E703: …") |
try/finally |
| Swift | 6.6 | throws FrameE703Error (D2) |
defer |
| Dart | 6.7 | StateError("E703: …") |
try/finally |
| GDScript | 6.8 | push_error(...) + typed-zero return (D3) |
manual clear |
| C++ | 6.9 | std::runtime_error("E703: …") |
try/catch(...) + rethrow |
Backends without native async (Go uses goroutines+channels; C, PHP, Ruby, Lua, Erlang, Graphviz) are not layered — they don’t have an async dispatch path that requires gating.
Decisions ratified during implementation
- D1. Gate threading: single-driver contract, documented (not enforced by atomics). See Drawbacks below.
- D2. Swift gate is
async throws -> TwithFrameE703Error— replaces the originalfatalErrorso the caller cantry?/catch. - D3. GDScript gate uses
push_error+ a typed-zero early return — replacesassert(which is stripped in Godot release builds), preserving the gate contract under--remap. - D4. C++ harness defaults to
-std=c++23for@@[target("cpp_23")]fixtures (local + Docker matrix lanes). - D5. Rust interface methods return
Result<T, FrameE703Error>— replaces the originalpanic!, aligning Rust with every other layered backend.
Drawbacks
- One more layer of indirection. Every interface call now hops through a wrapper. The cost is one boolean check and (on most backends) a try/finally setup — measured at <1% on the matrix’s microbenchmarks.
- Two classes in the emitted file. The machine is visible in the output
even though users shouldn’t reference it. Mitigated by the
_prefix and target-language privacy modifiers (Rustpub(crate), Java/C#private, Kotlin/Swiftprivate, etc.) where the language supports them. - Hard cut, no grace period. Existing code without
@@[async]won’t compile after upgrade. The codemod handles every case the validator catches. -
D1 — single-driver contract; multi-thread concurrency is out of scope. The gate is a plain
bool(or equivalent) on every backend — there’s no atomic CAS or memory barrier. Concurrent entry from multiple OS threads / dispatchers / executors is undefined behavior; the caller is responsible for serialization (Mutex, single-threaded executor, Dispatcher binding, etc.). The gate exists to catch the much more common bug of accidental re-entry from a single driver — which it does via the cooperative-concurrency tests (Promise.all, Task.WhenAll, async let, tokio::select!, gather, withTaskGroup, Future.wait). Per-language mitigation patterns:- Kotlin — wrap calls in
kotlinx.coroutines.sync.Mutex.withLock(seeasync_kt_mutex_serializes_drivers). - Rust — borrow checker enforces single-driver at compile time;
Arc<Mutex<S>>opt-in for shared ownership. - C# —
SemaphoreSlim(1, 1)for thread-pool dispatchers; orConcurrentExclusiveSchedulerPairfor cooperative scheduling. - Swift — wrap the system in an actor; the actor’s serialization is sufficient.
- Python / JS / TS / Dart — single-threaded event loops; the
concern doesn’t arise without
multiprocessing/web workers/Isolates. - Java — single-thread executor or
synchronizedon the casing instance.
- Kotlin — wrap calls in
- Rust
panic = "abort"skips_GateGuard::drop. Stack unwinding is required for the RAII gate clear; aborting profiles bypass it. Document as a known limitation.
Rationale and alternatives
Why not a reentrancy counter instead of a boolean? Considered. A counter would let external callers stack invocations safely as long as they unwound in LIFO order — but Frame’s kernel is single-threaded, the second external call is the bug, and rejecting it loudly is the contract we want. A boolean expresses the contract more clearly than a counter that could be ≤ 1.
Why not differentiate via call-site instrumentation (caller passes a token)? Considered. Would require every caller — across every language — to carry ambient context. The gate-via-flag approach localizes the contract to the casing wrappers and changes nothing at the call site.
Why not lift the architecture to all systems, not just async? Sync systems don’t have the re-entrancy hazard the gate exists to prevent. The kernel dispatches synchronously to completion; a second external call physically cannot interleave. Adding the gate to sync systems would be pure overhead.
Why a hard cut instead of W720 warning then upgrade? Two reasons: (1) The fix is mechanical — the codemod handles it, and the migration cost is one line per system. (2) A grace period means two emission shapes in the wild simultaneously, which complicates every downstream tool that consumes generated source. Hard cuts are how RFC-0015 and RFC-0042 shipped.
Forward references
- Multi-driver semantics. A future RFC may add
@@[async(multi)]or similar to opt into a counter-based or queue-based contract for systems that genuinely need concurrent external entry. The shape we ship leaves room: the casing is the natural place for any alternative contract. - Cross-file E721. RFC-0040 introduced analysis-only
@@import. When RFC-0040’s cross-file type resolution lands, E721 extends to cross-file composition automatically.
Testing — fixture suite
The contract is pinned by a layered fixture suite in framec-test-env:
Cross-backend common core (6 patterns × 11 backends = 66 fixtures)
Each pattern is authored once for the architecture, then ported to all 11 async-capable backends using each language’s idioms. Same Frame contract; language-native test driver per fixture.
| # | Stem | What it pins |
|---|---|---|
| C1 | async_exception_clears_gate |
Handler throws during await → gate clears in finally/defer/catch |
| C2 | async_concurrent_entry_e703 |
Cooperative concurrent entry — one wins, others see E703 |
| C3 | async_distinct_instances_parallel |
Two instances run concurrently — independent gates |
| C4 | async_sync_op_bypass_gate |
Sync operations called during in-flight async dispatch succeed |
| C5 | async_persist_roundtrip_gate_clears |
Save → restore → next async call works (gate not stuck after restore) |
| C6 | async_composition_parent_child |
Parent (async) holds child (async); both gates independent |
Locations: tests/<lang>/positive/async_<stem>.f<ext> (per-backend dirs for
languages with dedicated dirs); tests/common/positive/primary/async_<stem>.f<ext>
for the others. All 66 fixtures green on the matrix.
Per-language P1 unique fixtures (48 fixtures)
Each backend has additional fixtures exercising its language-specific async risk surface. Coverage matrix (this arc):
| Backend | P1 unique | Highlights |
|---|---|---|
| Kotlin | 7 | Cancellation cluster: withTimeout, Job.cancel, supervisorScope, Mutex multi-driver, Dispatchers.IO swap, E703 message format, NonCancellable finally invariant, companion-factory regression |
| TypeScript | 6 | Promise.race abandons loser; Promise.allSettled aggregation; for await throw; unhandled rejection isolation; dynamic import() in handler; Promise<void> ordering |
| Java | 5 (+ updated C1) | thenCompose chain; CompletableFuture.allOf; CompletionException unwrap (.handle/.exceptionally/.get/.join); CompletableFuture<Void>; null return without NPE |
| JavaScript | 5 | Unbound this foot-gun; three-deep composition; Promise.all concurrent entry; setImmediate/setTimeout in handler; Promise.allSettled |
| Python | 5 | asyncio.wait_for timeout; asyncio.gather(return_exceptions=True); asyncio.shield; create_task lifecycle (run + cancel); async for in handler |
| Swift | 4 | Task.cancel clears gate; withTaskGroup concurrent entry; async let parallel; Task.detached |
| Dart | 4 | Future.wait(eagerError: false); Future.then chain; Completer bridge; Zone value propagation across await |
| GDScript | 3 | D3 typed-zero return for each declared type; no Node/SceneTree subclass pollution; optional typing parity |
| C++ | 3 | Nested throw through co_await; std::string return lifetime via std::any_cast; throw after partial await |
| Rust | 3 | tokio::time::timeout drops the future; tokio::select! cancellation; D5 Result<T, FrameE703Error> recovery contract |
| C# | 3 | Task.WhenAll aggregation; Task.WhenAny winner; exception filter (catch (E) when (...)) |
Companion suite — RFC-0044 leak regression (12 fixtures)
A separate arc fixed and pinned the kernel context-stack leak (D-PY-1)
across 12 backends. See RFC-0044 for details. Fixtures live
alongside the RFC-0043 corpus and verify the invariant
len(_context_stack_after_throw) == len(_context_stack_before_call) on
every backend that has a catchable exception path.
Verification
For every fixture: cargo test --release clean; cargo clippy --release
-- -D warnings clean; cargo fmt --check clean; the fixture’s docker
matrix lane passes (make test-<lang>); the full matrix aggregates green.
Per-language guides
Short notes per backend, intended as a quick reference for users adopting
@@[async]:
- Python. Single-threaded event loop.
asyncio.CancelledErrorpropagates throughtry/finallycleanly — noNonCancellableequivalent needed. Useasyncio.wait_for/shield/gather(return_exceptions=True)as documented in the fixture suite. - TypeScript / JavaScript. Single-threaded event loop. The finally clear
order is
in_flight = null; busy = false(Fix #4 / D-JS-3). Microtask observers see the gate fully cleared before they fire. - Rust. Borrow checker enforces single-driver at compile time —
concurrent dispatch on the same
&mut selfis a compile error. The runtime gate via_GateGuardis defense-in-depth for users who escape the borrow checker viaRc<RefCell<>>/Arc<Mutex<>>. Casing methods returnResult<T, FrameE703Error>(D5);?-chain or match.panic = "abort"skips the guard’s drop. - Java. Machine is sync; casing wraps results in
CompletableFuture<T>. Handler throws becomeCompletableFuture.failedFuture(e)(Fix #3 / D-JAVA-1)..get()throws ExecutionException withcause;.join()throws CompletionException withcause;.handle()/.exceptionally()deliver the cause directly (no wrap, because the failure originates atfailedFuture()source). - C#. Casing methods return
Task<T>. Exception filters (catch when) interoperate cleanly with the finally. No internalConfigureAwait(false)— caller is responsible (ASP.NET sync-context capture is documented in Drawbacks). - Kotlin. Casing methods are
suspend fun.CancellationExceptionis structured concurrency’s normal mechanism — the finally clears the gate via plain assignment, sowithContext(NonCancellable)is NOT required.runCatching { ... }aroundasync { sys.call() }prevents the loser from cancelling the supervisor scope. - Swift. Casing methods are
async throws -> T(D2). Usesdefer { }for gate clear. Caller writestry await sys.method(). Strict-concurrency compile lane catchesSendableviolations. - Dart. Casing methods are
Future<T>.try/finallyfor gate clear. Zone values propagate cleanly across await boundaries. - GDScript. Casing methods are GDScript coroutines (the
awaitkeyword resumes on next idle frame). On E703:push_error(msg)+ typed-zero return (D3) — survives Godot--remap(release builds), unlike the originalassert. Generated system is RefCounted, NOT a Node subclass — no lifecycle hooks to break. - C++. Casing methods return
FrameTask<T>(a coroutine return type).try { co_await } catch (...) { pop_back; throw; }is the gate-cleanup idiom. Local harness uses-std=c++23(D4); Docker matrix lane uses the same.
Known limitations
- Rust
panic = "abort"skips_GateGuard::drop(). The gate stays set on panic; the process is exiting anyway. Usepanic = "unwind"(the default) if the gate must survive a recoverable panic. - C++ class order.
<system>FrameContextmust precede<system>in the generated source. Single-system files have this naturally; multi-system files emit in dependency order. - GDScript hard error during await leaves manual-cleanup window.
A user-level
assert()inside a handler crashes the script; the casing cannot trap it. (push_erroris logging, not an exception — it doesn’t bypass the cleanup.) - Cross-file
@@import+ async. RFC-0040 introduced analysis-only@@import. E721 is same-file only until RFC-0040 grows cross-file type resolution. - Async actions vs async ops vs async interface methods. All supported by emission; no fixture explicitly stresses the matrix product. Open for a future micro-RFC.
Acceptance / done criteria
@@[async]parses and validates (E720 hard cut). ✓- E721 fires for same-file sync-composes-async. ✓
- Layered emission across all 11 async-capable backends. ✓
- Codemod available as CLI + WASM export. ✓
- All five gates green per phase: cargo build, cargo test, cargo clippy, cargo fmt, validate_doc_samples. ✓
- D2/D3/D4/D5 ratified and implemented. ✓
- Fixture suite: 66 cross-backend + 48 per-language unique = 114 fixtures green on the matrix. ✓