Audience: Developers working on or integrating with the Pinpoint application
Location: src/Buffer/
Language: C++20
Status: Production — shipping on Linux, macOS and Windows; 8 test targets +
a manual latency benchmark
- What the Event Buffer Is
- Where It Fits in Pinpoint
- Core Concepts
- Getting Started — A Complete Example
- Registering Sources
- Producing Data (Capture Threads)
- The Buffer Lifecycle
- Consuming Data — Subscriptions
- The SwingWindow — Frozen Analysis View
- Diagnostics and Observability
- Configuration
- Internals — How It Works
- Platform-Specific Code and Why
- Common Mistakes
- File Map
The Pinpoint Event Buffer is a pre-allocated, lock-free, circular buffer that ingests raw device data from multiple asynchronous sources — cameras and IMUs — and presents it as a single unified, temporally-ordered stream.
It is designed around one specific requirement: capture the last 5 seconds of data continuously, then freeze it the instant a golf swing completes so analysis can read it without any copying, locking, or race conditions.
It is not a general-purpose message queue. It is not a database. It has no network layer and no serialisation format. It stores raw device bytes exactly as they arrived, tagged with a high-resolution timestamp, and keeps them available in a ring that overwrites from the oldest end.
┌──────────────────────────────────────────────────────────────────────┐
│ Pinpoint Application │
│ │
│ [CameraInstance] ──publish──► ┌─────────────────┐ │
│ [CameraInstance] ──publish──► │ EventBuffer │ │
│ [ImuInstance] ──publish──► │ (src/Buffer/) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────────────────────────────┤ │
│ │ │ │
│ [Subscription] [SwingWindow] │
│ (live consumers: (owned by ShotProcessor; │
│ UI preview, read concurrently by │
│ recorder) ShotAnalyzer + SwingExporter) │
└──────────────────────────────────────────────────────────────────────┘
- App launch:
EventBuffer::start()is called inmain.cppwith zero sources. The merger thread runs quietly, sleeping efficiently until devices are registered. - Device selection: The user selects cameras and IMUs from the UI.
CameraManager::setSelected()briefly pauses the buffer, callsregisterSource()(allocating ring memory once), then resumes. Ring memory stays allocated until the device is deselected — it is never freed between swings. - Capture: With sources registered, the buffer fills continuously. Camera frames and IMU packets are published into their respective rings. Old data is silently overwritten as rings wrap.
- Shot detection:
ShotControllerfiresshotDetected(ball-launch / acoustic / IMU evidence — seeshot_detector_developer_guide.md).ShotProcessorwaits out a per-source post-roll so the follow-through lands in the ring, then callsEventBuffer::pause()— producers stop writing, the merger quiesces, rings freeze. The last 5 seconds are intact. - Analysis:
ShotProcessorcallsEventBuffer::captureSwingWindow(4000ms), which returns aSwingWindow— a frozen, zero-copy view.ShotAnalyzerandSwingExporterthen read that one window concurrently from twoQtConcurrentworkers (bothconst, both zero-copy) — frames and IMU samples come straight from ring memory. - Dismiss: After the workers join and the optional ¼× replay finishes, the
SwingWindowis destroyed (ring memory stays allocated — only ring positions are reset), thenEventBuffer::resume()clears the positions and restarts capture for the next swing. - Device deselection: When the user deselects a device,
CameraManager::setSelected()pauses the buffer, callsderegisterSource()(freeing ring memory immediately), then resumes. - App exit:
aboutToQuitfiresEventBuffer::stop(), joining the merger thread.~EventBuffer()frees any remaining source rings.
The buffer is invisible to QML. Ownership splits three ways:
- The managers (
CameraManager,ImuManager) own the buffer state machine — pause/resume sequencing and the register/deregister dance on device selection. - The per-device instances (
CameraInstance,ImuInstance) own the data path: each registers its own source on selection, publishes raw bytes from its capture/BLE thread, and deregisters on deselection. ShotProcessor(src/Gui/shot/) owns theSwingWindowfor its entire lifetime. This migrated out ofCameraManager: every resume path now funnels throughCameraManager::resumeBuffer(), which refuses whileEventBuffer::swingWindowLive()is true, so the window cannot outlive the freeze that makes it valid.
Note that the EventBuffer is a third, independent consumer of camera frames —
separate from both the pose/ball pipeline and the view fan-out (CameraInstance
publishes display frames to subscribed QVideoSinks; that path never touches the
ring). The BufferController exposes diagnostic properties to QML.
A SwingWindow is also produced without a live buffer: SwingDiskLoader
(src/Analysis/swing_reanalyzer.cpp) builds one from an exported swing folder so
offline re-analysis runs the identical analysis code. See §9.
Every device that writes into the buffer registers as a source and receives a
SourceId — a small integer used for all subsequent operations. A source has a
SourceDescriptor that describes the device type, format, and expected data rate.
Sources come and go with device selection (register on select, deregister on
deselect); the buffer is briefly paused around each change.
A descriptor also carries an identifier — the device serial or an opaque
stable device id. It is what keys session-lifetime statistics (§10) and what lets
a window reader attribute a payload to a physical device, so it survives
deregistration where SourceId does not.
Each source owns a SourceRing — a fixed-size, pre-allocated circular buffer
sized for that source's data rate and the configured window duration (default 5s).
Camera frames go in camera rings; IMU packets go in IMU rings. They are independent
— a fast IMU ring does not interact with a slow camera ring.
A separate, lightweight TimelineIndex holds ordered references to events across
all source rings. Each entry is 40 bytes: a timestamp, a source ID, and a sequence
number pointing back into the source ring. The merger thread builds this in
timestamp order. Consumers read from here, then fetch payloads from source rings on
demand.
A dedicated background thread continuously polls each source ring for new events,
sorts them by timestamp using a k-way merge, applies a 5ms reorder window to absorb
cross-source clock jitter, and appends ordered IndexEntry records to the
TimelineIndex.
After pause(), captureSwingWindow() returns a SwingWindow — a frozen view
of a time range. It holds a snapshot of IndexEntry records (tiny — 40 bytes each)
and serves payloads through a SwingPayloadSource that it owns.
The payload source is the pluggable bit. Two backings exist, and the same
concrete SwingWindow type is fed by either:
| Backing | Where | Payload access |
|---|---|---|
RingPayloadSource |
event_buffer.cpp, built by captureSwingWindow() |
Zero-copy from the frozen rings; seqlock-validated. Valid only while Paused. |
SwingDiskSource |
src/Analysis/swing_reanalyzer.cpp, built by SwingDiskLoader |
Streams an exported swing folder; one decoded frame per camera resident at a time, IMU held in RAM. Always valid. |
That indirection is why offline re-analysis of an exported swing runs the same analysis stages as live capture rather than a parallel code path.
This example shows the full lifecycle from a C++ perspective.
#include "event_buffer.h"
#include "source_descriptor.h"
#include "format_descriptor.h"
// ── 1. Create the buffer (lives in main.cpp alongside other controllers) ──
pinpoint::EventBuffer buffer;
// ── 2. Register sources (before start()) ──────────────────────────────────
// Camera source
pinpoint::SourceDescriptor camDesc;
camDesc.name = "left_camera";
camDesc.identifier = "SN-4412093"; // device serial — keys lifetime stats
camDesc.window_duration = std::chrono::milliseconds(5000);
camDesc.expected_interarrival_us = std::chrono::microseconds(33333); // 30fps
pinpoint::CameraFormat camFmt{};
camFmt.pixel_format = pinpoint::PixelFormat::Mono8;
camFmt.width = 1920;
camFmt.height = 1080;
camFmt.fps_numerator = 30;
camFmt.fps_denominator = 1;
camFmt.max_payload_bytes = 1920 * 1080; // Mono8
camFmt.typical_payload_bytes = 1920 * 1080;
camFmt.plane_strides[0] = 1920; // bytes per row — NOT assumed from width
camDesc.format.device = pinpoint::DeviceKind::Camera_UVC;
camDesc.format.device_serial = camDesc.identifier;
camDesc.format.format = camFmt;
pinpoint::SourceId camId = buffer.registerSource(camDesc);
// IMU source
pinpoint::SourceDescriptor imuDesc;
imuDesc.name = "wt9011dcl_ble";
imuDesc.identifier = "C4:AC:59:11:22:33";
imuDesc.window_duration = std::chrono::milliseconds(5000);
imuDesc.expected_interarrival_us = std::chrono::microseconds(10000); // 100Hz
// IMU sources store DECODED samples, not raw device packets — the driver parses
// on the BLE thread and publishes a fixed-layout ImuSample (see §9).
pinpoint::ImuFormat imuFmt{};
imuFmt.device = pinpoint::DeviceKind::IMU_WitMotion;
imuFmt.sample_rate_hz = 100;
imuFmt.packet_bytes = sizeof(pinpoint::ImuSample); // 40
imuFmt.packet_schema = "imu_sample_v2";
imuDesc.format.device = pinpoint::DeviceKind::IMU_WitMotion;
imuDesc.format.format = imuFmt;
pinpoint::SourceId imuId = buffer.registerSource(imuDesc);
// ── 3. Start capture ──────────────────────────────────────────────────────
buffer.start(); // Idle → Capturing; launches merger thread
// ── 4. Producers publish from their capture threads ───────────────────────
// (This runs on a capture thread, not the main thread)
void onCameraFrame(const uint8_t* data, size_t len) {
auto slot = buffer.acquireWriteSlot(camId);
if (!slot.valid) return; // buffer is paused — discard
std::memcpy(slot.data, data, len);
*slot.bytes_written = len;
*slot.timestamp_us = pinpoint::EventBuffer::nowMicros();
buffer.publish(camId, slot.sequence);
}
// ── 5. A consumer subscribes and reads events ─────────────────────────────
auto sub = buffer.subscribe();
pinpoint::IndexEntry entry;
while (sub.waitNext(entry, std::chrono::milliseconds(100))) {
if (entry.flags & pinpoint::IndexEntryFlags::SourceStalled) {
// A device went silent — handle accordingly
continue;
}
auto handle = buffer.acquireReadHandle(entry.source_id, entry.source_sequence);
if (!handle.data) continue; // slot was overrun before we got here
// use handle.data / handle.bytes ...
if (!handle.validate(...)) continue; // verify no overwrite during read
}
// ── 6. Swing detected — freeze the buffer ────────────────────────────────
buffer.pause(); // Capturing → Paused
// ── 7. Extract and analyse the swing ─────────────────────────────────────
auto window = buffer.captureSwingWindow(std::chrono::milliseconds(4000));
for (const auto& e : window.entries()) {
auto handle = window.payloadOf(e);
// handle.data / handle.bytes — zero copy, guaranteed valid
// while window is in scope and buffer is Paused
}
// ── 8. Resume for next swing ──────────────────────────────────────────────
// Destroy window first (best practice — payload access after resume undefined)
window = {}; // or let it go out of scope
buffer.resume(); // Paused → Capturing; rings cleared, fresh start
// ── 9. Stop at session end ────────────────────────────────────────────────
buffer.stop(); // → Idle; merger thread joinedRegistration is callable while Idle or Paused. The buffer does not need to be
Idle — device selection can happen at any point as long as the buffer is paused first.
CameraManager::setSelected() handles this automatically.
Memory is allocated at registration and freed at deregistration — never between
swings. This is deliberate: GB-scale ring allocations are expensive, and some operating
systems do not fully return freed memory to the system on free(). Keep rings warm.
// Deregister a source and immediately free its ring memory.
// ONLY callable while Paused.
// You MUST destroy any live SwingWindow before calling this.
// After this call 'id' is invalid — further calls with it are safe no-ops.
buffer.deregisterSource(id);This is called automatically by CameraInstance::deregisterFromBuffer() when
CameraManager::setSelected(index, false) is called.
size_t n = buffer.activeSourceCount(); // count of non-deregistered sources| Field | Type | Purpose |
|---|---|---|
name |
std::string |
Human-readable name for diagnostics |
identifier |
std::string |
Device serial or opaque stable device id. Keys session-lifetime stats (§10) — survives deregistration. |
window_duration |
chrono::milliseconds |
How much history to retain (default 5000ms) |
expected_interarrival_us |
chrono::microseconds |
Used by liveness watchdog to detect stalls |
format.device |
DeviceKind |
Camera, IMU, or Marker_App — see types.h |
format.device_serial |
std::string |
Physical-device attribution for window readers. registerSource() falls back to identifier if this is empty. |
format.format |
CameraFormat or ImuFormat |
Format-specific fields — see below |
sync_source |
SyncSource |
Timestamping mode (always SoftwareTimestamp in v1) |
sync_group |
optional<string> |
Hardware sync group (placeholder, unused in v1) |
DeviceKind::Marker_App is not a device: it registers a source the application
publishes into, so app-generated events (e.g. a shot-impact marker) land in the
same timeline as device data and are ordered against it by the merger.
| Field | Purpose |
|---|---|
pixel_format |
PixelFormat enum — e.g. Mono8, BayerRG8, YUV422. pixel_format_names.h maps it to/from the canonical swing.json string. |
width, height |
Frame dimensions in pixels |
fps_numerator, fps_denominator |
Frame rate as a fraction |
max_payload_bytes |
Critical: slots are sized to this. Must be ≥ largest frame you will ever publish. If a frame exceeds this, it is clamped and logged. |
typical_payload_bytes |
Used for diagnostics only |
plane_strides |
array<uint32_t,4> — bytes per row per plane. Do not derive stride from width: drivers pad rows, and a decoder that assumes width bytes shears the image. Set it from what the driver reports. |
exposure_us |
Per-stream exposure in microseconds; 0 = unknown. Motion-blur-aware stages read it. |
exposure_source |
ExposureSource provenance — Measured (read from the frame, manual exposure), MeasuredAuto (read from the frame, auto-exposure active so it varies), Derived (computed as 1/(2·fps) — webcams that report nothing), Unknown. Round-trips through swing.json via exposureSourceName() / exposureSourceFromName(). |
| Field | Purpose |
|---|---|
device |
DeviceKind enum |
sample_rate_hz |
Nominal sample rate — informs watchdog threshold |
packet_bytes |
Fixed payload size per event. IMU sources publish decoded ImuSample records, so this is sizeof(ImuSample) (40), not the on-the-wire BLE packet size. |
packet_schema |
Opaque string identifying the payload layout. Currently "imu_sample_v2" — see imu_sample.h. |
The ring for each source is allocated with next_power_of_2(ceil(rate × window_seconds))
slots, each max_payload_bytes in size. For a 30fps camera with 1080p Mono8 frames
over a 5s window:
slots = next_power_of_2(ceil(30 × 5)) = next_power_of_2(150) = 256
bytes = 256 × (1920 × 1080) = 256 × 2,073,600 ≈ 531 MB per camera
Register with accurate dimensions — over-estimating fps_numerator doubles the
slot count and wastes memory. The Pinpoint integration calls
CameraInstance::updateBufferDescriptor() once the camera starts and reports its
actual format, so registrations use real values rather than guesses.
Every capture thread follows this protocol exactly:
void onDeviceDataReady(/* device callback args */) {
// Step 1: Timestamp FIRST — before any other work
int64_t ts = pinpoint::EventBuffer::nowMicros();
// Step 2: Acquire a slot — never blocks, never fails
auto slot = buffer.acquireWriteSlot(sourceId);
// Step 3: Check valid — false when buffer is paused
if (!slot.valid) return;
// Step 4: Write payload directly into slot.data
std::memcpy(slot.data, deviceBytes, byteCount);
*slot.bytes_written = byteCount;
*slot.timestamp_us = ts;
// Step 5: Publish — validates, updates stats, releases to readers
buffer.publish(sourceId, slot.sequence);
}You MUST:
- Call
nowMicros()as the absolute first line - Check
slot.validbefore writing - Fill both
bytes_writtenandtimestamp_usbefore callingpublish() - Call
publish()even for partial/short reads — record the actual size - Use only
EventBuffer::nowMicros()— no other clock source, ever
You MUST NOT:
- Allocate memory between acquire and publish
- Copy data through an intermediate buffer before the slot
- Hold any pointer to
slot.dataafter callingpublish()— the slot may be overwritten by the nextacquireWriteSlot()call on the same ring - Log synchronously inside the callback
- Call any lifecycle method (
start,pause,resume,stop) from a capture thread
If the buffer is paused, slot.valid is false and no ring state is modified —
the call returns immediately. If the ring is full (write head has lapped the read
head), the oldest slot is silently overwritten. This is the drop-oldest circular
behaviour: producers are never stalled or slowed by consumers falling behind.
EventBuffer::nowMicros() returns microseconds since an arbitrary epoch using the
platform's highest-resolution monotonic clock:
- Linux / Android:
clock_gettime(CLOCK_MONOTONIC)— ~20ns resolution - Windows:
QueryPerformanceCounter— ~100ns resolution - macOS / iOS:
mach_absolute_time— ~40ns resolution
It is lock-free, thread-safe, and never goes backwards. Every timestamp in the
system uses this single function. Do not use std::chrono::system_clock,
QDateTime::currentMSecsSinceEpoch(), or any other time source — mixing clock
sources corrupts the timeline ordering.
The buffer has four states. Transitions that are not listed are invalid and will assert in debug builds.
v6 model: start() and stop() are app-level operations called once each
(in main.cpp). pause() and resume() are the capture gate — called
automatically by swing/ball detection. Device selection calls
registerSource()/deregisterSource() with a brief internal pause/resume.
[constructed]
│
start()
│
▼
CAPTURING ◄──────── resume()
│
pause()
│
▼
PAUSED ──── captureSwingWindow() (analysis here)
│
stop() ◄─── also callable from CAPTURING
│
▼
IDLE
│
start() (next swing session)
Launches the merger thread. Called once in main.cpp before any controllers are
constructed. The merger applies ThreadPolicy::apply(ThreadRole::Merger) at entry
to elevate its scheduling priority (see Section 13), then invokes the optional
thread-register hook (see below).
start() is valid with zero registered sources, and that is the normal case at
app launch. With no sources it enters Paused, not Capturing, and sets an
internal no_source_paused_ flag — the merger parks rather than spinning over an
empty source set:
buffer.isWaitingForSources(); // true: Paused only because nothing is registeredUse this to distinguish "waiting for the first device" from a deliberate
swing-analysis pause — they are the same BufferState::Paused otherwise. The flag
is set by start() with no sources, by deregisterSource() when the last source
goes, and by resume() when it declines (see below). It clears on a successful
resume().
The GUI closes the loop rather than the buffer auto-resuming itself:
CameraManager::applyCaptureIntent() is called after every register/deregister and
funnels through resumeBuffer(), which is a no-op unless the buffer is Paused and
becomes effective once a source exists.
EventBuffer deliberately does not know about the app's resource profiler — the
standalone Buffer tests build without it. Instead the app injects a hook, called
once from the merger thread at loop entry:
// src/Gui/main.cpp
eventBuffer.setThreadRegisterHook([](const char *name) {
pinpoint::osmetrics::registerThread(name); // name == "Buffer.Merger"
});
eventBuffer.start(); // set the hook BEFORE start()Set it before start(); it fires exactly once. See
resource_profiler_developer_guide.md for what registration buys you.
- Sets
capturing_ = false— producers seeslot.valid = falseon their next callback and discard it. No blocking on the capture threads. - Signals the merger to drain: the merger emits all pending events from its
reorder heap into the index, then parks untimed on
control_wait_(see §13.1a) — it consumes no CPU while paused, waking only onresume()/stop(). On Windows, entering the park also drops the global 1ms timer resolution back to the system default (see §13.1b). - Waits up to
pause_drain_timeout_ms(default 20ms) for the merger to confirm it has drained. Logs a warning if this times out. - Rings are now frozen — no writes, no overwrites. Safe to read from any thread.
- DMA queues remain registered. Cameras keep running at the OS level; callbacks
fire and return immediately (discarded via
slot.valid). This avoids camera re-initialisation latency onresume().
- Refuses if no sources are registered. It stays Paused, sets
no_source_paused_, and returns — there is nothing to capture, and spinning the merger over an empty source set is pure waste. - If
resume_clear_ringsis true (default): folds each ring's counters into the session-lifetime totals (§10) before resetting the ring's write position to zero, then clears theTimelineIndex. The next swing gets a clean slate without the resource monitor losing every count on each pause→resume cycle. - Sets
capturing_ = true— producers begin writing immediately on the next callback. - Wakes the merger out of its paused park: bumps
control_wait_and notifies — done last, after the state transition is fully committed, so the woken merger never observes a torn mid-resume view. On its next normal-path iteration the merger re-raises the Windows 1ms timer resolution (see §13.1b).
Existing Subscription instances see a gap in sequence numbers after resume().
This is expected — subscriptions that span a resume are invalidated. Live consumers
(UI preview) should call resetToLatest() after resume.
If Capturing, internally calls pause() first. Then clears running_ and wakes
the merger — bumping control_wait_ (in case it is parked on the pause gate) as
well as index_wait_ (the normal-path cold timeout) — and joins its thread. On
exit the merger balances any outstanding Windows timeBeginPeriod (see §13.1b).
The buffer returns to Idle. Called once on app exit via
QObject::connect(&app, &QGuiApplication::aboutToQuit, ...) in main.cpp.
~EventBuffer() calls stop() as a safety net.
buffer.state(); // returns BufferState enum
buffer.isCapturing(); // convenience — true only in Capturing state
buffer.isWaitingForSources(); // Paused only because nothing is registered
buffer.swingWindowLive(); // a SwingWindow captured from this buffer is aliveAll are safe to call from any thread. swingWindowLive() is the app-wide resume
gate: CameraManager::resumeBuffer() returns early while it is true, so no path —
including QML — can unfreeze the rings out from under a window the shot workers
are still reading.
A Subscription gives a consumer a cursor into the TimelineIndex. Events arrive
in timestamp order across all sources.
auto sub = buffer.subscribe();
pinpoint::IndexEntry entry;
// Blocking read with timeout
while (sub.waitNext(entry, std::chrono::milliseconds(100))) {
if (entry.flags & pinpoint::IndexEntryFlags::SourceStalled) {
// Handle stall marker — see Section 10
continue;
}
// Zero-copy payload access
auto handle = buffer.acquireReadHandle(entry.source_id, entry.source_sequence);
if (!handle.data) continue; // slot was overrun before we got here
// Read handle.data / handle.bytes
// ...
// Validate AFTER consuming — if false, data was overwritten mid-read
// This is rare with a 5s window but correct code must handle it
if (!handle.validate(buffer.ringFor(entry.source_id)))
continue;
}
// Non-blocking read
if (sub.tryNext(entry)) { /* ... */ }
// Skip-ahead after resume or overrun
sub.resetToLatest();
// How many events were overwritten since last read
uint64_t missed = sub.overrunsSinceLastRead();Consumer-visible latency has two components:
-
Merger response — producer publish → event visible in
TimelineIndex. Measured p50: ~4µs on Linux/Windows, ~5µs on macOS. This is the time frompublish()to the merger waking and processing the event. -
Reorder window delay — events are held back by 5ms (configurable) before emission to absorb cross-source jitter. Additionally, an event from a fast source (e.g. 200Hz IMU) cannot be emitted until all sources have advanced past its timestamp. With a 60fps camera (16.7ms interval), the end-to-end visible latency floor for IMU events is approximately:
floor = reorder_window + 0.5 × slowest_source_interval = 5ms + 0.5 × 16.7ms ≈ 13msThis is correct by design. For live preview (~15ms behind real-time) it is imperceptible. For post-swing analysis via
SwingWindow, latency is irrelevant — the buffer is frozen and all data is immediately available.
SwingWindow is the primary interface for post-swing analysis. A window captured
from a buffer can only be constructed while that buffer is Paused.
buffer.pause();
// Last 4 seconds (covers backswing + downswing + follow-through)
auto window = buffer.captureSwingWindow(std::chrono::milliseconds(4000));
// Or explicit time range
auto window = buffer.captureSwingWindow(t_start_us, t_end_us);// All events in timestamp order across all sources
std::span<const pinpoint::IndexEntry> entries = window.entries();
// Events for a specific source only
std::vector<pinpoint::IndexEntry> camEntries = window.entriesFor(camId);
// Convenience counts
size_t nFrames = window.frameCount(camId);
size_t nSamples = window.imuSampleCount(imuId);
// Zero-copy payload access — valid for the window's lifetime
pinpoint::SourceRing::ReadHandle h = window.payloadOf(entry);
// h.data points into frozen ring memory — no copy
// h.bytes is the actual payload size
// Format metadata for a source — pixel format, dimensions, plane strides,
// exposure and its provenance. Read this rather than assuming anything about
// the payload layout; in particular NEVER assume stride == width.
const pinpoint::FormatDescriptor& fmt = window.formatOf(camId);// Get interpolated IMU state at an arbitrary timestamp
// (e.g. the exact moment a camera frame was captured)
pinpoint::ImuSample s{};
bool ok = window.interpolateImu(imuId, frame_timestamp_us,
reinterpret_cast<std::byte*>(&s), sizeof(s));out_bytes must equal sizeof(ImuSample) (40) — the window interpolates decoded
samples, not raw device packets. Accel and gyro are lerped componentwise; the
orientation quaternion is slerped along the shortest arc, never interpolated
as Euler angles. All values are in the raw sensor body frame (schema
imu_sample_v2) — see imu_sample.h and docs/design/imu_frame_contract.md.
SwingWindow's public constructor takes a std::unique_ptr<const SwingPayloadSource>, so anything that can serve (source_id, sequence) → bytes
plus a FormatDescriptor can back one:
class MySource final : public pinpoint::SwingPayloadSource {
pinpoint::SourceRing::ReadHandle payloadOf(SourceId, uint64_t) const noexcept override;
const pinpoint::FormatDescriptor& formatOf(SourceId) const noexcept override;
bool validate(SourceId, const pinpoint::SourceRing::ReadHandle&) const noexcept override;
};
pinpoint::SwingWindow w(std::make_unique<MySource>(), std::move(entries), t0, t1);The contract on payloadOf() is deliberately weak so a streaming backing is
possible: the bytes returned for a source need only stay valid until the next
payloadOf() call on that same source id. Analysis stages read camera frames
strictly one at a time, so a single reusable buffer per source satisfies it —
which is exactly how SwingDiskSource keeps one decoded frame per camera resident
instead of the whole swing.
validate() is the ring-only concern: RingPayloadSource re-checks the seqlock
generation against the live ring, while a disk/RAM backing returns true
unconditionally because its bytes cannot be recycled underneath the reader.
- For a ring-backed window:
window.entries()andwindow.payloadOf()are valid while the buffer isPausedand theSwingWindowobject is alive. A disk-backed window has no such coupling. - The resume/deregister guard (
swingWindowLive()) is owned byRingPayloadSource, set in its constructor and cleared in its destructor — i.e. held for the window's whole lifetime, moves included.CameraManager::resumeBuffer()refuses while it is set, so in practice you cannot resume out from under a live window; the app destroys it inShotProcessor::finishShot()and only then re-applies the capture intent. SwingWindowis non-copyable but movable. The move is defaulted — ownership of the payload source (and therefore of the guard) transfers with it, with no special-casing.- Destroying a
SwingWindowdoes not resume the buffer.resume()must be called explicitly when the application is ready for the next swing. - Two workers may read one window concurrently. Every accessor is
constand zero-copy;ShotAnalyzerandSwingExporterdo exactly this.
captureSwingWindow() allocates a std::vector<IndexEntry> for the index
snapshot. At 600 events/second aggregate (IMU + camera) over 4 seconds, this is
approximately 96 KB — negligible. Frame payloads are not copied — they are
read directly from the ring.
Every source ring maintains counters updated on the hot path:
const pinpoint::SourceStats& stats = buffer.statsFor(sourceId);
stats.events_written.load(); // total events published
stats.events_overwritten.load(); // slots overwritten before merger drained them
stats.bytes_written_total.load(); // total bytes stored
stats.last_write_timestamp_us.load();// timestamp of most recent event
stats.max_inter_arrival_us.load(); // largest gap between consecutive events
stats.bounds_violations.load(); // publishes where bytes_written > slot capacity
stats.monotonicity_violations.load();// non-monotonic timestamps, clamped by mergerThe merger thread checks each source every 10ms. A source is declared stalled once it has been silent for
max(expected_interarrival_us × stall_threshold_mult, stall_threshold_min_us)
i.e. 5 × its expected interval, but never less than 1 second. That floor
matters: at 240fps the rate-derived threshold is ~21ms, and ordinary BLE/USB
delivery hiccups well under a second are normal and self-heal — without the floor
the timeline would fill with stall markers that mean nothing. When a source does
trip the threshold, a marker is emitted into the TimelineIndex:
// In a consumer loop:
if (entry.flags & pinpoint::IndexEntryFlags::SourceStalled) {
qWarning() << "Source stalled:" << entry.source_id;
}
// Query current stalled sources:
std::vector<pinpoint::SourceId> stalled = buffer.stalledSources();The stall clears automatically when the source resumes writing.
Two consumers poll this. BufferController (src/Gui/cameras/) calls it on a 5s
timer, logs a line per source (at warn level when stalled), and republishes it to
QML as state / sources / totalEvents. ResourceMonitorController
(src/Gui/monitor/) takes the same snapshot for the resource-monitor screen — that
is where the lifetime totals are actually surfaced.
pinpoint::EventBuffer::DiagnosticsSnapshot snap = buffer.diagnostics();
snap.state; // current BufferState
snap.timeline_entries; // total events in TimelineIndex
snap.snapshot_timestamp_us; // when this snapshot was taken
for (const auto& src : snap.sources) { // currently-registered sources
src.id;
src.name; // source name string
src.identifier; // stable device id — keys the lifetime totals
src.slot_count;
src.events_written; // since the last ring reset (i.e. last resume)
src.events_overwritten;
src.bytes_written_total;
src.stalled;
// Session-lifetime totals: folded history + the current ring's counters.
// These SURVIVE the ring resets that pause→resume performs.
src.lifetime_events_written;
src.lifetime_events_overwritten;
src.lifetime_bytes_written;
}
for (const auto& lt : snap.lifetime) { // every source seen this session,
lt.identifier; // INCLUDING deregistered ones
lt.events_written;
lt.events_overwritten;
lt.bytes_written;
}This is the distinction to get right when reading diagnostics. The SourceStats
counters (and the matching SourceInfo fields) are per capture session: a
default resume() clears the rings, so they restart from zero on every
pause→resume — which, with swing replay, is once per shot. Before clearing,
resume() folds them into lifetime_, keyed by SourceDescriptor::identifier.
deregisterSource() folds too, so a device that has since been deselected still
appears in snap.lifetime.
So: use events_written to ask "how is this device doing right now"; use
lifetime_events_written or snap.lifetime to ask "how much has this device
delivered this session". The lifetime_ map is mutated only on the control plane
(resume() / deregisterSource(), both serialised with the buffer Paused) and
never touched on the producer hot path.
| Symptom | Likely cause | Action |
|---|---|---|
events_overwritten > 0 |
Consumer too slow, or ring undersized | Check consumer latency; increase window_duration or reduce capture rate |
bounds_violations > 0 |
Frame larger than max_payload_bytes |
Increase max_payload_bytes in SourceDescriptor |
monotonicity_violations > 0 |
Device sending non-monotonic timestamps | Informational only — merger clamps and continues |
| Source stalled, expected | IMU not connected in this session | Normal — watchdog only fires if last_write_timestamp_us > 0 |
| Source stalled, unexpected | Cable unplugged, BLE dropped | Application should alert the user |
EventBufferConfig is passed to the EventBuffer constructor. All fields have
defaults that work for the standard Pinpoint deployment.
pinpoint::EventBufferConfig config;
config.reorder_window_us = 5000; // 5ms cross-source ordering window
config.watchdog_interval_ms = 10; // how often stall detection runs
config.stall_threshold_mult = 5; // stall if silent for 5 × interarrival
config.stall_threshold_min_us = 1000000; // ...but never sooner than 1s (see §10)
config.timeline_index_capacity = 8192; // power of 2; covers ~10s at 500 events/s
config.merger_spin_iterations = 64; // ~10µs hot-path spinning before sleeping
config.merger_cold_timeout_us = 500; // yield budget per merger iteration (macOS)
// or sleep cap (Linux/Windows)
config.pause_drain_timeout_ms = 20; // max wait for merger to quiesce on pause()
config.resume_clear_rings = true; // clear rings on resume() for clean swing start
config.cpu_affinity_enabled = false;// pin merger to core 1 (desktop Linux/Windows only)
pinpoint::EventBuffer buffer(config);Tuning advice:
stall_threshold_min_us: the floor exists because the rate-derived threshold is absurdly tight at high frame rates (~21ms at 240fps). Lower it only if you genuinely need sub-second stall reporting and can tolerate the false positives.reorder_window_us: increase if sources have high clock jitter (>5ms) and events arrive out of order. Increases end-to-end latency by the same amount.resume_clear_rings: setfalseif you wantSwingWindowdata to persist across resume for a multi-swing comparison feature. You are then responsible for ensuring consumers do not read stale data.cpu_affinity_enabled: only meaningful on Linux and Windows desktops with ≥4 cores. Reduces merger jitter by preventing OS migration to low-power cores.
This section explains the implementation for anyone modifying the buffer or debugging subtle issues. Understanding the internals is not required for normal use.
The core primitive that makes the ring lock-free is the seqlock (sequence
lock). Each slot in the ring has a generation counter — a std::atomic<uint64_t>.
The protocol is:
Writer (producer):
- Increment generation to an odd number — this signals "write in progress".
- Write the payload bytes and header fields (timestamp, bytes_written).
- Increment generation to the next even number with
memory_order_release. This makes all prior writes visible to any reader that sees the even generation.
Reader (consumer):
- Load the generation counter with
memory_order_acquire. If odd, the slot is being written — skip or retry. - Read the payload.
- Re-read the generation.
- If the generation changed between steps 1 and 3, the slot was overwritten during the read — the data is potentially torn. Discard and skip forward.
This is correct even on ARM (which has a weaker memory model than x86) because the
acquire/release pairing creates the necessary happens-before relationships. The
compiler and CPU cannot reorder reads before the acquire or writes after the release.
The loads and stores above are necessary but not sufficient, because acquire
and release on a load/store are one-way barriers, and in both directions the
seqlock needs the other way. Both fences compile to nothing on x86-TSO and are
the difference between working and silently-not on arm64.
Writer — release fence after publishing the odd generation (acquireWriteSlot()
and getSlotByIndex(), source_ring.cpp):
write_seq_.store(seq + 1, std::memory_order_release);
std::atomic_thread_fence(std::memory_order_release); // ← this
return WriteSlot{...};The store-release orders everything before it and stops nothing after it from
moving up. But the caller's payload stores happen after acquireWriteSlot()
returns — the marker is published first and the bytes it is supposed to cover come
later. Without the second fence those payload stores may become visible before
the gen=odd marker, and a consumer recycling the slot then copies half-written
bytes while the generation still looks stable: a torn read that validate() calls
clean.
Reader — acquire fence before re-reading the generation (ReadHandle::validate()):
std::atomic_thread_fence(std::memory_order_acquire); // ← this
uint64_t gen_after = hdr.generation.load(std::memory_order_relaxed);
return gen_after == generation_snapshot;An acquire load cannot supply this. Acquire stops later accesses from floating
up; what has to be pinned here is the payload copy that already happened —
copyBytesRacy()'s loads must not sink below the check meant to vet them. An
acquire fence is the barrier with that direction (no prior load may cross it), so
the generation is re-read strictly after the bytes were taken. Without it the CPU
may satisfy the copy late, from a slot the producer has since recycled, and
validate() certifies those bytes against a generation read before they were
fetched. Note the load itself is then relaxed — the fence is doing the ordering,
so an acquire load would be redundant.
If you touch the seqlock, these two fences are the part to leave alone.
There is a deliberate data race in the seqlock: the reader reads payload bytes at
the same time a writer might be writing to them (if the ring wraps). ThreadSanitizer
(TSAN) flags this race. This is benign — the generation check is the safety mechanism,
not the absence of concurrent access. Narrow TSAN suppressions in
src/Buffer/tests/tsan_suppressions.txt cover exactly and only this pattern.
Every hot atomic is on its own 64-byte cache line with alignas(64):
SourceRing::write_seq_— incremented by the producer on every publishSourceStatsstruct — updated on every publish and by the watchdog- Each
SlotHeader— read and written on every access
Without this alignment, two atomics that happen to share a cache line cause false sharing — a cache coherency storm where every write on one CPU invalidates the cache line on every other CPU holding it, even if they are accessing different variables. At 200Hz × 5 sources the savings are measurable.
SlotHeader is alignas(64) and padded to exactly 64 bytes:
struct alignas(64) SlotHeader {
std::atomic<uint64_t> generation{0}; // 8 bytes
int64_t timestamp_us{0}; // 8 bytes
uint32_t bytes_written{0}; // 4 bytes
uint8_t _pad[64 - 8 - 8 - 4]; // 44 bytes padding
};
static_assert(sizeof(SlotHeader) == 64);The payload bytes follow immediately after the header in the pre-allocated storage.
slot_stride_ = sizeof(SlotHeader) + slot_max_bytes_, rounded up to the next
multiple of 64, so each slot also begins on a cache line boundary.
The merger thread runs a k-way merge over all source ring tails. Because k ≤ 16 (the maximum number of sources), the merge uses a simple linear scan to find the minimum — which is faster than a heap for small N due to cache locality and no allocation.
The reorder window exists because sources do not share a hardware clock. USB cameras may timestamp a frame at the driver level a few milliseconds after the sensor read-out; BLE IMU packets arrive in bursts. Without a reorder window, two events with nearly the same real-world timestamp could be emitted in the wrong order if the IMU packet arrived first.
safe_emit_until = min(latest_observed_ts across all sources) - reorder_window_us
An event is only emitted when event_ts ≤ safe_emit_until. This means all
sources must have produced at least one event with a timestamp beyond the event's
timestamp plus the window before the event can be released. The cost is latency
(~13ms end-to-end for the default 5ms window with a 60fps camera).
When the merger finds write_seq - next > slot_count, the producer has lapped it
and the sequence it wanted is gone. The obvious recovery — skip to the current
write head — is wrong, and used to be what this did. The backlog
[write_seq - slot_count, write_seq) is published, still-valid data; jumping to
the head throws away a whole extra ring lap (seconds of frames) on top of what the
producer already overwrote. So the merger resumes at the oldest sequence still
resident:
next = ws_now - slot.ring->slotCount();Salvaging into a region the producer is still advancing through is safe because
peekTimestamp()'s seqlock and lap checks reject any sequence the producer reaches
while the merger drains — a salvaged slot that goes stale mid-recovery is simply
dropped again.
Without a signal mechanism, the merger would need to poll constantly or sleep on a
timer. Timer-based sleep on macOS has poor granularity (Darwin timer coalescing can
turn a 500µs sleep into 5–10ms). The solution: every EventBuffer::publish() call
increments an atomic counter source_published_ and calls notifyAll() on it.
The merger's cold path waits on this counter rather than on a timer, so it wakes
within microseconds of a publish rather than on the next scheduler tick.
This is what produces the measured p50 merger latency of ~4µs — the merger is event-driven, not polled.
The TimelineIndex uses the same seqlock pattern as SourceRing. The difference
is access pattern: SourceRing is SPSC (one producer, the merger as sole consumer
via peekTimestamp), while TimelineIndex is SPMC (merger writes, multiple
consumer subscriptions read). The seqlock handles both correctly because it is
purely a property of the slot's generation counter, not of queue topology.
The index capacity is a power of 2 (default 8192). At 600 events/second aggregate, this is ~13 seconds of history — more than the 5s window, providing headroom for slow consumers.
A ring-backed SwingWindow accesses slot memory directly with no reference
counting. This is safe because:
captureSwingWindow()is only callable whenstate() == Paused.- In Paused state,
capturing_ = false, soacquireWriteSlot()returnsvalid = false— no producer can write to any slot. - The merger is quiesced and not advancing the write head.
- Therefore, ring slots cannot be overwritten while the buffer is Paused.
SwingWindow::payloadOf()returns aReadHandlewith a generation snapshot. Even though overwrite cannot happen while Paused,validate()is still called after use — correct code should not rely on state invariants for correctness.
What keeps the buffer in Paused for the window's lifetime is the
swing_window_live_ flag, and note where it now lives: RingPayloadSource
sets it in its constructor and clears it in its destructor. That placement is the
whole trick. The flag used to be set/cleared by SwingWindow itself, which meant
every move constructor and move assignment had to hand the guard over by hand and
null out the source. Now the window's moves are = default: the guard rides along
with the unique_ptr<SwingPayloadSource>, is held until the final owner is
destroyed, and a disk-backed window simply never acquires one — because
SwingDiskSource has no buffer to freeze.
The problem: the merger's cold path needs to block efficiently until a producer
publishes. C++20 std::atomic::wait would be ideal, but it has two problems:
- No timeout in C++20 (timed waits are proposed for C++26).
- On Apple platforms, the implementation uses polling-with-exponential-backoff
rather than a native futex because
__ulock_wait(Darwin's futex equivalent) is a private API that causes App Store rejection.
The solution: WaitFlag in src/Buffer/wait_flag.cpp implements three paths:
Linux (PINPOINT_PLATFORM_LINUX):
Uses a spin loop with PINPOINT_CPU_PAUSE() (x86 PAUSE instruction or ARM
yield) followed by std::this_thread::sleep_for with a manual deadline check.
std::atomic::notify_all() is available as a wakeup primitive (it maps to the
Linux futex syscall). The result is a genuinely low-latency wait — typically wakes
within 10–50µs of notification.
Windows (PINPOINT_PLATFORM_WINDOWS):
Uses WaitOnAddress / WakeByAddressAll — the Windows equivalent of futex,
available since Windows 8 (Win10 1809 is our minimum). These are the same
primitives that underlie std::atomic::wait on MSVC, so behaviour is similar to
Linux.
macOS / iOS (PINPOINT_PLATFORM_APPLE):
Uses a hybrid spin+yield loop:
// Phase 1: tight spin (~2µs)
for (int i = 0; i < 32; ++i) { PINPOINT_CPU_PAUSE(); check(); }
// Phase 2: yield loop — avoids Darwin timer coalescing
while (now < deadline) { sched_yield(); check(); }sched_yield() gives up the current timeslice without requesting a timer wakeup.
It returns when the scheduler has no other runnable threads, typically 1–20µs.
This avoids the 5–10ms minimum latency of condition_variable::wait_for under
Darwin timer coalescing. The trade-off is slightly higher CPU usage during idle
periods — acceptable because the merger is genuinely busy most of the time when
the buffer is Capturing.
On Apple the timed waitFor() above still avoids condition_variable
entirely — the spin+yield loop is all the merger's hot cold-path uses. But the
struct's mtx_/cv_ members are no longer dead: they back the untimed
wait() primitive described next (Apple also gains a waiters_ counter).
waitFor() polls, which is right for the hot cold-path (the merger is busy most
of the time while Capturing) but wrong while Paused: there the merger has
nothing to do and must not burn CPU. Before this primitive existed, the paused
merger looped on waitFor(..., 1000µs) against a flag that never changed while
paused — so the timeout was the only thing ending each wait and it re-armed
immediately, a continuous poll (a full core on macOS, ~20k wakeups/s on Linux).
WaitFlag::wait(expected) blocks indefinitely until the value differs from
expected and a notifyAll() arrives — no timeout, no polling. Timer coalescing
only afflicts timed waits, so the constraint that forced waitFor() onto
spin/yield does not apply here:
- Linux:
std::atomic::wait(futex-backed) — parks whilevalue_ == expected. - Windows:
WaitOnAddress(..., INFINITE)in a spurious-wakeup re-check loop. - Apple: a real
condition_variable(cv_/mtx_). To keep the hot-pathnotifyAll()as cheap as the old no-op, aseq_cstwaiters_counter gates it: with nobody parked,notifyAll()is a single atomic load + branch (no mutex).store()is promoted toseq_cstso a0→1waiter transition racing astore()can never be lost (no missed wakeup).
EventBuffer::control_wait_ is a dedicated generation flag the merger parks on
while paused; resume()/stop() bump it and notifyAll() to wake the merger
(see §7). Cold-path only — never call wait() on the capture/merge hot path.
The default Windows timer resolution is ~15.6ms. The merger raises it to 1ms via
timeBeginPeriod(1) so that its sub-millisecond cold-path waitFor() timeouts
and the 5ms reorder window behave as intended (without it, a 200µs wait sleeps
~15ms). timeBeginPeriod is process-wide: it raises the whole machine's
scheduler tick rate, which increases idle power draw and defeats CPU power-state
coalescing for as long as it is held.
The high-resolution timer is only needed on the normal (hot) path, where the
merger is draining and emitting events. While Paused the merger is parked
untimed on control_wait_ (§13.1a) and needs no fine granularity. So the merger
brackets the resolution around capturing periods rather than its whole lifetime:
- Raise on the normal path (idempotent — a no-op if already raised).
- Lower in the draining branch, immediately after it sets
drained_and before it parks oncontrol_wait_— so the paused process runs at the system default resolution. - Balance on exit: a final lower after the run loop covers
stop()while on the normal path, so notimeBeginPeriodis ever leaked.
timeBeginPeriod/timeEndPeriod are reference-counted per-process, so every
begin must have exactly one matching end. A merger-local bool hires_active
guards the pair so calls are balanced and never doubled. They fire only on the
Capturing ⇄ Paused edges, never per merge iteration — rapid pause/resume cycles
(swing replay) just toggle them, which is cheap relative to a pause. All of this
is #if defined(PINPOINT_PLATFORM_WINDOWS)-guarded; on Linux/macOS the
raise/lower helpers are no-ops and there is no behavioural change.
Manual validation (no automated assertion exists — the buffer tests do not probe global timer resolution): use
ClockRes,NtQueryTimerResolution, orpowercfg /energyto confirm the resolution sits at the system default while the buffer is Paused and rises to 1ms only while Capturing, and that it returns to its pre-start()value afterstop()(no leakedtimeBeginPeriod).
Capture and merger threads should run at elevated priority to reduce scheduling jitter. The approach differs significantly per platform:
Windows: SetThreadPriority(THREAD_PRIORITY_TIME_CRITICAL) works for any
process without administrator rights. No special configuration needed.
macOS / iOS: pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0)
works for any process. Grand Central Dispatch honours this class with very low
scheduling latency. No special configuration needed. Available from macOS 8+ / iOS 8+.
Linux: The ideal is SCHED_FIFO with priority 50, which gives the thread
real-time scheduling guarantees. However, this requires CAP_SYS_NICE. Without it,
the fallback is setpriority(PRIO_PROCESS, 0, -10) (nice value -10), which
requires no special privileges but gives only a hint to the scheduler. To grant
RT priority without running as root:
sudo setcap cap_sys_nice+ep pinpointOr via systemd: AmbientCapabilities=CAP_SYS_NICE
ThreadPolicy::apply() tries the best available option, logs the result via
lastApplyDescription(), and never throws. Pinpoint always starts whether or not
priority elevation succeeds.
Android: setpriority(PRIO_PROCESS, 0, -19) (equivalent to
THREAD_PRIORITY_URGENT_AUDIO) works for an app's own threads without elevation.
EventBuffer::nowMicros() wraps std::chrono::steady_clock::now(), which the
standard library maps to the best available monotonic clock per platform:
| Platform | Underlying call | Resolution | Notes |
|---|---|---|---|
| Linux | clock_gettime(CLOCK_MONOTONIC) |
~20ns | Unaffected by NTP |
| Windows | QueryPerformanceCounter |
~100ns | Unaffected by time changes |
| macOS | mach_absolute_time |
~40ns | Mach timebase |
| Android | clock_gettime(CLOCK_MONOTONIC) |
~100ns | Same as Linux |
All are monotonic — they never go backwards. All are lock-free — the call compiles
to a single syscall or hardware register read. Using any other clock source, even
std::chrono::high_resolution_clock (which may alias system_clock), risks
introducing non-monotonicity if the system time is adjusted.
SourceRing::storage_ requires 64-byte alignment. std::aligned_alloc is
standard C++17 but has a platform quirk: on MSVC, the allocation size must be a
multiple of the alignment. The implementation ensures slot_count_ × slot_stride_
is always a multiple of 64 by rounding slot_stride_ up during construction. On
MSVC, the implementation falls back to _aligned_malloc / _aligned_free to avoid
this constraint.
The spin loops in WaitFlag and the merger use PINPOINT_CPU_PAUSE() to signal
to the CPU that this is a spin-wait, allowing the processor to reduce power
consumption and avoid memory order violations on x86 (the PAUSE instruction also
avoids the memory order machine clear that occurs when a speculative load sees a
store to the same address). Defined in platform.h:
#if defined(_MSC_VER)
#define PINPOINT_CPU_PAUSE() _mm_pause()
#elif defined(__x86_64__) || defined(__i386__)
#define PINPOINT_CPU_PAUSE() __builtin_ia32_pause()
#elif defined(__arm__) || defined(__aarch64__)
#define PINPOINT_CPU_PAUSE() __asm__ __volatile__("yield")
#else
#define PINPOINT_CPU_PAUSE() ((void)0)
#endifplane_strides exists because drivers pad rows. Computing row * width instead of
row * plane_strides[0] produces a progressively sheared image — which looks like
a camera or decode bug and is neither. Read the stride from
SwingWindow::formatOf() / EventBuffer::formatOf(); never derive it.
acquireWriteSlot() returns a WriteSlot with data = nullptr and valid = false
when the buffer is Paused. Writing to nullptr is undefined behaviour. Always check
slot.valid before accessing slot.data.
The slot can be overwritten by the next acquireWriteSlot() call on the same ring
(when the ring wraps). Any pointer into slot memory is invalid after publish().
If you need to retain the data, copy it before publishing — but this defeats the
zero-copy purpose. The correct pattern is to publish and let the consumer read via
acquireReadHandle.
system_clock is not guaranteed to be monotonic and changes when the system time
is adjusted (e.g. NTP correction). Use only EventBuffer::nowMicros(). This is
enforced as a "MUST NOT" in the producer protocol and is the most common source
of ordering bugs during integration.
resume() calls SourceRing::reset() on all rings, which resets the write
position to zero and overwrites all generation counters. Any existing ReadHandle
obtained from the ring is now invalid. validate() will catch this — it will
return false — but only if you call it. Destroy the SwingWindow before resume().
publish() clamps bytes_written to slot_max_bytes_ in release builds and
increments bounds_violations. The stored payload will be truncated — the consumer
will see partial data with no error. Monitor bounds_violations in diagnostics.
Camera registrations should use the actual frame size reported by the driver after
the camera starts.
updateSourceFormat() replaces the whole FormatDescriptor. Re-stamps built from
frame data typically carry no device_serial, so a naive replace would erase the
attribution registerSource() normalised in. The buffer defends against exactly
that one field — an empty device_serial in the update preserves the registered
one — but nothing else is merged. Fill the descriptor from the current one rather
than from scratch. CameraInstance additionally refuses to re-stamp while
swingWindowLive(), preserving the invariant that a descriptor is immutable for
as long as a worker is reading through it.
deregisterSource() asserts that swing_window_live_ is false. If you call it while
a SwingWindow exists that references data in that source's ring, you get a use-after-free.
Always destroy the SwingWindow before deregistering any source. The typical flow:
window = {}; // destroy SwingWindow first
buffer.deregisterSource(cam_id); // now safe — ring memory freedderegisterSource() asserts Paused state. It is never safe to free ring memory
while the merger is running — the merger holds SourceSlot& references from its
drainSources lambda. CameraManager::setSelected() handles the pause/deregister/resume
sequence automatically — do not call deregisterSource() directly from application code
unless you also manage the state transition.
Qt's time classes are not guaranteed to be monotonic across all platforms and some
(e.g. QDateTime::currentMSecsSinceEpoch()) are system-clock-based. Only
EventBuffer::nowMicros() guarantees the properties the merger relies on.
src/Buffer/
├── CMakeLists.txt pinpoint_buffer library; PINPOINT_ENABLE_{ASAN,
│ UBSAN,TSAN} options; tests added only when
│ PROJECT_IS_TOP_LEVEL
├── platform.h Platform detection macros; PINPOINT_CPU_PAUSE()
├── types.h SourceId, DeviceKind (incl. Marker_App),
│ PixelFormat, IndexEntry, IndexEntryFlags
├── format_descriptor.h FormatDescriptor, CameraFormat (strides,
│ exposure), ImuFormat, ExposureSource + names
├── pixel_format_names.h PixelFormat ↔ swing.json string, both directions
│ derived from one table. Qt-free.
├── imu_sample.h ImuSample (40B, "imu_sample_v2") + makeImuSample
│ — the single source of truth for the stored frame
├── source_descriptor.h SourceDescriptor, SyncSource, slot-count/size math
├── source_stats.h SourceStats (cache-line aligned, all atomic)
├── event_buffer_config.h EventBufferConfig — all tunable parameters
│
├── source_ring.h SourceRing — seqlock SPSC ring, core primitive
├── source_ring.cpp …including the two seqlock fences (§12.1)
│
├── timeline_index.h TimelineIndex — SPMC seqlock ring of IndexEntry
├── timeline_index.cpp
│
├── wait_flag.h WaitFlag — platform-abstracted timed + untimed wait
├── wait_flag.cpp Three platform implementations in one file
│
├── thread_policy.h ThreadPolicy — unprivileged priority elevation
├── thread_policy.cpp Per-platform implementations
│
├── event_buffer.h EventBuffer, Subscription, DiagnosticsSnapshot
├── event_buffer.cpp Merger loop, lifecycle, watchdog, registration,
│ lifetime counters, RingPayloadSource
│
├── swing_payload_source.h SwingPayloadSource — the pluggable window backing
├── swing_window.h SwingWindow — frozen analysis view
├── swing_window.cpp
│
└── tests/
├── CMakeLists.txt 8 gtest targets + the benchmark
├── source_ring_test.cpp SPSC stress, overrun, seqlock correctness
├── adversarial_fuzz_test.cpp Producer/consumer race timing
├── timeline_index_test.cpp Append, read, snapshot, reset
├── wait_flag_test.cpp Timeout accuracy, wakeup latency; untimed
│ wait() — immediate-return, wake-on-notify,
│ and no-lost-wakeup-under-race (Apple handshake)
├── event_buffer_test.cpp End-to-end, lifecycle, multi-cycle;
│ ResumeWakesParkedMerger (pause-gate park/wake)
├── swing_window_test.cpp Payload access, lifetime, freeze semantics
├── watchdog_test.cpp Stall detection, recovery
├── thread_policy_test.cpp Priority elevation, thread-local description
├── latency_benchmark.cpp Two-mode benchmark (see below)
└── tsan_suppressions.txt Narrow seqlock race suppressions
One Buffer-related test lives outside this tree:
src/Analysis/tests/swing_window_parity_test.cpp builds a real EventBuffer,
captures a window from it, and asserts a ring-backed window and a disk-backed one
present the same data — i.e. that the SwingPayloadSource split did not fork
behaviour. It sits in the Analysis suite because it needs the reanalyzer.
The Buffer suite has not migrated to the shared pp_add_test helper — it
predates it, keeps its own GoogleTest FetchContent, and uses its own
PINPOINT_ENABLE_* sanitizer options rather than the repo-wide PP_SANITIZE.
Both entry points below are current; see testing_developer_guide.md for the
wider picture.
# Standalone — Buffer as its own top-level project (this is what enables its tests)
cmake -S src/Buffer -B build/buffer
cmake --build build/buffer -j6
ctest --test-dir build/buffer --output-on-failure
# Under the umbrella, alongside every other suite
cmake -S tests -B build/tests
cmake --build build/tests -j6
ctest --test-dir build/tests --output-on-failureSanitizers use a separate build dir (the flags bake into objects), and for these
targets the knob is PINPOINT_ENABLE_*, not PP_SANITIZE:
# ASAN + UBSAN (correctness)
cmake -S src/Buffer -B build/buffer-asan \
-DPINPOINT_ENABLE_ASAN=ON -DPINPOINT_ENABLE_UBSAN=ON
cmake --build build/buffer-asan -j6
ctest --test-dir build/buffer-asan --output-on-failure
# TSAN (threading) — the seqlock's deliberate benign race needs the suppressions
cmake -S src/Buffer -B build/buffer-tsan -DPINPOINT_ENABLE_TSAN=ON
cmake --build build/buffer-tsan -j6
TSAN_OPTIONS="suppressions=$PWD/src/Buffer/tests/tsan_suppressions.txt" \
ctest --test-dir build/buffer-tsan --output-on-failureOn arm64 macOS, do not add
detect_leaks=1— LSan is unsupported there and the whole suite reports as failing. Seetesting_developer_guide.md.
latency_benchmark is deliberately not registered with CTest — it is a
manual-run timing harness, and its targets are wall-clock assertions that would be
flaky in CI.
cmake -S src/Buffer -B build/buffer && cmake --build build/buffer -j6
# Mode A: merger response latency (single source, direct ring read)
# Target: p50 < 100µs, p99 < 500µs
./build/buffer/tests/latency_benchmark --merger
# Mode B: end-to-end latency (multi-source via Subscription)
# Target: p50 ≈ reorder_window + 0.5 × slowest_source_interval
# For 5ms reorder + 60Hz camera: p50 ≈ 13ms — this is by design, not a bug
./build/buffer/tests/latency_benchmark --e2e
# Both modes
./build/buffer/tests/latency_benchmarkFor the full design rationale, architectural decisions, and revision history, see
docs/design/event_buffer_design.md.