Skip to content

Gated reads and leases

Concepts

Feature data-deps makes a signal’s type carry what reading it implies, on both edges of a task’s life: bring-up (a read that waits for its producer) and teardown (a producer that waits for its holders).

Some couplings are not merely observed but depended on: an estimate means nothing until the node producing it is up. Writing deps: [ESKF ready] in every consumer states that once per consumer, by hand, in a place that has forgotten which signal motivated it. A gate moves the obligation onto the signal:

// The declaration says what reading it implies. Backed starts the producer
// on first open (needs `control`), then waits for its readiness.
pub static ESTIMATE: Backed<Watch<CriticalSectionRawMutex, Estimate, 4>> =
Backed::new(Watch::new());
// The consumer states a read. open hands back a counted Open guard; Deref
// gives the wrapped signal's own API back, and the guard is the reader's
// hold on the producer, so bind it for as long as the reading lasts.
let est = node.open(&crate::ESTIMATE).await;
let mut rx = est.receiver().unwrap();

Nothing names the producer. The graph already knows who writes a signal, so the gate resolves the producer by the static’s address, over the caller’s own graph, covering discover-derived tables no declaration site could name. Two graphs in one binary never answer for each other.

open is the only awaiting verb, it fires once per consumer at setup, and it grants no exclusive access. It hands back an Open<T> guard: the open is counted before it waits, and the count drops when the guard falls. That count is what makes the teardown verb below possible. Open::signal() returns the wrapped signal for APIs that demand a 'static reference; a reference kept past the guard’s drop is uncounted, like any reach past the gate that skips open. There is deliberately no blanket impl of the Gated trait: calling open on an ungated signal is a compile error rather than a no-op the reader would mistake for a guarantee. Write your own gate by implementing Gated: it names its handle (type Handle: Deref plus fn admit(&'static self); a wrapper that only waits sets Handle = &'static Self). ensure receives the reading node and the coupling entry, so it can log the caller, throttle a first access, wait on a mode, anything, including awaiting. One trap the graph cannot catch: coupling tables may legitimately be cyclic, so an open from a task the producer transitively deps:-on deadlocks silently. The producer’s bring-up waits on the opener (a ready dep, a resource it provides) while the opener waits on the producer, and nothing faults. Open such a signal only after satisfying whatever the producer’s bring-up gates on, for example after this task’s own set_ready().

The count has a producer-side verb. node.retire(&SIG, cooldown).await resolves once SIG.openers() has sat at zero for a whole cooldown; a reader arriving inside the cooldown restarts the clock. When it fires, the producer withdraws its readiness, so a late opener waits for the next activation instead of reading a producer on its way out (one admitted just before the withdrawal cancels the retirement), and with control it requests its own Deactivate. The next open starts it again, as before. Backed::unwatched(cooldown) is the same wait without the automatic stop, for a producer that cleans up by hand. Any other stop releases what the node holds too, and wakes openers parked on the stopped gate so they can request the next start without waiting out their retry.

Run it: the demand-responsive telemetry mechanism tour in the playground retires a modem, a sensor hub and a twin store in reverse order as their readers leave, and brings each back on the next open.

The edge that bites: a task that published a handle into a static (a network stack, a DMA buffer, a peripheral view) cannot free it while a consumer is still holding it. No declaration answers that, because coupling tables are best-effort by construction: an unadopted helper, a computed target or a forgotten entry is simply absent, and ordering a lifetime invariant on “not mentioned” is not sound. So the holders are counted instead:

pub static NET_STACK: Leased<StackCell> = Leased::new(StackCell::new());
// The consumer holds the guard exactly as long as it uses the value.
let Some(stack) = node.lease(&crate::NET_STACK) else { return };
serve(*stack).await;
// The producer, on its way down, before freeing the backing:
crate::NET_STACK.drain().await;

drain closes the signal to new leases, then waits for the live ones to drop. Closing is what makes the count trustworthy: afterwards an asker gets None, the honest answer, rather than a handle about to dangle. A producer that comes back up calls reopen.

sequenceDiagram
accDescr: A producer waits for every lease holder to drop before freeing the backing
autonumber
participant P as Producer
participant L as Leased signal
participant C as Consumer
P->>L: provide value
C->>L: node.lease() → guard
P->>L: drain() (closing)
L-->>P: waits live holders
C-->>L: guard drops
L-->>P: zero holders
P->>P: free the backing

What this buys over a deps: edge is exactness: the count covers every holder, including a consumer no table carries and a detached node, which teardown skips entirely and which therefore escapes any ordering-based scheme. The failure mode is a leaked guard: drain never returns, the producer misses its shutdown ack, and the shutdown ack timeout names the producer. A use-after-free becomes a loud timeout.

Cost: one AtomicU32 and one signal per leased signal, one AtomicU32 of reader count per Backed gate (the Open guard is a wrapper around the signal reference, nothing more), and nothing for signals that opt out. lease is sync; open is async; both record the coupling, and the diagram tool draws them as their own edge kinds.

Ordering that lives entirely in the couplings

Section titled “Ordering that lives entirely in the couplings”

deps: is optional. A graph whose ordering lives entirely in its runtime couplings is a first-class shape: gated reads bring producers up on first use, lease drains order teardown, channel rendezvous sequence the middle. The zero-sized Flat topology means such a graph pays no dependency bookkeeping at all. Reach for it when the dependency structure is genuinely dynamic, for example a lazily-started radio that comes up when the first consumer reads from it, not at boot.