Stream delivery: counted and reliable streams¶
A plain stream! sends each item once and forgets it: nothing tells the host that an item is missing, and nothing is
kept for a host that is slow or gone. That is right for a latest-value stream (a motor controller's state at 1 kHz,
where an old value is worthless), and wrong for measured data, logs and monitors, where the host must know exactly
what it didn't get, or must get everything.
WireWeaver has three delivery kinds for device → host streams:
| plain | counted | reliable | |
|---|---|---|---|
| Position in each event | – | yes | yes |
| Data the device dropped reported exactly | – | yes | yes |
| Link losses, duplicates, reordering detected | – | yes | yes, and repaired |
| Device timestamp per event | – | optional | optional |
| Acknowledged, resent on loss | – | – | yes |
| Kept while the host is slow or gone, sent when it is back | – | – | while the device's store has room |
| Latest values first after a reconnect, the backlog beside them | – | – | yes |
| Extra bytes per event | 0 | 1-8, plus units, lost, t when present |
the same |
| Device state | none | a few counters | counters and a store |
Item types carry none of this: positions, loss counts and the time of the first unit are in a small head the framework
puts in front of every event, so a SampleBlock stays pure data (an item may add a time of its own, such as when a
UART chunk's last byte arrived).
This page explains what counted and reliable streams guarantee. How to declare them is in Streams,
the device's handlers and producers and the host's PositionedStream in
Counted and reliable streams, the bytes on the wire and the acknowledgement protocol in
Stream protocol.
Where stream data gets lost¶
From a producer on the device to user code on the host:
producer ──► device buffer ──► event loop ──► framer ──► medium ──► host transport ──► event loop ──► stream queue ──► user code
(DMA, ISR) (user's ring) (ww_device) (USB..) (nusb, socket) (client) (per stream)
- The producer's buffer overflows: a producer can't wait, so it drops data. That happens on every medium, when the stream's rate is above what the link moves, while the device's event loop is busy (a flash erase, a long handler), and while no host is connected.
- The medium loses it, depending on the transport (below).
- The link goes down: whatever was in buffers on the way is gone, and whatever the producer makes until the host is back.
- The host's queue is full, when user code doesn't read.
| Medium | The medium itself | Where losses happen |
|---|---|---|
| USB bulk / interrupt | CRC and retries in hardware, flow control by NAK: no loss | the producer's buffer, unplug, device reset, USB suspend, host process exit |
| RTT | RAM rings, no corruption; a chunk that doesn't fit is retried, then the link goes down | the producer's buffer (a probe polls at its own pace), detach |
| UDP | datagrams lost, duplicated, reordered; a message split across datagrams is dropped whole when a piece is lost | any datagram, the producer's buffer, link down |
| WebSocket | TCP: no loss while connected, backpressure blocks the device | the producer's buffer, what is in flight when the connection drops |
| In-process | none | the producer's buffer |
Counted streams make every one of these losses visible, with its size and place. Reliable streams also repair link losses and keep data on the device while it has room.
Choose by what a missing piece costs:
- plain: the latest value is all that matters, and counting costs more than it tells;
- counted: measured data, telemetry over UDP: gaps are acceptable, but must be shown where they are;
- reliable: logs, monitors whose protocol decoders can't recover from a gap, data loggers that collect while no host is connected.
The kind is part of the API, visible in introspection (ApiItemKind::StreamFn { delivery, .. }), so it is
chosen in the API crate. How much a reliable stream keeps, and where, is the device's choice: one device keeps 256 KB
in RAM, another days of data on an SD card, behind the same API.
Positions and units¶
A counted or reliable stream counts units from the start of its session, as a u64. What a unit is, the stream's
documentation says, and the device says how many units each item has when it sends it:
| Stream | Unit | An item of… | …has units |
|---|---|---|---|
| UART receive | byte | 37 bytes | 37 |
| I2C monitor | transaction | 12 transactions | 12 |
| ADC samples | sample set (one sample of every channel) | 1 536 bytes, 4 channels × 12 bit | 256 |
| a status stream | item | one value | 1 |
Each event carries the position of its first unit (pos) and its number of units (units). The next event starts at
pos + units + lost:
lost: units the device dropped right before this event (producer overflow, store full, no host). It explains a jump ofposexactly and says the gap is the device's.- A jump of
posthatlostdoesn't explain is a link loss (UDP): the host knows exactly how many units are missing, though not their content. A reliable stream gets them resent. - An event that starts before what the host expects is a duplicate or a late one: the host drops it and counts it.
The host never counts units of an item: it only compares positions, so units are whatever the device says (an ADC block's bytes don't tell its sample sets without the sample format). Counting units rather than items keeps every position exact: a protocol decoder can say "the frame starts at byte 4711 of this capture", and a gap of 60 000 bytes says how much is missing even when the device dropped raw bytes before they ever became items.
Positions are 64-bit because a store can be big: a 32 GB SD card holds 2³⁵ bytes of a byte stream within one session. On the wire a position costs only the bytes it needs (below), and it is never truncated, so the host never has to guess its high bits.
Sessions, cursors and resuming¶
A session is one run of a stream with one meaning of its positions, numbered by the device (session: u32, for
example a boot counter combined with an open counter). A cursor names a point in it:
- A session starts when the host opens the stream, or when the device starts it by itself (a logger that samples from power-on). Positions start at 0.
- It ends when the stream is opened again (the units may mean something else now), or when the device's store is erased. It does not end when the link goes down, nor, with a store that survives reboots, on a reboot.
- Opening can carry a cursor: a resume. The device answers with
StreamOpened { session, oldest, live_from, resume_from }:- same session, cursor still in the store: the backlog starts at the cursor, nothing is lost;
- same session, cursor older than the store keeps: the backlog starts at the oldest event kept, the units before
it are reported lost (cause
Store); - another session: the old one has ended, the host starts with the new one.
- Counted streams resume too. They keep nothing, but the device keeps counting while the host is away, so after a
reconnect the first event's
lostsays exactly what the host missed.
A host that saves data also saves the cursor with it, and resumes from it after a restart: with explicit acknowledgements (below), nothing is lost even when the host crashes between receiving data and saving it.
Credit sinks, the other direction, have sessions and cursors too, with the roles swapped: the host sends, so the
host names the session, and the device's position says how far it accepted. A sink continues after
disconnect().keep_streams() and Commander::resume_streams() from where the device is, and a restarted host
continues from a saved Sink::cursor() with <sink>_resume(cursor); a device that lost the session is reported
(StreamError::SinkSessionLost). See credit sinks.
Latest values while backfilling¶
A resumed reliable stream has two runs of positions, each delivered in order:
- the live run starts at the newest event the device has (
live_from), so a slow stream shows its latest value at once instead of after its next event, and goes on with everything new; - the backfill run goes from the resume point up to
live_from.
The device sends live events first, and backfill events with whatever capacity the link has left. A data logger with
six hours on its SD card shows its current readings on a dashboard as soon as the laptop connects, while the backlog
arrives beside them; when the live data alone fills the link, the backlog waits in the store and nothing is lost. When
the backfill run reaches live_from it ends, and the runs are one again.
Every event is sent once, and acknowledgements stay cumulative: one position per run. On the host each event says which run it belongs to, so a dashboard reads the live run, a recorder takes both and writes by position. While a backfill runs, acknowledged live events stay in the store until the backfill passes them.
Acknowledgements¶
A reliable stream's host acknowledges what it has, cumulatively per run, and grants the device a window of bytes in
flight (4 MiB by default, what the host queues for the stream). A host that doesn't read stops granting: the device
stops sending and keeps the data in its store, so the host's memory stays bounded. A gap (UDP) is asked for again
from its first unit, and the device goes back and resends from there (go-back-N). Data counts as received when user
code takes it, or with AckMode::Explicit only when user code confirms it, for example after writing it to disk. On
USB the link doesn't lose, so an acknowledgement is a few bytes every half window. The messages and timeouts:
Acknowledgements and resends.
Stores¶
What a reliable stream keeps lives in a store, a ww_stream::store::StreamStore the device picks per stream: RamRing
in RAM, PersistentRamRing in RAM that survives a soft reset, LogStore on an SD card or NOR flash that survives
reboots and power cuts. When a store is full it drops the newest events (FullPolicy::DropNewest, the default) or the
oldest (DropOldest); either way the units are counted and reported to the host as lost. See
Stream stores.
Events are stored as they are sent, head included, so a resent event is bit-identical to the first send: its
timestamp is the device's time when the data was made, never when it was sent. A reboot restarts the device's clock;
the first event after one carries the new clock epoch in HeadExt::clock_epoch, so the host can tell the two
timelines apart.
With several reliable streams, the device sends by priority class (High, Normal, Low, set by the firmware, not
the API), live data before backlog within a class, see
Sending.
Counting¶
Every sending stream keeps TxStats (stats()): units sent and resent, lost by cause (producer, store full, no
host), bytes acknowledged, resend requests, overruns. With the cnt feature it also counts into typed
cnt instance counters, one set per stream, named by the firmware, so cnt tui shows
per stream where data went:
use ww_stream::counters::StreamCnt;
static ADC_SAMPLES_CNT: cnt::Counters<StreamCnt> = cnt::counters!(StreamCnt, adc_samples);
let samples = Reliable::new(RamRing::<{ 256 * 1024 }>::new()).counters(&ADC_SAMPLES_CNT);
The host's receiver (ww_stream::rx::Receiver) keeps RxStats with the same causes: received, lost on the device, on the link and in the store,
duplicates, late, out of order.
Timestamps¶
t in the head is the device time of the event's first unit, a ww_si::Second in the storage the device's timer gives
cheaply: Second { Micro, U32 } (5 bytes, wraps every 71.6 minutes, the host unwraps it) on most MCUs. A device uses
one clock for every stream, so items of different streams line up.
The client unwraps every device time it gives out by one rule, for event heads, the items of batched streams and
observe updates alike: a time in wrapping storage (U32 µs, U16 ms, ..) comes as a U64 in the same prefix that
counts on across wraps, taking each time as the one closest to the previous with the same low bits (ww_si::Unwrapper
without an anchor). That holds while consecutive times are less than half a wrap apart (35.8 minutes for U32 µs,
32.7 s for U16 ms); the device may change prefix and storage on the way. Each stream handle (each half of a split)
and each observe level counts on its own, and the count starts over after a disconnect (the device may have rebooted)
and at a new clock epoch in a head's ext. U64 and float times come as they are.
Three ways to timestamp, chosen per stream:
| Granularity | How | Cost | For |
|---|---|---|---|
| Per anchor | t in every N-th event (e.g. every 50 ms); the host interpolates by position and the configured rate |
~5 B per anchor | fixed-rate streams: ADC, logic analyzer |
| Per event | t in every event |
5 B per event | UART chunks (a chunk ends when the line goes idle, so t is its first byte), sensor readings |
| Per item | t per event, and each item's offset from it inside the item, in the head's unit, as a Series column (delta of delta) |
about 1.2 B per item for jittered ~100 µs gaps, 1.9 B for random 20-2020 µs gaps, 0.23 B for a regular rate | monitors: I2C transactions, GPIO edges |
Two examples:
- An ADC at 500 kS/s, 12-bit packed, one channel (750 KB/s): in blocks of 1 024 samples (1.5 KiB), event header and head
take 0.7 %, a
tin every event 0.3 % more. Per-sample timestamps would take 15 % and tell nothing, the rate is fixed: use blocks of about 1 KiB and an anchor every 50 ms. - An I2C monitor at 1 MHz with about 20 000 transactions per second, in chunks of 32: 625 events per second of about
15 bytes of header, head and
t(9.4 KB/s), plus per-transaction time at ~1.5 B each (30 KB/s), against ~10 bytes of data per transaction (200 KB/s).
With each transport¶
| Medium | Counted | Reliable |
|---|---|---|
| USB, RTT, WebSocket, in-process | device drops and link-down gaps exact | nothing lost while the store has room; backfill after a reconnect, live first; bounded host memory |
| UDP | link losses, duplicates and reordering detected too | go-back-N repairs them; acknowledgements carry the whole state, so losing one is harmless |
| CAN, UART (planned transports) | as UDP | as UDP; keep events small, a message split across frames is lost whole |
Resending is per stream, not in the link layer: a link that resends would hold latest-value streams back behind a lost datagram, couldn't keep data across a reconnect or a reboot, and wouldn't know about data the device dropped.