TAIL OSv0.9.0

Periodic Framework — Rust API

Declare a period; the framework owns the timing loop, topic admission, freshness and the publish. A node is a function with an attribute — there is no main to write.

A periodic node is what you would otherwise create by hand as a thread running a timer loop — spawn a task, read the clock, do the work, sleep for the remainder, repeat. The framework replaces that loop, so you never write the thread, the sleep, or the drift correction the hand-written version needs.

For why the framework works this way, see the design document. This page is the API.

Quick start

Two programs: one that produces a reading on a topic, one that consumes it. Neither writes a main, a thread, or a sleep.

Add the crate as a direct dependency. It is standalone — not re-exported through tail_core — so adding it forces no toolchain or std rebuild.

[dependencies]
periodic = { path = "<relative path to>/framework/periodic/lib" }

A publisher

Reads a sensor every 20 ms and publishes the reading. This is the whole program:

#![feature(tail_core)]

use periodic::periodic;

#[derive(Clone, Copy)]
#[repr(C)]
struct Range { stamp: u64, range_mm: u32, quality: u32 }

// SAFETY: `repr(C)`, all-integer, every bit pattern valid.
unsafe impl std::tail_core::sync::shared_mutex::ShmSafe for Range {}

#[periodic(every = "20ms", publish = "sensor/range", lane = safe, on_overrun = Skip)]
fn lidar() -> Range {
    Range { stamp: 0x1234_5678, range_mm: 4200, quality: 97 }
}

A subscriber

Reads that topic every 20 ms and brakes on it. stale_after says how old a reading may be before it counts as absent, so the fail-safe branch is the one that runs when the sensor goes quiet:

#![feature(tail_core)]

use periodic::{periodic, Cycle};

// Must match the publisher's payload exactly.
#[derive(Clone, Copy)]
#[repr(C)]
struct Range { stamp: u64, range_mm: u32, quality: u32 }

// SAFETY: `repr(C)`, all-integer, every bit pattern valid.
unsafe impl std::tail_core::sync::shared_mutex::ShmSafe for Range {}

#[periodic(every = "20ms", subscribe = "sensor/range", lane = safe,
           stale_after = "60ms", on_overrun = Skip)]
fn brake(lidar: Cycle<Range>) {
    match lidar.get() {
        Some(reading) => brake_for(reading.range_mm),
        None          => brake_for(0),   // absent or stale
    }
}

Neither program names the other. The framework opens each topic and retries, so the two can start in any order.

#[periodic] keys

every — required

The release period. Releases fall on start + n * period, never drifting.

#[periodic(every = "20ms", ...)]

lane — required

Which transport carries the topic. All three move the same payload and converge on one API, so changing lane is a change to this line and nothing else.

kernel one copy Copied through a pool shared by all topics. One kernel call each way, no dedicated memory per topic. Any number of writers.
safe safe zero-copy No copy, and the writer cannot alter a frame a reader is inside. Cost scales with pages, not payload size. Exactly one writer, named when the topic is created.
fast fast zero-copy No copy and no kernel call at all. Cost scales with nothing. Exactly one writer.

Choose the one with the least machinery that meets the requirement:

more than one node publishes the topic?            ->  kernel   (the only lane that allows it)
payload under ~2 KB                                ->  kernel
large payload, a reader has a safety requirement   ->  safe
large payload, nothing downstream is safety-rated  ->  fast

Zero-copy is not "the fast one, always". Below that crossover safe is slower than the copy — two kernel calls and a mapping teardown against one call and a small memcpy — and it buys a safety argument that did not need to be made.

#[periodic(lane = safe, ...)]

Full detail under Lanes.

on_overrun — required

What to do when a cycle runs past its next release: Skip, CatchUp, or Fault. No default — omitting it does not compile.

#[periodic(on_overrun = Skip, ...)]

publish

Topic to publish the body's return value on.

#[periodic(every = "20ms", publish = "sensor/range", lane = safe, on_overrun = Skip)]
fn lidar() -> Range { read_sensor() }

subscribe

Topic to read from. The body receives a Cycle<T>.

#[periodic(every = "20ms", subscribe = "sensor/range", lane = safe,
           stale_after = "60ms", on_overrun = Skip)]
fn brake(lidar: Cycle<Range>) { ... }

stale_after — required with subscribe

How old a sample may be before it reads as absent.

#[periodic(subscribe = "sensor/range", stale_after = "60ms", ...)]

init

Builds the node's state once, before the first release. The body takes it &mut, first.

#[periodic(every = "20ms", publish = "diag/count", lane = safe, on_overrun = Skip,
           init = Cycles::default())]
fn counter(cycles: &mut Cycles) -> Count {
    cycles.0 += 1;
    Count { cycles: cycles.0 }
}

safety

Bare flag: this topic carries safety-relevant data. Binding it to the QM-only fast lane fails the build.

#[periodic(every = "10ms", publish = "brake/command", lane = safe,
           on_overrun = Fault, safety)]
fn brake_command() -> Command { ... }

Keys as constants

String-valued keys are expressions, so a test can assert on the same constant.

const PERIOD: &str = "20ms";
const TOPIC: &str = "sensor/range";

#[periodic(every = PERIOD, publish = TOPIC, lane = safe, on_overrun = Skip)]
fn lidar() -> Range { read_sensor() }

Node shapes

Publisher

#[periodic(every = "20ms", publish = "sensor/range", lane = safe, on_overrun = Skip)]
fn lidar() -> Range { read_sensor() }

Publisher that may skip a cycle

Return Option<T>. None publishes nothing and is not an overrun.

#[periodic(every = "20ms", publish = "sensor/range", lane = safe, on_overrun = Skip)]
fn lidar() -> Option<Range> { read_sensor_if_ready() }

Prefer T when the node always produces a sample: Option<T> costs a copy that T does not.

Subscriber

#[periodic(every = "20ms", subscribe = "sensor/range", lane = safe,
           stale_after = "60ms", on_overrun = Skip)]
fn brake(lidar: Cycle<Range>) {
    brake_for(lidar.get().map_or(0, |r| r.range_mm));
}

Pipe — reads and publishes

#[periodic(every = "20ms", subscribe = "sensor/range", publish = "estimate/distance",
           lane = safe, stale_after = "60ms", on_overrun = Skip)]
fn filter(raw: Cycle<Range>) -> Distance {
    Distance { mm: raw.get().map_or(0, |r| r.range_mm) }
}

With state

State comes first, then the Cycle if the node subscribes.

#[periodic(every = "5ms", subscribe = "input/pad", lane = kernel, stale_after = "300ms",
           on_overrun = Skip, init = Motor::attach())]
fn drive(motor: &mut Motor, pad: Cycle<Command>) {
    motor.set(pad.get().map_or(Command::Stop, |c| *c));
}

Cycle<T>

Everything about this activation, on one value. Passed to any node that subscribes.

get() -> Option<&T>

The sample, if one arrived and is still fresher than stale_after. Stale reads as absent, so the fail-safe branch is the default branch.

match lidar.get() {
    Some(reading) => brake_for(reading.range_mm),
    None          => brake_for(0),   // absent or stale
}

copied() -> Option<T>

The same sample, copied out, for a node that would rather own it.

let reading = lidar.copied().unwrap_or_default();

since_received() -> Option<Duration>

How long since a sample last arrived at this node. Not the sample's publication age — no transport timestamps a publication.

if let Some(gap) = lidar.since_received() {
    if gap > Duration::from_millis(100) { raise_sensor_warning(); }
}

was_stale() -> bool

Whether data was held but had aged past stale_after. Only for a node that needs the distinction get() collapses.

if lidar.was_stale() {
    log::warn!("using fail-safe: last reading too old");
}

missed() -> u32

Publications lost since the previous cycle. Not a running total.

if cycle.missed() > 0 {
    count_dropped(cycle.missed());
}

rejected() -> u32

Publications the transport refused since the previous cycle. Not a running total.

if cycle.rejected() > 0 {
    count_rejected(cycle.rejected());
}

overruns() -> u32

Releases missed since the previous cycle ran. Delivered once, then cleared.

if cycle.overruns() > 0 {
    log::warn!("missed {} release(s)", cycle.overruns());
}

is_healthy() -> bool

No loss, no rejection, no overrun. The one-call form of the three above.

if !cycle.is_healthy() {
    raise_degraded();
}

Cycle borrows its sample and is deliberately not Send: a topic must be read on the thread that subscribed, so moving it across threads is a compile error.

OverrunPolicy

Skip

Abandon the missed releases, resume on the grid. Preserves phase. The default choice for a control loop, where a late command computed from stale inputs is worse than none.

#[periodic(on_overrun = Skip, ...)]

CatchUp

Run the missed releases back to back until caught up. Preserves count. For an integrator or accumulator that must see every interval exactly once.

#[periodic(on_overrun = CatchUp, ...)]

Fault

Stop the node and report. For a node whose deadline is the safety requirement. Exits with NodeError::DeadlineMissed rather than success, so a supervisor can tell a missed deadline from a clean shutdown.

#[periodic(on_overrun = Fault, ...)]

Lanes

Cost and writer count are in lane. What decides a lane beyond those is the safety classification, which the compiler enforces.

Lane May carry data a safety requirement depends on
kernel Yes
safe Yes
fast No — Quality Managed only

fast is Quality Managed by its own design: its writer holds a standing read-write mapping of every slot for the region's lifetime, so it can alter a frame a reader is parsing. That is detection, not prevention.

Declaring safety on a fast topic therefore fails the build rather than a review:

#[periodic(every = "10ms", publish = "brake/command", lane = fast,
           on_overrun = Fault, safety)]
fn brake_command() -> Command { ... }
//  error: a safety-classified topic cannot be carried on a QM-only lane

Known gap. The lane is not compared between publisher and subscriber. Two processes that disagree fail as a subscriber that silently never receives anything.

Durations

An integer and a unit. No fractional form — write "1500us", not "1.5ms".

Unit Meaning Example
ns nanoseconds "250ns"
us microseconds "500us"
ms milliseconds "20ms"
s seconds "1s"

Payloads

A payload crosses shared memory: repr(C), Copy, and every bit pattern valid. The publisher's and subscriber's types must match exactly.

#[derive(Clone, Copy)]
#[repr(C)]
struct Range { stamp: u64, range_mm: u32, quality: u32 }

// SAFETY: `repr(C)`, all-integer, every bit pattern valid.
unsafe impl std::tail_core::sync::shared_mutex::ShmSafe for Range {}

NodeError

A node that cannot start says which topic and why, then exits non-zero. It does not run forever reading None.

Variant Cause
BadDuration(text) A duration literal could not be parsed
UnusablePeriod The period is zero, or rounds to zero ticks
NeverAppeared(topic) The topic did not appear within the admission attempts
Refused(topic, error) The transport refused the open; retrying will not fix it
DeadlineMissed(node) A cycle overran under on_overrun = Fault

Admission retries because the kernel refuses a name nobody publishes yet — so a misspelled topic fails loudly at startup rather than becoming a topic that never delivers.

Executor

Most nodes never touch this; the attribute drives it. It is public because the cadence is testable without a topic.

Executor::new(clock, period, policy)

Start releasing every period, from now. Fails with StartError::UnusablePeriod if the period is zero or rounds to zero ticks.

let mut executor = Executor::new(clock, Duration::from_millis(20), OverrunPolicy::Skip)?;

run(body)

Run until the policy stops the node. The closure receives this cycle's overrun count.

executor.run(|overruns| {
    if overruns > 0 { log::warn!("missed {overruns} release(s)"); }
    do_work();
});

step(body) -> bool

Run one cycle. Returns false once the policy has stopped the node — only Fault does.

while executor.step(|_overruns| do_work()) {}

release() / period()

The next release, and the declared period, in clock ticks.

let next = executor.release();

Clock

What the executor sleeps against: now(), ticks_per_second(), sleep_until(deadline). Releases are slept to, never "now + period" slept for — a relative sleep adds each cycle's scheduling delay to the period and never recovers it.

next_release(policy, release, period, now)

Pure function over ticks: how many releases were missed, and where the next one falls.

let (missed, next) = next_release(OverrunPolicy::Skip, release, period, now);

period_ticks(period, ticks_per_second)

A declared period in ticks, or None if it is zero or unrepresentable.

let ticks = period_ticks(Duration::from_millis(20), clock.ticks_per_second())?;
Generated from framework/periodic/doc/periodic_framework_public.md in the TAIL OS repository.