Files
MARTe-Integrated-Components/Docs/StreamHub-Developer.md
T
Martino FerrariandClaude Opus 4.6 092fd3c775 fix(streamhub): stop the trigger going deaf between captures
TriggerEngine::CheckSample returned early in every state but ARMED, so an
edge arriving while a capture was being collected or handed out was
dropped, and the automatic rearm then waited for a FRESH edge. The engine
was therefore blind from its own trigger point until the capture had been
harvested — a post-window — and for the holdoff on top of that.

On a sparse pulse train that rounds the capture spacing up to a whole
pulse period: at the default 1 s window the blind stretch is 1 s, so a
1 Hz train was caught at 0.5 Hz and a wider window lost whole multiples.

The comparator now keeps running through COLLECTING and TRIGGERED and
remembers the first edge at or past trigTime + max(postSec, holdoffSec).
The holdoff guards against re-triggering on the ringing of the same
event and is measured from the trigger point, so it overlaps the
post-window rather than adding to it. Rearm() fires on the remembered
edge; it also keeps the tracked level, so the first sample after it has
a real predecessor instead of being spent seeding one.

Arm() stays the operator's arm and discards the held edge — they asked
for the next event, not one already been and gone — and SetConfig() and
Disarm() drop it too, since it was never judged against the new window.

This is the same defect and the same remedy already validated in the Go
hub (wshub/trigger.go, trigger_sporadic_test.go); the C++ hub had been
left with the original semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 01:45:13 +02:00

26 KiB
Raw Blame History

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 SignalRingBuffer has its own FastPollingMutexSem; writers are the UDPS receive threads, readers are the push loop and zoom handlers.
  • WSServer has 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 active flag; 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; thereafter packetT = pktCalibOffset + hrt/hrtFreq.
  • Each referenced time signal gets its own offset on first value; timerToSec = 1e-9 for uint64 time signals, 1e-6 otherwise.
  • 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's Anchor = Continuous explicitly 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 after holdoffSec (normal, unless stopped)--> ARMED
                                                    └─ or straight to COLLECTING on a held edge
any --disarm--> IDLE

UDPSourceSession calls TriggerEngine::CheckSample for every decoded sample of the configured signal (signal index cached per config epoch).

The comparator keeps running through COLLECTING and TRIGGERED. It cannot fire there — the capture in flight owns that stretch — but it remembers the first edge at or past trigTime + max(postSec, holdoffSec), and Rearm() fires on that remembered edge instead of waiting for a fresh one. Without this the engine is deaf from its own trigger point until the capture has been harvested and the holdoff has run, which on a sparse pulse train rounds the capture spacing up to a whole pulse period: at a 1 s window a 1 Hz train was caught at 0.5 Hz, and a wider window lost whole multiples. The capture is built from the edge's own timestamp out of rings that still hold everything around it, so honouring it costs nothing.

Arm() and Rearm() differ only in this: Arm() is the operator's own arm and discards the held edge (they asked for the next event), while Rearm() is the automatic end-of-capture arm and consumes it. Rearm() also keeps the tracked level, so the first sample after it is compared against its real predecessor rather than being spent seeding one. SetConfig() and Disarm() drop the held edge as well — it was never judged against the new window. Each source is read [trigTimepreSec, 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), never LIBRARIES_STATIC — the GTest binary links every .a it 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 t with %.17g: at Unix-epoch magnitudes %.6f destroys µs resolution.