RFC-0036 — No-allocation dispatch for no_std, interrupt, and hot-path use
- Status: Draft
- Author: Mark Truluck
- Created: 2026-05-22
- Builds on: RFC-0020 (runtime reference architecture), RFC-0021 (parked the event pool as item 1), RFC-0025 (typed compartment payload)
Summary
Define an opt-in dispatch mode in which a compiled Frame system processes an event with zero heap allocations. Today every dispatch allocates — an event object plus context/compartment bookkeeping — which is a minor cost in an ordinary application but a disqualifier for three classes of use: dispatching from an interrupt handler, running on a fixed/bounded heap, and targets with no allocator at all. This RFC lifts RFC-0021’s parked “event pool” item into a prioritized, user-visible capability, extends it to the context and compartment storage, and gates it behind an attribute so allocator-backed targets are unchanged.
Motivation
A Frame system’s dispatch currently allocates on every event. With the typed
compartment payload from RFC-0025 the per-state context is a fixed
struct rather than a stringified map, but the event itself is still a heap-owned
object (an Rc<FrameEvent> in the Rust target) and the context stack the kernel
pushes and pops is a growable collection. A direct count of allocator calls
across a representative run puts the current Rust output at roughly six heap
allocations per event dispatch.
In an ordinary hosted application that is a footnote — RFC-0021 collected it as a performance optimization and deliberately did not prioritize it, pending a profiling signal. The signal has now arrived, and it is not about speed. For three classes of use, per-event allocation is disqualifying, not merely slow:
-
Interrupt context. A global allocator is protected by a lock. If an interrupt handler allocates while the code it preempted holds that lock, the handler spins forever — a self-deadlock. So a Frame system cannot be dispatched directly from an interrupt handler. The only workaround is to never dispatch from the handler: post the event’s data into a queue and dispatch it later from ordinary context. That “post now, drain later” indirection then has to wrap every device, timer, and cross-core event a Frame system reacts to.
-
Bounded memory. A kernel or embedded target runs on a fixed heap. Unbounded per-event allocation on a hot path — an allocation per network segment, per disk block, per input report — is untenable on a fixed budget even when each allocation is promptly freed.
-
No allocator at all. Some
no_stdtargets ship without a global allocator. Per-event allocation precludes using Frame on them entirely.
In each case the state machine itself is exactly the right tool — these are lifecycle- and protocol-shaped problems, which is what Frame is best at — yet the runtime’s allocation behavior keeps it off the interrupt path and the data path, which is where much systems code lives. The state machines stay on the control plane only because the runtime, not the design, forces them there.
The contract
The key words MUST, MUST NOT, SHOULD, MAY are to be interpreted as in RFC 2119.
Introduce an opt-in attribute — spelled @@[no_alloc] here, final name to be
decided — on a system. Under it, the generated dispatch for that system MUST
NOT perform any heap allocation. The three current allocation sites are removed:
- The event. Instead of a heap-owned event object, the event is passed by
value — a
Copytag plus its parameters. This is RFC-0021 item 1’s “event pool” in its simplest form: because dispatch is single-threaded and stack-disciplined, a single in-selfslot (or a by-value parameter) suffices, with no pool sizing or generation-count machinery. In the Rust target this also removes theRc<FrameEvent>that RFC-0020 introduced — the handler signature loses theRc. - The context. RFC-0025’s typed per-state context struct is stored inline in the system (a fixed-size field, or a fixed enum/union over the states’ context types), not behind a heap handle.
- The compartment / context stack. The push/pop stack the kernel maintains for nested dispatch is a fixed-capacity array sized to the system’s maximum nesting depth — which is known at compile time from the declared state hierarchy — rather than a growable collection.
Because zero-allocation requires fixed-size storage, the mode imposes a systems
profile on the system it is applied to. Under @@[no_alloc]:
- Event and state-context parameters MUST be
Copy/ fixed-size. AStringorVecpayload is rejected at compile time; a bounded inline buffer MAY be offered as the alternative. - The maximum nesting depth MUST be statically determinable (it is, from the declared hierarchy); the context stack is sized to it.
- If the system uses the deferred-event / forwarding queue, that queue MUST be fixed-capacity, with a defined behavior on overflow.
A system that violates the profile is a compile error under @@[no_alloc],
not a silent fallback to allocation — the entire value of the mode is the
guarantee, so a violation must be loud.
Success criterion. Under @@[no_alloc], the allocation count per dispatch
MUST be 0 (down from the ~6 measured today). This is directly checkable by
counting allocator calls across a dispatch.
Systems without the attribute are unchanged: allocator-backed backends keep the RFC-0020 shape, and dynamic-language backends — where short-lived small objects are the idiomatic allocation and a pool buys nothing — need not implement the mode at all.
Examples
A small control-plane machine of the kind a systems target dispatches from an interrupt or runs on a fixed heap:
@@[no_alloc]
@@system PortReset {
interface:
connect()
reset_done()
timeout()
machine:
$Idle { connect() { -> $Resetting } }
$Resetting {
reset_done() { -> $Enabled }
timeout() { -> $Idle }
}
$Enabled { }
}
Under @@[no_alloc], dispatching connect() allocates nothing: the event is a
Copy value, the (empty) context is inline, and the context stack is a
fixed-depth array. The same .frs without the attribute compiles to the
RFC-0020 allocating shape — the attribute is the only difference.
A system that wouldn’t qualify — a String event payload — is rejected rather
than silently allocated:
@@[no_alloc]
@@system Logger {
interface:
log(line: String) // compile error under @@[no_alloc]: non-Copy payload
machine:
$Open { log(line: String) { } }
}
Alternatives
- Status quo: keep per-event allocation, rely on “post then drain.” Works, and is the current practice for interrupt-driven systems code. But it forces the post/drain indirection onto every interrupt path, bars Frame from data paths entirely, and does nothing for allocator-less targets. It treats a runtime limitation as something every user must architect around.
- A universal event pool (RFC-0021 item 1, applied to every backend). Adds pool machinery to dynamic-language backends whose allocators already handle short-lived small objects well, for no benefit there. Opt-in confines the machinery to the targets that need it.
- Caller-supplied storage / a per-dispatch arena. Pushes the burden onto the user at every call site and leaks runtime mechanics into user code; the attribute keeps the guarantee inside the generated system.
- Make the no-alloc shape always-on (no attribute). Changes every backend’s idiomatic output and imposes the systems profile (Copy-only payloads, bounded depth) on systems that have no reason to accept those restrictions. Opt-in preserves the expressive default and offers the constrained profile only where it’s wanted.
Migration
Source-additive: a new opt-in attribute. Systems that don’t use it are unchanged,
and existing code keeps compiling exactly as before. A system that adds
@@[no_alloc] must satisfy the systems profile (Copy events, statically-bounded
nesting); the compiler reports a violation rather than silently allocating.
References
- RFC-0020 — runtime reference architecture; the dispatch shape this constrains.
- RFC-0021 — runtime performance optimizations; parked the event pool as item 1, which this RFC lifts and prioritizes.
- RFC-0025 — typed compartment payload; the typed per-state context this stores inline.
- Frame runtime specification
- Glossary
CHANGELOG.md