doc/help/Data-Hotpath.md
A technical reference for how a single byte travels from a connected device to a rendered widget, and how the frame parser and dataset transforms plug into that pipeline. If you're looking for the high-level user view, start with Data Flow. For threading-specific guarantees (what is and isn't guaranteed, why FrameReader stays on the main thread), see Threading and Timing Guarantees. This page is for advanced users, plugin authors, and anyone debugging throughput, latency, or timing problems.
The hotpath is the chain of components that runs once per received frame at full data rate. Serial Studio targets sustained data rates above 256 kHz, so every stage on this path avoids allocations, copies, and cross-thread context switches.
flowchart TD
A["Driver
(driver thread or main)"] -->|CapturedDataPtr| B["FrameReader
(main thread)"]
B -->|enqueue| Q["Lock-free queue
65536 slots"]
Q -->|dequeue| C["DeviceManager
(main thread)"]
C -->|frameReady
DirectConnection| D["ConnectionManager
(main thread)"]
D --> E["FrameBuilder
(main thread)"]
E -->|TimestampedFramePtr| F["Dashboard"]
E -->|TimestampedFramePtr| G["CSV / MDF4 / API / gRPC / Sessions / MQTT"]
Each arrow is either a direct in-thread call or a Qt::DirectConnection signal. Same-thread
queued connections are avoided on this path: at 10 kHz they fill Qt's event queue faster than
the consumer can drain it, and the FrameReader's bounded queue starts dropping frames.
Drivers wrap one transport each (UART, TCP/UDP, BLE, Audio, Modbus, CAN Bus, MQTT, USB, HID,
Process I/O). They publish data through HAL_Driver::publishReceivedData(...), which carries
a shared IO::CapturedData payload:
data (QByteArray; Qt's copy-on-write means consumers get an atomic refcount bump, not
a deep copy)timestamp (steady-clock time of acquisition)frameStep (cadence in nanoseconds, when the driver knows it)logicalFramesHint (how many logical frames are encoded in the chunk, when known)Drivers that already know their cadence fill frameStep so downstream stages can fan out
timestamps without re-measuring. The audio driver, for example, backdates the chunk start by
step * (totalFrames - 1) so each parsed sample lines up with the moment it was captured,
not the moment Qt happened to deliver it.
If a driver posts to the main thread (via QMetaObject::invokeMethod or a queued connection),
it must capture SteadyClock::now() before queueing and pass that into
publishReceivedData. A default-constructed timestamp would otherwise be filled in on the
receiving thread and report fictional timing.
IO::FrameReader runs on the main thread and owns a single producer / single consumer
CircularBuffer. It scans the buffer for frame boundaries (start delimiter, end delimiter,
both, or none) and pulls out one logical frame at a time.
CircularBuffer::findFirstOfPatterns(), a single-pass scan with
a stack-allocated PatInfo array of up to eight patterns; it never touches the heap.XOR-8, MOD-256, CRC-8, CRC-16, CRC-16-MODBUS,
CRC-16-CCITT, Fletcher-16, CRC-32, Adler-32) runs immediately after extraction.Each completed frame is enqueued into a lock-free
moodycamel::ReaderWriterQueue<CapturedDataPtr> with 65536 slots. When that queue is full,
frames are dropped and a log line is emitted. That message is the canonical signal that a
downstream stage is too slow.
The frame reader is configuration-immutable. To change delimiters, decoder, or checksum mode,
the live FrameReader is destroyed and a new one is created via
ConnectionManager::resetFrameReader() or DeviceManager::reconfigure(). There are no
mutexes anywhere on this path.
DeviceManager::onReadyRead drains the queue and emits frameReady(deviceId, frame) per
dequeued frame, using Qt::DirectConnection so it lands in
ConnectionManager::onFrameReady as a normal function call. ConnectionManager then routes
the frame into FrameBuilder::hotpathRxFrame or, for multi-source projects,
hotpathRxSourceFrame(sourceId, data).
FrameBuilder is where the project's parsing rules turn raw bytes into a populated Frame
object. It runs three things in order:
parse() expects.parse(frame) in Lua or JavaScript) returns an array of values. See
Frame Parser Scripting for the full API.transform(value)) are called in group then dataset order. A
transform can read raw values from any dataset and final values from datasets earlier in
the order, plus shared registers from Data Tables. See
Dataset Value Transforms.In Quick Plot mode, steps 1 and 2 are replaced by a built-in line splitter that treats commas
as the field separator. In Console-Only mode, the FrameBuilder hotpath is a no-op: bytes go
straight to the terminal via DeviceManager::rawDataReceived.
When a single captured chunk expands into N logical frames, FrameBuilder publishes them at
data->timestamp + step * i, so a dropped or coalesced read on the driver side does not
collapse all the frames into a single instant.
The parser and transforms are the only points on the hotpath where user code runs. Both run
under a runtime watchdog and are wrapped so that a thrown error, infinite loop, or non-finite
return value falls back to the safe path: the raw value, or an empty frame. Errors do not
interrupt the data stream. Transform watchdogs use a 100 ms budget; for JavaScript transforms
the budget is armed once per frame in applyDatasetValues and covers all of that frame's
transforms collectively, not per dataset call.
Transforms that don't declare an info parameter (function transform(value)) pay no extra
cost for it: the engine inspects each transform's parameter count at compile time and skips
building the info table or object when it isn't used.
base, table, string, math, utf8, and
coroutine loaded. JavaScript uses Qt's QJSEngine with the Console and GC extensions only.local declarations become upvalues of the transform closure, so each
dataset has its own state on the shared Lua state.var
declarations are private to that dataset's closure on the shared QJSEngine.Two datasets that copy the same EMA template will not clobber each other's state in either language.
FrameBuilder produces exactly one TimestampedFramePtr per parsed frame in
hotpathTxFrame. The dashboard receives that pooled pointer directly. The asynchronous
sinks receive it through a single detached copy, made once per frame and only when at least
one sink is active (cached in m_anyAsyncSink):
ENABLE_GRPC),The dashboard never copies the frame; the detached copy exists so a slow export backlog cannot pin the slot pool. Export workers run on dedicated threads and consume from lock-free queues, so writing to disk or the network never blocks the dashboard.
The driver owns time. Every component downstream of the driver propagates the timestamp
attached to the captured chunk; nothing on the hotpath calls steady_clock::now() to stamp a
frame after the fact.
IO::CapturedData carries the chunk timestamp.FrameReader::frameTimestamp(endOffsetExclusive) walks pending chunks to assign each
extracted logical frame the correct moment, advancing the per-chunk clock by frameStep.FrameBuilder interpolates timestamps when one chunk expands into multiple frames.FrameConsumerWorkerBase::monotonicFrameNs(frame->timestamp, baseline). That helper is a
safety net against same-nanosecond collisions on coarse clocks (Windows steady_clock has
about 15 ms resolution); it is not the source of truth.If timing looks wrong on a chart or in an export, work from left to right: driver stamp,
CapturedData propagation, FrameReader split, FrameBuilder fan-out, and only then the
export or report. Patching a downstream stage to "fix" timing usually masks an earlier bug.
The hotpath is designed around three rules:
FrameBuilder reuses one Frame per source and draws
each TimestampedFramePtr from a fixed-size slot pool (acquireFrame) as an aliasing
shared_ptr that shares the slot's control block (no deleter, no per-frame control block);
a slot is recycled when its use_count drops back to 1, detected by a probe at the next
acquireFrame.
Parser engines are compiled once, CircularBuffer and lock-free queues are pre-sized,
and per-source transform engines are looked up once per source switch (not per dataset).TimestampedFramePtr
directly. The only frame copy is the single detached one made for the asynchronous export
sinks, and only when one is active (gated on m_anyAsyncSink), so a slow sink can't pin
the slot pool.The cost per frame is dominated by:
parse), which the user controls.To measure this pipeline end-to-end on your own hardware, the Benchmark Dialog
(About > Benchmark) drives the real FrameReader -> FrameBuilder -> consumer chain and
reports sustained frames/second per stage and per parser language, gated against the targets
above. The same engine runs headless for CI; see
Command-Line Interface.
The parser and transforms are the user-visible parts of the hotpath. Everything else is fixed; the parser and transforms are where you add custom protocol logic and signal conditioning without rebuilding the application.
flowchart LR
R["Raw bytes
from FrameReader"] --> D["Decoder"]
D --> P["parse(frame)
Lua or JavaScript"]
P --> V["Raw values array"]
V --> T1["Dataset 1
transform(value)"]
V --> T2["Dataset 2
transform(value)"]
V --> T3["Dataset N
transform(value)"]
T1 --> F["Final frame
(shared with all consumers)"]
T2 --> F
T3 --> F
A few things to keep in mind:
local in Lua,
top-level var/let in JavaScript) are still the lightest option.NaN,
Infinity) and errors fall back to the raw value silently.For the full parser and transform API, see Frame Parser Scripting and Dataset Value Transforms.
| Symptom | Where to look |
|---|---|
[FrameReader] Frame queue full -- frame dropped in the log | A downstream consumer is too slow. Check parser and transform CPU cost first; a transform that does HTTP calls or string-heavy work will saturate the path. |
| Timestamps drift or cluster | Driver stamping. Check that the driver fills frameStep and that any cross-thread post captures SteadyClock::now() before queueing. |
| Same-instant timestamps in CSV or the Session DB | Coarse clock granularity (often Windows). The export-side monotonic helper preserves order, but per-frame resolution is hardware-bound. |
| Parser appears to skip frames | Check the operation mode (Console-Only skips the parser by design) and the delimiter configuration. In multi-source projects, each source has its own parser engine; an error in one source does not affect the others. |
| Transform changes don't take effect | Transforms are compiled once when the project loads or the connection opens. Re-open the connection or reload the project. |
| High CPU but the dashboard is smooth | The bottleneck is upstream of the dashboard. Profile the parser and transforms; the dashboard refresh rate cap doesn't affect parser load. |
Treat these as load-bearing:
FrameReader runs on the main thread. Its configuration is immutable; recreate the reader
to change delimiters, decoder, or checksum.CircularBuffer is single-producer / single-consumer. Never make it MPMC.Dashboard is main-thread only. It receives the same TimestampedFramePtr that export
workers receive, and reads from frame->data directly.If you're writing a plugin or a new driver, follow the existing drivers (see BluetoothLE.h
and BluetoothLE.cpp as the canonical reference) and keep these invariants intact.
parse() reference.