TAIL OSv0.9.0

Periodic Framework — Python API

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

This is the Python half of the Rust API. The keys mean the same things and are spelled the same way, so a reader of that page already knows this one. Where the two differ, this page says why.

Quick start

Two programs, one of each shape: a node that only publishes and a node that only subscribes. Neither writes a loop or a sleep.

A Python publisher

from periodic import periodic, run, Skip, Payload, Topic

Reading = Payload("stamp:u64 counter:u32", "Reading")

count = 0


@periodic(every="20ms", publish=Topic("pair/from_python", Reading),
          lane="kernel", on_overrun=Skip)
def sensor():
    global count
    count += 1
    return Reading.instance(stamp=0x1234_5678_0000_0000 | count, counter=count)


run()

A node that returns a value publishes it. count lives at module level, which is how a Python node keeps state between cycles; below is why Python needs no init for that.

A Python subscriber

from periodic import periodic, run, Skip, Payload, Topic

Range = Payload("stamp:u64 range_mm:u32 quality:u32", "Range")

@periodic(every="20ms", subscribe=Topic("sensor/range", Range),
          lane="safe", stale_after="200ms", on_overrun=Skip)
def brake(lidar):
    reading = lidar.get()
    brake_for(reading.range_mm if reading else 0)

run()

Each node retries admission until its topic appears, so it may start before or after the node at the other end.

These are the tested examples. framework/periodic/python/examples/publisher.py and subscriber.py are compiled into test/integration_test/tests/periodic_python_pair with include_str! and run on target, each against a Rust peer. The publisher above is that file without its docstring. The subscriber file wraps the same declaration in a few lines that let the test read a verdict from its exit status, so only its body differs.

get() returns None both when nothing has arrived and when what is held is older than stale_after. The two collapse on purpose, so the fail-safe branch is the branch that runs when the sensor goes quiet.

Keys

The same set as the Rust attribute, with two differences noted below.

Key
every required Release period: "20ms". Releases fall on start + n * period, never drifting
lane required "kernel", "safe" or "fast" — which transport carries the topic
on_overrun required Skip, CatchUp or Fault. No default
publish Topic(name, payload) to publish the body's return value on
subscribe Topic(name, payload) to read from. The body receives a Cycle
stale_after required with subscribe How old a sample may be before it reads as absent

Every one of these is checked when the function is decorated — which is import time, before a topic is opened and before a release is taken. Rust refuses them at compile time; import is the earliest equivalent Python has.

Two differences from Rust, both deliberate

A topic names its own payload. Rust infers it from the body's signature; Python has nothing to infer it from, and a transport cannot carry a shape nobody named. So a topic is Topic("sensor/range", Range) rather than a bare string — which is also where the payload actually belongs, since a topic has one layout and a node may touch two.

There is no safety key. Asking for one is an error, and this is a design position rather than an omission — see Safety.

A node that converts

Each end names its own payload, so a filter reading 16 bytes and publishing 4 is ordinary:

  sensor/range              estimate/distance
  Range { stamp: u64,  ──►  Distance { mm: u32 }
          range_mm: u32,
          quality: u32 }
  16 bytes                  4 bytes
Range = Payload("stamp:u64 range_mm:u32 quality:u32")
Distance = Payload("mm:u32")

@periodic(every="20ms",
          subscribe=Topic("sensor/range", Range),
          publish=Topic("estimate/distance", Distance),
          lane="safe", stale_after="60ms", on_overrun=Skip)
def filter(raw):
    reading = raw.get()
    return Distance.instance(mm=reading.range_mm if reading else 0)

Payloads

A payload crosses shared memory to a peer that may be written in Rust, so its layout is repr(C)'s. Declare the fields and the framework computes it:

Range = Payload("stamp:u64 range_mm:u32 quality:u32")
Range.size    # 16 -- the same as the Rust struct
Range.align   # 8
Declaration Rust
u8 i8 u16 i16 u32 i32 u64 i64 the same
f32 f64 the same
bool bool
name:u8[32] [u8; 32] — arrives as bytes
name:u32[4] [u32; 4] — arrives as a tuple

Nested payloads are not supported; flatten them.

Why not a struct format string. struct pads with native alignment only under @; under <, >, = and ! it packs tight. So repr(C) { u32, u64 } is 16 bytes while struct.calcsize("<IQ") is 12, and a wrong prefix would give you a subscriber reading a shifted field. There is no prefix to get wrong here. (ctypes is not available on TailOS: there is no libffi.)

What is checked, and what is not. The payload's size is checked against the topic on every lane — at open on safe and fast, and per sample on kernel — so a declaration with the wrong number of fields fails loudly at startup. Field order at an identical total size is checked by nothing, in any language pair. Keep the two declarations beside each other in review.

Publishing a value

@periodic(every="20ms", publish=Topic("sensor/range", Range), lane="safe", on_overrun=Skip)
def lidar():
    return Range.instance(stamp=0x1234_5678, range_mm=4200, quality=97)

Return None to publish nothing this cycle; that is not an overrun.

Cycle

Everything about this activation, passed to any node that subscribes.

get() The sample, or None if absent or stale
copied() The same sample, in an object of its own
since_received() Seconds since a sample last arrived at this node, or None
was_stale() Whether data was held but had aged out
missed() Publications lost since the previous cycle. Not a running total
rejected() Publications the transport refused since the previous cycle
overruns() Releases missed since the previous cycle ran
is_healthy() No loss, no rejection, no overrun

get() borrows. It returns the same object every cycle with its fields rewritten, which mirrors Rust's get() -> Option<&T>. A body that keeps it and reads it next cycle sees new data — use copied() to keep one. Rust's borrow checker catches that mistake; Python's cannot, so it is stated here instead.

That reuse is not an optimisation detail you can ignore: it is what keeps a cycle free of allocation the garbage collector has to walk. See Cost.

run()

run()

Runs the declared node, and does not return until its policy stops it.

Decoration cannot do this itself: a module whose import never finishes is never entered into sys.modules, and every traceback from it is reported inside an import. rclpy.spin exists for the same reason.

A process is one node — which is what the Rust attribute enforces by generating main. Declaring two raises, naming both.

Safety

A Python node cannot declare a topic safety-relevant, and this is deliberate.

A safety classification asserts that a bound on cycle time exists and is known. Measured on target, under QEMU:

A full collection, against a bare interpreter heap 6.6 ms worst, 4.4 ms median
An incidental collection inside a 20 ms release grid 2.5 ms — 12.7% of the period

Both the pause and its frequency scale with your live heap, which this framework neither observes nor bounds. There is no bound to assert, so the claim is not available to make.

A Python node is Quality Managed by construction, in the way the fast lane is by its own design. It may subscribe to a topic another node has classified safety-relevant — consuming safety-relevant data at QM is an ordinary architecture. What it may not do is claim the classification for what it publishes. Put that node in Rust.

Cost

Measured on target under QEMU on 2026-09-09. These are not board numbers — QEMU's CPU is not a Raspberry Pi 3's. The ratios should hold; the absolute milliseconds should not be quoted.

Smallest period with no overruns 5 ms (1 ms overran 35 cycles in 200)
Lateness against the grid median ~0.9–1.0 ms, p99 ~1.7 ms, at every period
Time to the first release cold ~4.2 s, warm ~1.4–1.9 s

That last row matters to a system integrator: a Python node is not ready for seconds after it is spawned. It is interpreter startup, not this framework — TAIL-326 — and the framework's admission retry already spans it, which is why a Python subscriber may still be started before its Rust publisher.

What the framework does about the collector, so you do not have to:

  • run() calls gc.freeze() before the first release, moving the interpreter's whole startup heap out of the generations the collector walks. Measured: a full collection costs 6.6 ms before that call and 0.018 ms after it.
  • Nothing the collector walks is allocated per cycle. The cycle object, the sample get() returns and both payload buffers are created once.

What you can do. A body that does not retain what it makes triggers no collection at all — CPython collects on allocations minus deallocations. Keeping a rolling window of samples is what makes the collector run; if you need one, that is the cost to measure.

Errors

NodeError for anything wrong with a declaration, raised at decoration. OSError for a node that cannot start, carrying which topic and why. An uncaught one exits non-zero, so a supervisor can tell a node that never started from one that stopped cleanly.

A duration that will not parse NodeError at decoration
A missing or unknown key NodeError at decoration, naming it
safety=True NodeError at decoration, with the reason above
A topic that never appears OSError from run() after the admission attempts
A payload that disagrees with the publisher's OSError from run(), or refused per sample on kernel
A cycle overrunning under Fault NodeError from run() — the ABI reports it as an OSError, restated in the framework's vocabulary because you asked for Fault

init has no equivalent, and does not need one

Rust needs init because a bare fn cannot own state between calls. A Python closure, a module global or a bound method already does:

cycles = 0

@periodic(every="20ms", publish=Topic("diag/count", Count), lane="safe", on_overrun=Skip)
def counter():
    global cycles
    cycles += 1
    return Count.instance(cycles=cycles)
Generated from framework/periodic/doc/periodic_python_public.md in the TAIL OS repository.