Which way the detector's rows run was decided once, in the module assembly, and never stated again: not on the wire, not in the file, nowhere a consumer could read it. mirror_y was consumed inside the DetectorGeometryModular constructor and discarded. It is now a declared property of the detector setup, carried into the start message, written to HDF5 under detectorSpecific, and read back. Absence means true, which is the MX convention and the only thing Jungfraujoch has ever produced. Deliberately a boolean and not a corner enum: the assembled image can only be flipped in Y, so a four-corner value would encode states that cannot occur. DECTRIS stream2 has no field for this - checked against the specification - so the key is new rather than an extension of theirs, and a consumer that does not know it skips it and behaves exactly as before. The .poni file gains pyFAI's orientation. Without it pyFAI applies its own default, 3 (bottom left), and believes increasing row means physically upwards. The numbers still agreed - a mirror preserves 2theta, so radial integration was never affected - but the azimuth came out with the opposite sense, which matters for cake and sector integration. Declaring orientation 2 is not a one-line addition: it re-anchors Poni1 to the top edge and reverses rot2 and rot3, a row flip being improper. Measured against pyFAI 2026.5.0 by searching all four orientations, both Poni1 anchorings and all eight sign combinations: exactly two combinations reproduce the lab position DiffractionGeometry computes to 1.4e-17 m - the unlabelled form written before, and (orientation 2, Poni1 = height-1-beam_y, +rot1/+rot2/-rot3), which is now written. Calibration_PoniFileAxisConvention pins it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 KiB
Review: data path (receiver / pushers / devices)
Scope
Streaming data path of Jungfraujoch:
receiver/—JFJochReceiver(base),JFJochReceiverFPGA,JFJochReceiverLite,FrameTransformation,LossyFilter,JFJochReceiverPlots,JFJochReceiverService,JFJochReceiverCurrentStatus,ImageMetadata.image_pusher/—TCPStreamPusher(priority: zero-copy buffer-reuse fix),ImagePusherbase.- Supporting plumbing in
common/:ImageBuffer,ZeroCopyReturnValue,ThreadSafeFIFO. image_puller/,acquisition_device/,jungfrau/,preview/,detector_control/skimmed for the data-flow and ownership picture (not exhaustively audited).
Vendored libs and broker/gen/ treated as black boxes. ffbidx reached only transitively (via the indexer); no
direct touchpoints in this subsystem — see final section.
Architecture overview (threading & data flow)
There are two receiver implementations sharing the abstract JFJochReceiver. The base owns all the cross-cutting
state — counters (images_collected/sent/skipped, compressed sizes), MovingAverage stat windows, the ImageBuffer
and ImagePusher references, the ScanResultGenerator, LossyFilter, indexer, and the SendStartMessage/
SendEndMessage/GetStatus machinery (JFJochReceiver.cpp:108-210). Each subclass adds its own ingestion threads.
FPGA path (JFJochReceiverFPGA): one AcquireThread per data stream drives the FPGA, plus a pool of
FrameTransformationThreads (count = forward_and_sum_nthreads). Each transformation thread pulls an image number
from a ThreadSafeFIFO<int64_t> work queue (images_to_go), waits on AcquisitionDevice::Counters for the frame,
pulls per-module DeviceOutput straight from the device buffer, runs metadata/azimuthal/spot-finding analysis,
geometry-transforms + compresses into an ImageBuffer slot, then hands the slot to the pusher
(JFJochReceiverFPGA.cpp:272-503). Frame ownership in the device buffer is released explicitly via
FrameBufferRelease. A separate FinalizeMeasurement future joins all worker futures, sends the END message, and
finalizes the writer (:550-588). CPU-summation mode fans each image out to nested SummationThreads.
Lite path (JFJochReceiverLite): a MeasurementThread waits for the upstream START message off an ImagePuller,
configures the experiment from it, then a pool of DataAnalysisThreads each poll the puller's FIFO directly for
DATA/END/CALIBRATION messages, analyze, and forward into ImageBuffer slots (JFJochReceiverLite.cpp:227-354).
There is no explicit work-distribution queue — every analysis thread competes on image_puller.PollImage(), and the
END message is observed via the shared end_message_received flag.
Buffer handoff & zero-copy. ImageBuffer is a fixed ring of fixed-size slots. GetImageSlot() returns a
ZeroCopyReturnValue* (pointer into a stable std::vector<ZeroCopyReturnValue>), the worker serializes/compresses the
CBOR image directly into the slot, marks ReadyToSend(), then calls image_pusher.SendImage(*loc). For TCP the slot
is not copied: TCPStreamPusher::SendImage(ZeroCopyReturnValue&) enqueues the slot pointer into a per-connection
ThreadSafeFIFO; the per-connection WriterThread sends it and calls z->release() (returning the slot to the ring)
only after the bytes are handed to the kernel — and for MSG_ZEROCOPY sends, only after the kernel errqueue
completion notification (TCPStreamPusher.cpp:351-388,544-568). The synchronous SendImage(ptr,size,number) overload
takes a transient caller buffer (z == nullptr) and must never zero-copy it — this is the recently-fixed hazard.
Plot production (JFJochReceiverPlots): both receivers call plots.Add(message, az_int_profile) per image from the
worker threads. Internally each metric is a StatusVector (documented as internally thread-safe), so the per-metric
appends need no outer lock; only the std::map<string,ROIStatus> (roi_m, a shared_mutex) and the
az_int_profile / XFEL maps (m) are mutex-guarded (JFJochReceiverPlots.h:34-88). The broker pulls snapshots via
GetPlots/GetPlotRaw/GetAzIntProfile, and GetStatus reads aggregate means (indexing rate, bkg estimate). So plot
data is produced incrementally on the hot path and read concurrently by broker/UI threads — characterized here;
frontend↔broker line is out of scope.
Findings
[High] LossyFilter::ApplyFilter mutates shared RNG/counter but the body is unsynchronized — receiver/LossyFilter.cpp:19-29
Category: Bug
ApplyFilter is called concurrently from every FPGA FrameTransformationThread and every Lite DataAnalysisThread
(JFJochReceiverFPGA.cpp:438, JFJochReceiverLite.cpp:308). RollDice() does lock random_m, but the rest of
ApplyFilter does not: message.number = image_number++ reads-modifies-writes the std::atomic<int64_t> image_number
via ++ (atomic, OK on its own) but the decision to renumber and the renumber itself are not atomic together. Two
threads can interleave between RollDice() returning true and the image_number++, so the renumbered sequence is
fine numerically (atomic post-increment) but is assigned in nondeterministic order relative to original image order —
filtered serial-MX output image numbers are not guaranteed monotonic/contiguous per source order. More importantly the
p == 1.0 fast path returns without touching image_number, while the p < 1.0 path renumbers from a separate
counter, so for 0 < p < 1 the emitted message.number is decoupled from the original frame number across threads
with no ordering guarantee. Worth confirming this matches the intended serial-MX renumbering contract; if monotonic
per-arrival numbering is expected, the dice-roll + assignment needs to be one critical section.
[High] images_sent is incremented at enqueue time, not send time (TCP) — receiver/JFJochReceiverFPGA.cpp:484-485, JFJochReceiverLite.cpp:331-332
Category: Bug / Inconsistency
Both receivers do image_pusher.SendImage(*loc); ++images_sent; with the comment "Handle case when image not sent
properly". For the TCP pusher SendImage(ZeroCopyReturnValue&) is asynchronous — it only enqueues onto the
per-connection FIFO and returns void; the actual send can later fail (broken socket → z->release() with no send,
TCPStreamPusher.cpp:552-555,1071-1094). So images_sent over-counts whenever a connection breaks mid-run or the
2-second enqueue deadline expires and the slot is dropped. The authoritative "written" count comes from
GetImagesWritten() = total_data_acked_ok (ACK-based), so the receiver-side images_sent and the pusher-side acked
count can disagree; the "wrong number of images" symptom the zero-copy commit chased can also surface here. The
file-based pushers return a bool from SendImage(z) (via the base overload) but that bool is discarded for the
ZeroCopyReturnValue overload, which is void — there is no way for the caller to know the async send failed.
[Medium] ZeroCopyCompletionThread reads c->zc_pending.empty() without holding zc_mutex — image_pusher/TCPStreamPusher.cpp:392
Category: Bug (data race)
The loop condition while (c->active || !c->zc_pending.empty()) inspects std::deque::empty() without zc_mutex,
while EnqueueZeroCopyPending/ReleaseCompletedZeroCopy/ForceReleasePendingZeroCopy mutate the deque under the
lock (:362-381,351-360). Concurrent push_back/pop_front against an unsynchronized empty() is a data race (UB),
even if in practice it only affects the loop's exit timing. Since StopDataCollectionThreads already drains via
WaitForZeroCopyDrain + ForceReleasePendingZeroCopy before joining zc_future, the unlocked check is also
redundant for correctness — gating purely on c->active (atomic) and letting the drain/force path handle the tail
would remove the race. The same loop drives c->fd.load() and c->broken (both atomic, fine).
[Medium] FPGA SendStartMessage runs before acquisition threads, but END/finalize ordering differs from Lite — receiver/JFJochReceiverFPGA.cpp:111-137 vs JFJochReceiverLite.cpp:94-157
Category: Architecture / Inconsistency
The two receivers implement the same lifecycle (start msg → images → end msg → finalize writer → check buffer
returned) but in structurally different places and orders. FPGA sends START in the constructor before spawning
acquire threads and runs END/finalize in a dedicated FinalizeMeasurement future joined by StopReceiver. Lite does
START and END inside its single MeasurementThread, and crucially Lite calls CheckIfBufferReturned before
image_pusher.Finalize() (:141-149) while FPGA also does buffer-return-then-Finalize (:575-582) — those match,
but the surrounding error handling diverges: Lite's MeasurementThread throws out of the future on buffer-return
timeout, FPGA's FinalizeMeasurement also throws, yet FPGA additionally cancels devices on END. The duplicated
"send end / check buffer / finalize / set status" tail (≈15 lines) is copy-pasted with subtle drift; a shared
protected helper on the base (e.g. FinalizeWriterAndBuffer()) would remove the divergence risk. This is the clearest
instance of the FPGA-vs-Lite copy-paste-with-drift the review asked to look for.
[Medium] WriterThread does not break the loop on a broken connection — image_pusher/TCPStreamPusher.cpp:544-568
Category: Smell / timeout correctness
Once c->broken is set, the writer keeps looping, draining the queue element-by-element and calling
e.z->release() per element (:552-555) until it finally sees e.end. Each GetBlocking() blocks until something is
enqueued, so during the 2s producer enqueue window (SendImage PutTimeout loop, :1076-1089) the thread spins
through release-and-continue. Functionally it drains correctly, but on a broken socket it would be cheaper and clearer
to flush the queue once and exit; as written, shutdown relies on the producer eventually pushing {.end=true} via
StopDataCollectionThreads. Confirm there is no path where active is cleared and the {.end} sentinel fails to
enqueue (the PutTimeout 200ms fallback at :851-856 clears + re-Puts, which looks correct, but the interaction is
subtle).
[Low] JFJochReceiverFPGA::AcquireThread logs the same exception twice — receiver/JFJochReceiverFPGA.cpp:192-196
Category: Inconsistency
In the second catch block, logger.ErrorException(e); Cancel(e); logger.ErrorException(e); logs e twice (and
Cancel(e) itself logs again at JFJochReceiver.cpp:221-222), so a single device error produces three log entries.
The first catch block (:179-184) logs it once. Minor, but inconsistent and noisy.
[Low] JFJochReceiver::GetStatus() reads many non-atomic / mutex-guarded members without their locks — receiver/JFJochReceiver.cpp:80-105
Category: Bug (benign race) / Inconsistency
GetStatus() is const and is called both from worker threads (after every image) and indirectly from the broker via
current_status. It reads max_image_number_sent (guarded elsewhere by max_image_number_sent_mutex) and
max_delay (guarded by max_delay_mutex) without taking those mutexes, and reads plain int64_t/std::optional
members non-atomically. The atomic counters are fine; the mutex-guarded scalars are technically racy reads. In
practice these are status/telemetry values so a torn read is cosmetic, but it is inconsistent with the locking the
same fields get on the write side. Either make them atomic or take the locks.
[Low] IsConnectionAlive is racy as a pre-send gate — image_pusher/TCPStreamPusher.cpp:169-191, used at :1047,1199
Category: Smell
SendImage(ptr,size,number) checks IsConnectionAlive(c) then sends; the connection can break between the check and
the send, which is unavoidable and handled by SendAll's error paths — so the check is an optimization, not a
guarantee. SendCalibration re-checks broken/connected/active four times before and after taking send_mutex
(:1165-1203); this is defensive but verbose (≈40 lines of repeated guards). A single re-check under the lock would be
equivalent given broken/connected/active are atomics. Simplification opportunity, not a bug.
[Low] ImageBuffer::GetImageSlot waits on a preview reader while holding the global mutex — common/ImageBuffer.cpp:111-135
Category: Architecture
When a recycled slot still has readers > 0 (a preview/JPEG copy in flight), GetImageSlot does
cv_preview_done.wait(ul, …) while holding m. The wait releases m, so it is not a self-deadlock, but it stalls
every other producer/consumer that needs the buffer mutex (status reads, ReadyToSend, ReleaseSlot) until the preview
memcpy of one image completes. GetImage does drop the lock for its memcpy (:225-230), so the window is just the
copy duration, but on a large image at high frame rate this serializes the hot path behind a UI read. Worth noting as a
latency coupling between preview and acquisition.
[Nit] nexus_mask declared and never used — receiver/JFJochReceiver.cpp:125
Category: Simplification
std::vector<uint32_t> nexus_mask; in SendStartMessage is dead. Remove.
[Nit] SummationThread returns int64_t but always returns 0 — receiver/JFJochReceiverFPGA.cpp:241-270
Category: Simplification
The return value is never read (futures … f.get() discards it, :345-346). Should be void. The maintainer's
"no superfluous code" principle applies.
[Nit] FillNotCollectedModule / err_value const-correctness and naming — receiver/FrameTransformation.cpp:138-140
Category: Nit
Minor: precompression_buffer/compressed_buffer/err_value are std::vector<char> and repeatedly C-cast to typed
pointers; consistent reinterpret_cast or a typed view would read cleaner. Also image_mode/pixel_depth are
initialized in the member-init list in an order that does not match declaration order (pixel_depth declared after
image_mode’s use); harmless here but compilers with -Wreorder will warn.
FPGA vs Lite receiver: shared vs duplicated
Shared (good): All telemetry/counters, SendStartMessage/SendEndMessage, GetStatus, GetFinalStatistics
skeleton, the ImageBuffer slot protocol, LossyFilter, ScanResultGenerator, JFJochReceiverPlots.Add, and the
Cancel base behavior all live on JFJochReceiver. The two subclasses correctly funnel through these.
Duplicated with drift (risk): The per-image forwarding tail is near-identical but copy-pasted in both
subclasses: get slot → nullptr ⇒ writer_queue_full=true → else serialize into slot → SetImageNumber/SetImageSize/ SetIndexed → ReadyToSend() (FPGA) / release() (Lite dark-mask) → optional preview/metadata sockets → SendImage
++images_sentelserelease()→UpdateMaxImageSent. CompareJFJochReceiverFPGA.cpp:440-489againstJFJochReceiverLite.cpp:310-336. They differ in small ways (FPGA does compression into the slot and computescompression_ratioinline; Lite serializes an already-compressedDataMessage), but the slot lifecycle and the preview/metadata/pusher dispatch are the same logic written twice. The MaskThread (Lite:179-225) duplicates a third, slightly different copy. The finalize tail (send end → CheckIfBufferReturned → Finalize writer → set status) is also duplicated (see Medium finding above). A sharedForwardImageToWriter(ZeroCopyReturnValue&, DataMessage&)helper on the base would collapse three near-copies into one and remove the drift surface (e.g. only FPGA guards theimage_collection_efficiency == 0empty-image case, only Lite forwards calibration messages).
Efficiency / progress semantics differ legitimately: FPGA derives efficiency from packet counts and progress from
device frame counters; Lite derives both from images_collected / max_image_number_received vs GetFrameNum().
That divergence is inherent (Lite has no packet accounting) and is fine.
Inconsistencies with rest of repo
- Logging style mismatch.
TCPStreamPusherbuilds log messages with string+concatenation throughout ("... " + std::to_string(x) + ..., e.g.:127,565,694) while the rest of the receiver subsystem uses the fmt-stylelogger.Info("... {}", x)API (e.g.JFJochReceiver.cpp:48,JFJochReceiverService.cpp:223).SendCalibrationin the same file uses the fmt style (:1166), so the file is internally inconsistent too. SendImage(z)return type asymmetry. BaseImagePusher::SendImage(const uint8_t*, size_t, int64_t)returnsbool; theZeroCopyReturnValueoverload returnsvoid(ImagePusher.h:43-44). Callers therefore cannot detect async TCP send failures (ties into theimages_sentover-count finding). Other pushers (HDF5/ZMQ) also override the void overload, so the asymmetry is repo-wide, but it is the root of the silent-failure path here.- Mutex-guarded-vs-atomic inconsistency in the base receiver.
max_image_number_sent/max_image_number_received/max_delayuse a dedicated mutex on write but are read lock-free inGetStatus/GetProgress. Elsewhere in the repo similar telemetry is plain atomic. Pick one idiom. ThreadSafeFIFO::Getdecrementsutilizationbut not via the blocking path's accounting is consistent within the class; no issue, just noting the FIFO is the shared primitive both paths rely on and it is sound (all ops mutex-guarded).
ffbidx touchpoints (if any)
None directly in the data path. ffbidx is reached only transitively through IndexAndRefine/IndexerThreadPool
(JFJochReceiver.h:23-24, member indexer), which the receivers feed via MXAnalysisAfterFPGA/MXAnalysisWithoutFPGA
and finalize via indexer.FinalizeRotationIndexing() / GetConsensusUnitCell() in SendEndMessage
(JFJochReceiver.cpp:165-176). The indexer object is shared across all worker threads by reference; its internal
thread-safety is owned by the image-analysis/indexing subsystem (separate review). No ffbidx API is called from
receiver/pusher/device code in scope, so no ffbidx-specific misuse to flag here.