feat: LatestFrameWriter/LatestFrameReader (producer-owned latest-frame, seqlock)#1
Conversation
Review — C++ Reviewer (Epic 020 / feature-002, Wave-1 foundation)Verdict: no blockers. Approve pending the MINORs below. This is a correct, well-structured seqlock primitive. I re-derived the publish/read ordering against the C++ memory model and it holds; I built and ran everything under three sanitizers. Verification I ran (not just trusted)
Seqlock correctness — confirmed
MINORs
Build-config fixes (team-lead question #8)Correct and minimal. The None of the above gates merge. Once 1–3 are addressed (code or doc), this is good to go for Wave 2. |
|
Resolving the review threads — all addressed in
— Claude |
What
Adds the additive latest-frame shared-memory primitive from Epic 020 / feature-002 (ADR-1 + ADR-11): a producer-owned, consumer-optional, newest-frame-wins, tear-free zero-copy display buffer built on zerobuffer's platform layer only. It does not touch the SPSC
Reader/Writer, so the AI path's SPSC contract is unchanged (FR-6).This is Wave 1 (foundation). streamer (
GstDisplaySink→LatestFrameWriter) and native-player (ShmFrameSource→LatestFrameReader) build on it in Wave 2.Public API — link against this (for Wave-2 engineers)
Header:
#include <zerobuffer/latest_frame.h>— one definition of the on-segment layout, shared by both sides.Zero-copy read (ADR-11 / design §4–5 "no extra copy"): the reader hands
consumea pointer straight into the slot; the seqlock is validated around the consume — on a mid-read lap the consume is re-invoked on the newest slot (bounded retries), so the caller treats its work as committed only whenread_latest_intoreturnstrue. This eliminates the N×-per-reader full-frame copy (a 12 MB HDR frame × N readers), critical for many-reader HDR-FullHD on RPi. The primitive stays generic — it never copies and knows nothing about PixelRepack/DisplayBuffer.Metadata wire contract (verified against native-player)
get_metadata_raw()returns a pointer to a 4-byte little-endian length prefix followed by the JSON, andget_metadata_size()returns4 + json_len— exactly whatnativeplayer::ShmCaps::parse(metadata, size)consumes (shm_caps.cpp:142-159).set_metadata(caps, len)takes the bare JSON and the writer owns the length framing. Before the firstset_metadata, the reader reports "no caps yet" (get_metadata_raw()==nullptr, size 0), not an empty-caps block.publish(uint64_t seq). I added the frame byte-count:publish(uint64_t sequence, size_t size)— the reader reportssizeandShmFrameSourcedrops when it disagrees withShmCaps::frame_bytes(), so the real count must travel with the frame.size > slot_sizeis clamped (no OOB).read_latest()+ aLatestFramevalue class. Replaced by the zero-copyread_latest_into(consume, timeout)functor above (single path;data()/size()/sequence()/valid()become the consume params + the bool return). Team lead is syncingdisplaysink-design.mdReader API + design.md §4/§5.Design notes
seqlock: writer makes it odd before touching the payload, even after; reader reads publish_index (acquire) → seqlock (acquire) → invokesconsumeon the slot in place → acquire fence → re-checks seqlock; a mid-read lap → re-invoke consume on the newest. Cross-process atomicity viastd::atomic_refover plain POD fields;frame_number/sizeare relaxedatomic_ref, the payload is read in place by the caller (validated by the seqlock re-check). No per-frame copy, no per-frame allocation.process_exists); a segment owned by a live writer is refused. On clean release the writer invalidates the header magic before unlinking → readers detect "producer closed" and re-attach to a fresh segment (works even in-process). Liveness is pid + start-time viaplatform::process_exists.Tests (gtest,
tests/test_latest_frame.cpp— 17, all green)Writer create+publish with no reader · reader-attaches-after-writer gets latest · newest-wins skips intermediates · zero-copy pointer rotates through the ring (proves no per-frame alloc: 1 < distinct ≤ slot_count) · absent segment tolerated (consume never invoked) · detach/re-attach (writer unaffected) · writer restart → reader recovers · writer-gone reported, no crash/block · oversized frame clamped, no OOB · metadata set + rewrite (exact prefix bytes) · caps not-ready before first
set_metadata· tear-free under concurrent write (256 KB frames, committed_torn==0 AND retry path exercised) · multi-reader · second-writer-on-live-segment rejected · stale-segment reclaimed.Sanitizers: full suite TSan verified clean (ThreadSanitizer, ASLR off via
setarch -R), and clean under ASan + UBSan (lib instrumented — no leaks/OOB/UB). The in-place payload read is a benign, standard seqlock pattern the seqlock re-check makes correct. (TSan prints a benign "atomic_thread_fencenot supported with -fsanitize=thread" note — a TSan instrumentation limitation, not a race; TSan reports zero races.)Build-config fixes (pre-existing, unrelated to the primitive — flagged to team lead)
BUILD_TESTS=ONdid not configure on a clean checkout, independent of this change:tests/CMakeLists.txtreferencedtest_frame_raii.cpp, which is absent from the repo → now guarded withif(EXISTS ...).tests/generated/CMakeLists.txtlinksnlohmann_jsonbut the main CMake only fetches it afteradd_subdirectory(tests)→ hoisted a find/fetch to the top oftests/CMakeLists.txt..gitignorerule/cpp/tests/test_*was silently ignoring test sources → added!/cpp/tests/test_*.cppso test sources are tracked while built binaries stay ignored.🤖 Generated with Claude Code