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, a busy boolean, and an in_flight method-name marker. Each interface method is a gated wrapper: check busy → throw E703 if set; otherwise set busy = true, set in_flight, await the machine’s method, clear both on the way out (via try/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’s async declaration: a user-sync op produces a sync delegate that delegates to a sync machine method; a user-async op 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:

  1. The composition machinery (@@<Other>() instantiation, @@import cross-file resolution) only ever references the casing’s user-declared name. Framec’s own machinery cannot accidentally bypass the gate.
  2. Users who name _<Name>Machine are 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 -> T with FrameE703Error — replaces the original fatalError so the caller can try? / catch.
  • D3. GDScript gate uses push_error + a typed-zero early return — replaces assert (which is stripped in Godot release builds), preserving the gate contract under --remap.
  • D4. C++ harness defaults to -std=c++23 for @@[target("cpp_23")] fixtures (local + Docker matrix lanes).
  • D5. Rust interface methods return Result<T, FrameE703Error> — replaces the original panic!, 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 (Rust pub(crate), Java/C# private, Kotlin/Swift private, 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 (see async_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; or ConcurrentExclusiveSchedulerPair for 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 synchronized on the casing instance.
  • 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.CancelledError propagates through try/finally cleanly — no NonCancellable equivalent needed. Use asyncio.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 self is a compile error. The runtime gate via _GateGuard is defense-in-depth for users who escape the borrow checker via Rc<RefCell<>> / Arc<Mutex<>>. Casing methods return Result<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 become CompletableFuture.failedFuture(e) (Fix #3 / D-JAVA-1). .get() throws ExecutionException with cause; .join() throws CompletionException with cause; .handle() / .exceptionally() deliver the cause directly (no wrap, because the failure originates at failedFuture() source).
  • C#. Casing methods return Task<T>. Exception filters (catch when) interoperate cleanly with the finally. No internal ConfigureAwait(false) — caller is responsible (ASP.NET sync-context capture is documented in Drawbacks).
  • Kotlin. Casing methods are suspend fun. CancellationException is structured concurrency’s normal mechanism — the finally clears the gate via plain assignment, so withContext(NonCancellable) is NOT required. runCatching { ... } around async { sys.call() } prevents the loser from cancelling the supervisor scope.
  • Swift. Casing methods are async throws -> T (D2). Uses defer { } for gate clear. Caller writes try await sys.method(). Strict-concurrency compile lane catches Sendable violations.
  • Dart. Casing methods are Future<T>. try/finally for gate clear. Zone values propagate cleanly across await boundaries.
  • GDScript. Casing methods are GDScript coroutines (the await keyword resumes on next idle frame). On E703: push_error(msg) + typed-zero return (D3) — survives Godot --remap (release builds), unlike the original assert. 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. Use panic = "unwind" (the default) if the gate must survive a recoverable panic.
  • C++ class order. <system>FrameContext must 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_error is 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. ✓

Cross-references

  • Glossary — system, casing/machine, async.
  • RFC-0015 — Factory construction (provides __create).
  • RFC-0017 — Init decouple (provides __frame_init).
  • RFC-0020 — Runtime kernel (the machine’s dispatch path).
  • RFC-0044 — Kernel context-stack must clean up on exception.
  • CHANGELOG.md