25 KiB
StreamHub — Developer Guide
Source/Applications/StreamHub/ is a headless C++ application (MARTe2-linked,
MARTe2 coding style, no STL) that aggregates one or more UDPS sources
(UDPStreamer DataSources) and serves them to oscilloscope clients over
WebSocket. The Go hub (Client/udpstreamer) is the feature-complete reference
implementation of the same protocol and is kept untouched.
Wire protocols: Protocol.md (UDPS, source → hub) and StreamHub-API.md (WebSocket, hub → clients).
1. Source layout
| File | Role |
|---|---|
main.cpp |
CLI entry (-cfg file.cfg -port N -maxPoints N), signal handling |
StreamHub.{h,cpp} |
Top-level object: config, push loop, WS command dispatch, broadcasts |
UDPSourceSession.{h,cpp} |
One UDPS source: UDPSClient listener, payload decode, wall-clock calibration, ring buffers, stats |
SignalRingBuffer.h |
Per-signal (t,v) ring: monotonic write counter, ReadSince cursor reads, binary-search ReadRange |
TriggerEngine.{h,cpp} |
Trigger FSM (IDLE/ARMED/COLLECTING/TRIGGERED), edge detection per decoded sample |
UDPSourceStats.h |
512-entry cycle/frag/byte rings → avg/std/min/max, rate, 20-bin histogram |
HistoryWriter.{h,cpp} |
Disk-backed circular history: per-signal .shist files, WriteTick, ReadRange (binary search via pread), disk space monitoring |
WSServer.{h,cpp} |
RFC 6455 server: handshake, framing, per-client write mutex, broadcast/unicast |
WSFrame.h, SHA1.h, Base64.h |
Header-only WS plumbing, shared with the ImGui client |
LTTB.h |
Largest-Triangle-Three-Buckets decimation |
The UDPS client itself lives in the shared library
Source/Components/Interfaces/UDPStream/ (UDPSClient, also used by
DebugService). Note: in multicast mode the server delivers CONFIG over the
TCP control connection, so UDPSClient selects on both the multicast UDP
socket and the TCP socket and frames TCP reads.
2. Thread model
| Thread | Created by | Work |
|---|---|---|
| main / push loop | StreamHub::Run() |
At PushRate Hz: PushData() (serialise v1 frames), trigger servicing (capture finalisation, auto-rearm), PushStats() at StatsRate Hz |
| WS accept | WSServer::Start() |
accept() + handshake, spawns client readers |
| WS client reader ×16 | WSServer |
Reads frames, may contain several coalesced frames per TCP read; dispatches JSON to StreamHub::OnWSCommand(json, len, slotIdx) |
| UDPS receive ×32 | UDPSClient::Start() (one per session) |
select() on UDP (+TCP in multicast), reassembles fragments, calls UDPSourceSession listener callbacks |
Synchronisation:
- Each
SignalRingBufferhas its ownFastPollingMutexSem; writers are the UDPS receive threads, readers are the push loop and zoom handlers. WSServerhas a per-client write mutex (push loop and command replies can write concurrently).- WS commands run on reader threads, but mutating operations (ring resize via
setMaxPoints) are deferred to the push loop through pending atomics. - Session slots use an
activeflag; removal never compacts the array, so indices stay stable.
3. Time base (wall-clock calibration)
All timestamps exposed to clients are Unix wall-clock seconds (float64). Each session calibrates per time-source:
- First DATA packet anchors
pktCalibOffset = wallNow − hrt/hrtFreq; thereafterpacketT = pktCalibOffset + hrt/hrtFreq. - Each referenced time signal gets its own offset on first value;
timerToSec = 1e-9foruint64time signals,1e-6otherwise. - The time-signal offset is snapped only on a genuine discontinuity in the
source: reconnect, CONFIG change, or the source clock jumping backward (a
looping/rewinding producer such as a rewinding
FileReader). - Plain drift — a source free-running on its own clock, or remote-vs-local HRT
frequency error — is slewed, not snapped. Past a 2 s threshold the offset
is nudged toward wall clock by at most 10 % of the packet's own duration.
Snapping instead would shift the whole published timeline in one step and so
tear a hole of exactly the drift into a stream that is in fact continuous;
a source drifting past the threshold repeatedly used to produce a train of
2 s holes. Drift is the honest reading, and the trade-off
TimeArrayGAM'sAnchor = Continuousexplicitly asks for: a producer that cannot sustain its nominal sample rate will fall progressively behind wall clock, and the hub reports that rather than hiding it. The Go hub anchors once and never re-anchors, so it never had the hole.
Per timeMode:
| timeMode | Timestamping |
|---|---|
| FIRST/LAST_SAMPLE | anchor = calibrated time-signal value (fallback packetT); t = anchor ± k·dt from samplingRate |
| FULL_ARRAY | t[k] = calibrated time array element |
| PACKET, n=1 | packetT |
| PACKET, n>1 | elements span (lastPktWall, wallNow] — backward anchoring, deliberately different from the Go hub (which extrapolates forward and overlaps the next packet under jitter); keeps ring time strictly monotonic |
Multi-element PACKET signals are exposed per element as name[i].
4. Push path (only-new samples)
SignalRingBuffer keeps a monotonic totalWritten; the hub keeps a
per-(session, signal) cursor and uses ReadSince(cursor, …) (clamped to the
oldest sample on overrun). Each tick serialises only new samples, LTTB-capped
to MaxPushPoints (default 50) per signal — LTTB is applied only to temporal
signals (multi-element, timeMode ≠ PACKET). Cursors advance even with zero WS
clients so a connecting client never receives a backlog burst. Cursors are
reset on (re)CONFIG and on ring resize.
This replaces the original "re-send the last N points each tick" design, which caused visible trace corruption (LTTB picked different points per overlapping window).
5. Trigger engine
Hub-side, web-client semantics (setTrigger fields in
StreamHub-API.md):
IDLE --arm--> ARMED --edge crossing--> COLLECTING --every source past trigTime+postSec+0.15s--> TRIGGERED
TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED
any --disarm--> IDLE
UDPSourceSession calls TriggerEngine::CheckSample for every decoded sample
of the configured signal (signal index cached per config epoch). Each source is
read [trigTime−preSec, trigTime+postSec], LTTB-capped to 20 000 pts/signal and
appended to a binary version 2 capture frame; every FSM transition
broadcasts a triggerState event.
Once fired, that event carries trigTime and the window latched at fire
time (preSec/postSec). Clients draw the still-filling capture from their own
buffers on that axis long before the v2 frame arrives — for a long window at a
high rate the hub stays silent for seconds — and the trigger bar's window and
pre-% are editable, so without the latched values a client would place the
filling trace on whatever window the operator happened to be typing. Older hubs
omit both fields; clients fall back to their local config.
The COLLECTING deadline is on the data's clock, via
UDPSourceSession::ProducerNewestTime() — trigTime comes from sample
timestamps, and a source free-running on its own clock sits seconds away from
clock_gettime(), so a wall-clock deadline chops exactly that offset off every
capture's tail. Only signals actually timestamped from a time signal count
toward that reading: PACKET-timed ones (including the time array itself) are
stamped on arrival and would just report "now".
Sources are harvested independently — BeginTriggerCapture,
HarvestTriggerCapture per source as it becomes ready, FinishTriggerCapture
once all are in — with the frame accumulating in capBuf_ across push ticks.
Waiting for the slowest source before reading any of them lets the leaders'
rings roll past the pre-trigger region first, losing the head of their traces. A
2 s wall-clock watchdog bounds the wait for a source that stopped advancing: it
is harvested short, with a warning naming the source and how far it got.
setTrigger also records the requested window, and each stats tick the push
loop runs GrowRingsForTrigger(). A ring whose measured rate
(Count() / TimeSpan(), since UDPS sources usually advertise
samplingRate = 0) cannot hold window + 0.5 s is grown in place to
rate × (window + 0.5) × 1.2 points, clamped to RingMaxMB per signal.
SignalRingBuffer::Grow() copies oldest→newest and leaves count /
totalWritten untouched so the per-client push cursors survive the resize.
Rings never shrink; a hub left with a 5 s window on a 5 MSps source will sit at
the ceiling.
6. Configuration
WSPort = 8090
MaxPoints = 20000 // legacy global cap (overridable with -maxPoints)
PushRate = 30 // Hz
MaxPushPoints = 50 // per signal per push
StatsRate = 1 // Hz
RingTemporal = 1000000 // initial ring capacity, temporal signals (pts)
RingScalar = 100000 // ring capacity, scalar/PACKET signals (pts)
RingMaxMB = 128 // per-signal growth ceiling (MiB) for trigger windows
SourcesFile = "streamhub_sources.json" // saveSources persistence
AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099"
// comma/space-separated WebSocket Origin allowlist (max 8 × 128 chars).
// Without it the handshake only accepts an Origin whose host matches
// the request Host, so a browser serving the SPA from another port
// (run_streamhub.sh: SPA 8099, hub 8090) gets 403. Non-browser
// clients send no Origin and are unaffected.
Sources = {
Src1 = { Label = "PSU" Addr = "127.0.0.1" Port = 44500
MulticastGroup = "239.0.0.1" DataPort = 44503 } // multicast optional
}
Sources from SourcesFile are loaded after the static Sources block;
sources added at runtime via WS addSource get ids s1, s2, ….
History configuration
An optional +History block enables disk-backed circular storage
(HistoryWriter). The + prefix is MARTe2 StandardParser syntax for a
child node; the hub looks for both +History and History as the node name.
+History = {
Directory = "/data/streamhub_history" // required
DurationHours = 1 // hours of data to retain per signal (default 1)
Decimation = 10 // keep every Nth sample (default 1)
FlushIntervalSec = 5 // header flush period in seconds (default 5)
MinDiskFreeMB = 500 // pause writes below this threshold (default 500)
}
Per-signal file capacity is computed at source CONFIG time:
capacity = ceil(DurationHours × 3600 × samplingRate / Decimation), minimum
1000 pairs.
The Go hub (Client/udpstreamer) carries the same archive and the same file
format, configured with flags instead of a config node: -history-dir
(defaults to <tmp>/udpstreamer-history; empty disables),
-history-window-sec, -history-decimation, -history-flush-sec,
-history-min-free-mb (negative disables the check; 0 means the 500 MB default,
where the C++ MinDiskFreeMB = 0 disables it) and -history-max-mpts, a
per-signal budget in millions of stored points, defaulting to 16 MPts (256 MB).
The budget exists because the timespan alone cannot bound the file: 600 s of a
1 MSps signal is 9.6 GB.
The Go hub sizes its files from the window, not from a retention period. The
archive exists to answer a zoom or a trigger capture after the in-memory rings
have rolled past it, and neither ever asks for more than the live or trigger
window — so a file holds windowSec × rate samples (plus 25 % headroom, since a
capture is read back a window after its first sample was written), and never
hours of them. Retaining an hour instead meant a 1 s live window was archived at
a thousandth of the resolution the same budget could have bought.
The budget is therefore spent on resolution, not on span. A signal too fast to
archive sample-for-sample within it is stored as a min/max envelope: bucket
source samples collapse to their two extremes, with bucket the narrowest that
makes the window fit. The .shist header's decimation field carries
bucket × Decimation, so a reader knows the stored resolution, and a file is
only reopened when it matches.
Hub.retuneRings re-sizes the files once a second alongside the rings, from the
same activeWindowSec(). A file's capacity and bucket are fixed at creation, so
a re-size discards what it held; two rules keep that rare. A file is only grown
when it no longer covers the window, and only shrunk when it is enveloped
(bucket > 1), covers more than twice the window, and a narrower bucket is
actually available — a file already at full resolution is left alone however
short the window becomes, so arming a 1 s trigger does not throw away the
seconds the capture is about to ask for. historyInfo is re-broadcast whenever a
re-size happens.
The budget is also settable at runtime from the web UI (the history badge in the
status bar) via the setHistoryBudget WS command; historyInfo reports it as
maxMPtsPerSignal and reports each signal's bucket. Changing it re-creates the
files, so the archived samples are lost — a file's capacity and bucket width are
fixed at creation and an existing envelope cannot be re-bucketed into a different
one.
History is on by default in the Go hub because it is what holds a trigger capture
at full resolution — see Trigger captures below. Signals whose producer
declares samplingRate = 0 — every UDPS source — are not sized from a guess: the
file is opened only once the hub has measured the rate off the live stream, which
it retries once a second.
.shist binary file format
Each signal gets one file: <Directory>/<sourceId>/<signalName>.shist.
The file size is fixed at creation (64 + capacity × 16 bytes) and never grows.
Offset Size Field
0 4 Magic: "SHR1"
4 4 uint32 version (1)
8 4 uint32 capacity (max pairs)
12 4 uint32 head (next write position, 0-based, wraps at capacity)
16 4 uint32 count (valid entries, ≤ capacity)
20 4 uint32 decimation
24 8 float64 tOldest (Unix seconds)
32 8 float64 tNewest (Unix seconds)
40 24 reserved (zero-padded to 64 bytes)
64 … data: capacity × 16 bytes (float64 time + float64 value per pair)
Data is written at the head position and wraps circularly. The oldest valid
entry is at logical index (head + capacity − count) % capacity. All I/O
uses pwrite/pread (no mmap), so concurrent reads from WS threads are
safe without locking.
Headers are flushed to disk every FlushIntervalSec seconds and on shutdown.
On restart, if an existing file has matching magic, version and capacity, it is
reopened — head/count/time bounds are restored from the on-disk header.
History query path
historyZoom requests (see StreamHub-API.md) call
HistoryWriter::ReadRange which performs binary search over the circular file
using pread to locate the [t0, t1] window, then copies matching pairs.
If the result exceeds the requested n, decimation is applied (same decimator
as in-memory zoom: LTTBDecimate in the C++ hub, minMaxDecimate in the Go
one).
Both the web SPA and ImGui client issue historyZoom in parallel with regular
zoom and merge the results: history covers the older part of the visible
window, the in-memory ring covers the recent part.
A range wider than the read budget is thinned across its whole width with a stride, not truncated at the front: answering a 10 s query with its first few milliseconds reads as an empty plot to a client and sends it back to its own coarse copy of the data.
In-memory buffer policy (Go hub)
Each temporal signal gets one ring holding a fixed budget of (t, v) pairs:
10 M points, 160 MB, settable with -ring-mpts. Scalar signals keep a flat
100 000-packet ring, where a megasample budget would be waste. Rings start at
250 k points and are grown to the budget on demand, so a source that is
configured but never sends costs nothing.
Like the disk archive, the budget buys resolution, not span. Once a second
retuneRings compares the measured source rate against the window being
displayed and picks each ring's min/max bucket:
| condition | bucket | effect |
|---|---|---|
Sps × window ≤ budget |
1 | stored verbatim; the ring reaches further back than the window, which is free zoom headroom |
Sps × window > budget |
⌈2 × Sps × window × 1.25 ÷ capacity⌉ |
bucket samples collapse to their two extremes, so the whole window fits |
A bucket costs two points (its minimum and its maximum), hence the factor 2 — and why a bucket of 2 covers no more ground than a bucket of 1.
The window is the trigger's while a trigger is armed: its pre-window has to
already be in the ring when the trigger fires, or the capture has nothing to
back-fill from. Otherwise it is the widest window any connected client has
reported with the setWindow command, defaulting to 10 s for clients that never
send one. Sizing for the live window matters as much as for a capture: a fixed
sample-count ring covers ~6 s at 1 MSps, so a zoom on a 60 s timescale used to
come back with only its tail.
Retuning is hysteretic — a bucket is held while it covers the window without covering more than twice it. Sharing one threshold for up and down makes a rate jittering across a bucket boundary halve and double the stored resolution every second.
Live pushes, the disk archive and the trigger comparator all see every sample:
ingest hands the raw batch to each, and only the ring's own copy is reduced.
Trigger captures (Go hub)
A trigger capture is delivered as a decimated snapshot (20 000 points), so a zoom into it has to come from full-resolution storage. The rings are tuned to ~1.25× the trigger window, so they roll past a captured window shortly after the capture — and the trigger rearms and starts refilling them immediately.
In-memory double buffer. The rings are the write half; captureHold
(capturehold.go) is the read half. As buildTriggerCapture lifts each signal's
window out of its ring it publishes the undecimated slice into the hold, and
zoomSlice answers from the hold rather than the ring for any range the held
window fully contains. The swap happens only once the next capture is complete —
which is also the moment the client stops displaying the previous one — so the
shot being explored is never overwritten by the acquisition running behind it. A
capture that came back empty does not swap, so it cannot blank the window on
screen.
Waiting for the buffer. An armed trigger ignores edges until its buffers
reach back far enough for a capture taken now to come back whole (fillLocked
in trigger.go, fed by refreshTriggerFill from the trigger signal's own ring
— once per tick, and again on every trigger command so that an arm cannot
fire on a stale measurement). Firing earlier can only produce a capture whose
front was never recorded, which is what made the first shot after a widened
window come back short.
What must hold is that the buffer spans the whole window at harvest time — its
newest sample is then trigTime + post, so anything less has lost the front of
the capture. It keeps filling while the post-window is collected, so the
shortfall it may start with is what it will make up in that time, measured
rather than assumed:
need = windowSec − growth × postSec (floored at the pre-trigger window)
growth is the ring's span growth in seconds per second, sampled over at least
bufGrowthIntervalSec and smoothed. The three regimes fall out of the one
formula:
| ring | growth | needs |
|---|---|---|
| still filling | 1 | the pre-trigger window — everything after the trigger is yet to be recorded anyway |
| full, re-bucketing for a longer window | 0…1 | in between: it drops dense old samples to take sparse new ones, so it fills slower than real time and the front of the capture recedes while the post-window elapses |
| full, settled | 0 | the whole window — which a ring tuned for that window already exceeds, so nothing actually waits |
Measured at 1 MSps, widening 10 s → 30 s with 50 % pre: growth settles at ~0.65,
so need converges on ~20.4 s of the 30 s and the trigger fires ~12 s after
arming with a capture that is 100 % complete. Requiring the whole window instead
would have waited 26 s for the same result.
The gate measures the trigger signal's ring, not the narrowest of all of them: a
signal that never reaches back that far would otherwise stop the trigger from
ever firing. It is disabled outright when there is no ring to measure or nothing
is needed, and forceTrigger overrides it. While it holds off, triggerState
carries bufferFill/bufferNeedSec and is re-broadcast as the fraction climbs,
so the UI shows ARMED 42% rather than a trigger that looks stuck.
Back-filling a short capture. A ring only spans the window once it has
rolled over completely at its current min/max bucket, which takes as long as the
window itself; widen the window, or arm right after setting it, and the first
captures start late and the client draws a blank front half.
backfillCaptureHead (trigger.go) therefore prepends whatever of
[t0, ring's first sample) the archive still holds, budgeting the read by the
share of the window being filled and trimming the overlap so the frame's
timestamps stay ascending. It needs history enabled; without it the capture is
simply short, and the hub logs by how much. The hold declines any range its own
samples do not actually cover, so a stretch neither source could supply falls
through to the archive instead of being redrawn as the same hole on every zoom
and every fit.
The hold declines ranges reaching outside its window: those are live zooms, and
only the rings still track the stream. Inside the window it needs no
trigger-state gating, because retuning never rewrites stored samples — a ring
that still covers the range holds the very same points. It is cleared when
updateConfig rebuilds the rings, since a restarted producer can replay the same
timestamps.
Budget: the hold costs one window per signal on top of the ring budget, up to a
further ~0.8 × -ring-mpts. Nothing is held until the first capture fires.
On disk. The archive covers what the hold cannot: ranges wider than the
capture window, and sessions where the hub restarted. It is circular and sized
from that same window, so it too wraps over a captured shot within a window of
delivering it. When a capture is delivered, the hub therefore copies
[trigTime − pre, trigTime + post] out of each .shist into a
<signalName>.cap file, laid out as a full non-wrapping .shist
(capacity == count, head == 0) so the same readRange reads it. A
historyZoom whose range the capture file fully contains is answered from it;
anything wider is answered from the archive. The copy is replaced by the next
trigger and by nothing else — rearming keeps it, because the client is still
showing that capture.
Budget: a capture costs one window's worth of disk per signal on top of
-history-max-mpts.
Protecting the window in place instead — pinning the region and refusing to wrap onto it — does not work, and was tried: a capture held for longer than the archive covers stops the archive dead, and the resulting hole lands exactly where the next capture's pre-trigger window belongs.
7. Build & test
source env.sh # always, for build and run
make -f Makefile.gcc apps # builds StreamHub.ex
# or: make -C Source/Applications/StreamHub -f Makefile.gcc
./Build/x86-linux/StreamHub/StreamHub.ex -cfg hub.cfg
make -f Makefile.gcc test
./Build/x86-linux/GTest/MainGTest.ex # 71 unit tests, incl.
# SignalRingBuffer (ReadSince / binary-search ReadRange / wrap),
# TriggerEngine FSM, LTTB — sources in Test/Applications/StreamHub/
cd Test/E2E/suite && ./run_e2e.sh # full-stack E2E (see below)
./run_streamhub.sh -w -g # interactive demo stack
End-to-end test
Test/E2E/suite/run_e2e.sh is the unified E2E suite covering the whole
streaming + debug chain (chain/direct/recorder/debug/tcplogger
scenario kinds, see Test/E2E/suite/scenarios.py), including StreamHub live
push, zoom, window and trigger checks via the Go chain-client. It builds
everything, runs the scenario matrix plus the stress matrix, and produces a
consolidated report_data.json + Typst PDF report
(Test/E2E/suite/E2E_Report.typ). See the script's --help for options.
When changing the WS protocol, update in lockstep: this hub, the Go hub
(Common/Client/go/wshub), the browser SPA (Client/udpstreamer/static), the
ImGui client (Client/streamhub/Protocol.cpp), the E2E chain-client
(Test/E2E/suite/client), and StreamHub-API.md.
8. Gotchas
- No STL in this directory (MARTe2 style); use
StreamString,FastPollingMutexSem, fixed arrays. - Link against UDPStream dynamically (
LIBRARIES += -lUDPStream), neverLIBRARIES_STATIC— the GTest binary links every.ait finds and would get duplicate symbols. - WS text frames may arrive coalesced; never NUL-terminate a payload in place without restoring the byte (it is the first header byte of the next frame).
- Zoom replies must print
twith%.17g: at Unix-epoch magnitudes%.6fdestroys µs resolution.