From 655038e3ce208ca8c01fda2c41fcb17686d10fb8 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 24 Jun 2026 18:25:39 -0400 Subject: [PATCH 1/5] Fix GUI deadlock when stopping a wedged acquisition When a device wedges (FIFO overrun / stalled USB transfer), the getData*() loops spin inside their own `while (!disconnecting_)` loop and never return, so StreamThread::threadFunction never re-checks shutdown_. StreamThread::stop() sets shutdown_ then immediately joins, but disconnecting_ was only set later inside disconnect() (after the join), so the worker could never exit and join() blocked forever, freezing the GUI thread. Add IDevice::requestStop() (sets disconnecting_) and call it in StreamThread::stop() before join(), so the worker breaks out of getData*() and exits cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/include/mccoutlet/Device.hpp | 12 ++++++++++++ src/core/src/Device.cpp | 6 ++++++ src/core/src/StreamThread.cpp | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/src/core/include/mccoutlet/Device.hpp b/src/core/include/mccoutlet/Device.hpp index c73b024..a8ce634 100644 --- a/src/core/include/mccoutlet/Device.hpp +++ b/src/core/include/mccoutlet/Device.hpp @@ -57,6 +57,17 @@ class IDevice { virtual bool startAcquisition() = 0; virtual void disconnect() = 0; virtual bool isConnected() const = 0; + + /** + * @brief Request that any in-progress getData*() call return promptly. + * + * Safe to call from a thread other than the one running getData*(). + * Causes the blocking acquisition loops to break out so the streaming + * thread can observe its shutdown flag and exit before disconnect() + * and the subsequent join(). Without this, a wedged device leaves + * getData*() spinning and stop() deadlocks on the join. + */ + virtual void requestStop() = 0; virtual DeviceInfo getInfo() const = 0; virtual DeviceCapabilities getCapabilities() const = 0; @@ -123,6 +134,7 @@ class MCCDevice : public IDevice { bool connect() override; bool startAcquisition() override; void disconnect() override; + void requestStop() override; bool isConnected() const override; DeviceInfo getInfo() const override; DeviceCapabilities getCapabilities() const override; diff --git a/src/core/src/Device.cpp b/src/core/src/Device.cpp index e017781..822e17a 100644 --- a/src/core/src/Device.cpp +++ b/src/core/src/Device.cpp @@ -422,6 +422,12 @@ bool MCCDevice::restartScan() { return true; } +void MCCDevice::requestStop() { + // Break the getData*() polling loops so the streaming thread can exit + // before disconnect()/join(). Set from the caller's thread. + disconnecting_ = true; +} + void MCCDevice::disconnect() { disconnecting_ = true; diff --git a/src/core/src/StreamThread.cpp b/src/core/src/StreamThread.cpp index f7e27bb..b72866c 100644 --- a/src/core/src/StreamThread.cpp +++ b/src/core/src/StreamThread.cpp @@ -61,6 +61,14 @@ void StreamThread::stop() { shutdown_ = true; + // Break any in-progress getData*() call before joining. Otherwise a + // wedged device leaves the worker spinning inside getData*() (which only + // checks the device's own abort flag, not shutdown_) and join() blocks + // forever, freezing the caller (the GUI thread). + if (device_) { + device_->requestStop(); + } + if (thread_ && thread_->joinable()) { thread_->join(); } From 268a1511aab19c87d22a5d80cc91fa12a634e581 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 24 Jun 2026 18:26:03 -0400 Subject: [PATCH 2/5] Cap consecutive scan restarts to avoid spinning on a wedged device restartScan() calls ulAInScanStop(), which is what makes libuldaq print "##### error still xfer pending. mNumXferPending =N" when USB transfers are stuck. A genuine FIFO overrun recovers in one restart, but an unrecoverable state left the getData*() loops calling restartScan() ~20x/sec indefinitely. Cap consecutive restarts per getData*() call (MAX_RESTART_ATTEMPTS = 3); on exhaustion, report an error and return false so the streaming thread stops cleanly instead of spinning. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/src/Device.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/core/src/Device.cpp b/src/core/src/Device.cpp index 822e17a..97a66ac 100644 --- a/src/core/src/Device.cpp +++ b/src/core/src/Device.cpp @@ -18,6 +18,11 @@ constexpr int MAX_DEV_COUNT = 100; // Larger values tolerate more jitter but use more memory. constexpr int SCAN_BUFFER_SECONDS = 10; +// Give up after this many consecutive scan restarts within a single getData*() +// call. A genuine overrun recovers in one restart; an unrecoverable USB state +// (e.g. stuck transfers) otherwise spins here forever, hammering ulAInScanStop. +constexpr int MAX_RESTART_ATTEMPTS = 3; + std::string ulErrorString(UlError err) { char msg[ERR_MSG_LEN]{}; ulGetErrMsg(err, msg); @@ -476,6 +481,7 @@ bool MCCDevice::getData(std::vector& buffer, double& timestamp) { const int channelCount = config_.high_channel - config_.low_channel + 1; const size_t total_buffer_elements = scan_buffer_.size(); + int restart_attempts = 0; while (!disconnecting_) { ScanStatus status{}; TransferStatus xfer{}; @@ -483,6 +489,15 @@ bool MCCDevice::getData(std::vector& buffer, double& timestamp) { UlError err = ulAInScanStatus(handle_, &status, &xfer); if (err != ERR_NO_ERROR || status != SS_RUNNING) { if (disconnecting_) return false; + if (++restart_attempts > MAX_RESTART_ATTEMPTS) { + if (statusCallback_) { + statusCallback_( + "Acquisition could not be recovered after " + + std::to_string(MAX_RESTART_ATTEMPTS) + + " restart attempts; stopping stream", true); + } + return false; + } if (!restartScan()) return false; continue; } @@ -520,6 +535,7 @@ bool MCCDevice::getDataInt32(std::vector& buffer, double& timestamp) { const int channelCount = config_.high_channel - config_.low_channel + 1; const size_t total_buffer_elements = scan_buffer_.size(); + int restart_attempts = 0; while (!disconnecting_) { ScanStatus status{}; TransferStatus xfer{}; @@ -527,6 +543,15 @@ bool MCCDevice::getDataInt32(std::vector& buffer, double& timestamp) { UlError err = ulAInScanStatus(handle_, &status, &xfer); if (err != ERR_NO_ERROR || status != SS_RUNNING) { if (disconnecting_) return false; + if (++restart_attempts > MAX_RESTART_ATTEMPTS) { + if (statusCallback_) { + statusCallback_( + "Acquisition could not be recovered after " + + std::to_string(MAX_RESTART_ATTEMPTS) + + " restart attempts; stopping stream", true); + } + return false; + } if (!restartScan()) return false; continue; } @@ -566,6 +591,7 @@ bool MCCDevice::getDataInt16(std::vector& buffer, double& timestamp) { const int channelCount = config_.high_channel - config_.low_channel + 1; const size_t total_buffer_elements = scan_buffer_.size(); + int restart_attempts = 0; while (!disconnecting_) { ScanStatus status{}; TransferStatus xfer{}; @@ -573,6 +599,15 @@ bool MCCDevice::getDataInt16(std::vector& buffer, double& timestamp) { UlError err = ulAInScanStatus(handle_, &status, &xfer); if (err != ERR_NO_ERROR || status != SS_RUNNING) { if (disconnecting_) return false; + if (++restart_attempts > MAX_RESTART_ATTEMPTS) { + if (statusCallback_) { + statusCallback_( + "Acquisition could not be recovered after " + + std::to_string(MAX_RESTART_ATTEMPTS) + + " restart attempts; stopping stream", true); + } + return false; + } if (!restartScan()) return false; continue; } From 26cc73c1333b9328b988d129bcacdde112cd0873 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 24 Jun 2026 18:26:52 -0400 Subject: [PATCH 3/5] Throttle overrun status reports to prevent GUI event-queue flooding restartScan() emitted a status callback on every restart. In the GUI each callback posts a queued event via QMetaObject::invokeMethod, so a recovery storm could flood the main thread's event queue and make the window unresponsive. Coalesce overrun reports to at most one per second; overrun_count_ still increments for every restart so the reported count stays accurate. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/include/mccoutlet/Device.hpp | 5 +++++ src/core/src/Device.cpp | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/include/mccoutlet/Device.hpp b/src/core/include/mccoutlet/Device.hpp index a8ce634..0ed74ab 100644 --- a/src/core/include/mccoutlet/Device.hpp +++ b/src/core/include/mccoutlet/Device.hpp @@ -8,6 +8,7 @@ */ #include +#include #include #include #include @@ -153,6 +154,10 @@ class MCCDevice : public IDevice { std::atomic disconnecting_{false}; int overrun_count_ = 0; + // Coalesce overrun status reports: time of the last one emitted to the + // callback. Default-constructed (epoch) means none emitted yet. + std::chrono::steady_clock::time_point last_overrun_report_{}; + // ulAInScan circular buffer (double precision, as required by uldaq) std::vector scan_buffer_; int scan_buffer_samples_per_chan_ = 0; diff --git a/src/core/src/Device.cpp b/src/core/src/Device.cpp index 97a66ac..6b23eac 100644 --- a/src/core/src/Device.cpp +++ b/src/core/src/Device.cpp @@ -418,7 +418,13 @@ bool MCCDevice::restartScan() { scans_read_ = 0; overrun_count_++; - if (statusCallback_) { + // Coalesce reports to at most one per second so a recovery storm cannot + // flood the consumer (e.g. the GUI's queued-event status updates). + auto now = std::chrono::steady_clock::now(); + if (statusCallback_ && + (last_overrun_report_.time_since_epoch().count() == 0 || + now - last_overrun_report_ >= std::chrono::seconds(1))) { + last_overrun_report_ = now; statusCallback_( "Device FIFO overrun detected, scan restarted (overrun #" + std::to_string(overrun_count_) + ")", true); From 7c7f0f26790a2da5e216002cafef3538f63a2897 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Sat, 4 Jul 2026 14:25:30 -0400 Subject: [PATCH 4/5] Hold a power assertion while streaming to prevent USB suspend wedging the DAQ When the Mac idles into system sleep (or App Nap throttles the libusb event thread), the DAQ's in-flight USB transfers never complete. uldaq then logs "##### error still xfer pending" on the next scan stop and the acquisition cannot be recovered, so the restart cap trips and the stream dies whenever the machine is left unattended. Prevent the wedge at the source: StreamThread::threadFunction now holds an RAII PowerAssertion for the lifetime of the streaming session. On macOS this uses -[NSProcessInfo beginActivityWithOptions:reason:] with NSActivityUserInitiated | NSActivityLatencyCritical, which blocks idle system sleep, opts out of App Nap, and disables timer throttling; it is released automatically on stop, error, or shutdown. Display sleep is still permitted, and a manual sleep (lid close) is still handled by the existing graceful-stop path. Non-Apple platforms get a no-op stub. Verified via pmset -g assertions that the PreventUserIdleSystemSleep assertion is registered while the guard is alive and released on destruction. Co-Authored-By: Claude Fable 5 --- src/core/CMakeLists.txt | 12 ++++++ src/core/include/mccoutlet/PowerAssertion.hpp | 37 +++++++++++++++++++ src/core/src/PowerAssertion.cpp | 10 +++++ src/core/src/PowerAssertion.mm | 33 +++++++++++++++++ src/core/src/StreamThread.cpp | 9 +++++ 5 files changed, 101 insertions(+) create mode 100644 src/core/include/mccoutlet/PowerAssertion.hpp create mode 100644 src/core/src/PowerAssertion.cpp create mode 100644 src/core/src/PowerAssertion.mm diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 2596707..57123aa 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -6,6 +6,18 @@ add_library(mccoutlet_core STATIC src/StreamThread.cpp ) +# Power management: keep the host awake during acquisition. macOS needs the +# Objective-C++ implementation (NSProcessInfo activity, built with ARC) linked +# against Foundation; other platforms use the no-op stub. +if(APPLE) + target_sources(mccoutlet_core PRIVATE src/PowerAssertion.mm) + set_source_files_properties(src/PowerAssertion.mm + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") + target_link_libraries(mccoutlet_core PRIVATE "-framework Foundation") +else() + target_sources(mccoutlet_core PRIVATE src/PowerAssertion.cpp) +endif() + target_include_directories(mccoutlet_core PUBLIC $ diff --git a/src/core/include/mccoutlet/PowerAssertion.hpp b/src/core/include/mccoutlet/PowerAssertion.hpp new file mode 100644 index 0000000..5d5d958 --- /dev/null +++ b/src/core/include/mccoutlet/PowerAssertion.hpp @@ -0,0 +1,37 @@ +#pragma once +/** + * @file PowerAssertion.hpp + * @brief RAII guard that keeps the host awake during acquisition. + */ + +#include + +namespace mccoutlet { + +/** + * @brief Keeps the system awake for as long as the object is alive. + * + * On macOS, idle system sleep suspends the USB bus, which wedges the DAQ's + * in-flight transfers (uldaq then logs "##### error still xfer pending" and + * acquisition cannot be recovered). App Nap can likewise throttle the libusb + * event thread enough to overrun the device's hardware FIFO. This guard opts + * the process out of both via @c -[NSProcessInfo beginActivityWithOptions:reason:] + * for the duration of streaming, releasing the activity on destruction. + * + * On non-Apple platforms it is a no-op (idle sleep does not suspend USB the + * same way; add a platform implementation here if that changes). + */ +class PowerAssertion { +public: + /// @param reason Human-readable reason shown in power diagnostics. + explicit PowerAssertion(const std::string& reason); + ~PowerAssertion(); + + PowerAssertion(const PowerAssertion&) = delete; + PowerAssertion& operator=(const PowerAssertion&) = delete; + +private: + void* token_ = nullptr; ///< retained activity token (id on macOS) +}; + +} // namespace mccoutlet diff --git a/src/core/src/PowerAssertion.cpp b/src/core/src/PowerAssertion.cpp new file mode 100644 index 0000000..89475b7 --- /dev/null +++ b/src/core/src/PowerAssertion.cpp @@ -0,0 +1,10 @@ +// Non-Apple no-op implementation of PowerAssertion. +// macOS uses PowerAssertion.mm instead (selected in src/core/CMakeLists.txt). +#include "mccoutlet/PowerAssertion.hpp" + +namespace mccoutlet { + +PowerAssertion::PowerAssertion(const std::string& /*reason*/) {} +PowerAssertion::~PowerAssertion() = default; + +} // namespace mccoutlet diff --git a/src/core/src/PowerAssertion.mm b/src/core/src/PowerAssertion.mm new file mode 100644 index 0000000..a320c0e --- /dev/null +++ b/src/core/src/PowerAssertion.mm @@ -0,0 +1,33 @@ +// macOS implementation of PowerAssertion. Compiled with ARC (-fobjc-arc); +// see src/core/CMakeLists.txt. +#include "mccoutlet/PowerAssertion.hpp" + +#import + +namespace mccoutlet { + +PowerAssertion::PowerAssertion(const std::string& reason) { + // NSActivityUserInitiated -> disables App Nap and idle system sleep. + // NSActivityLatencyCritical -> also disables timer throttling so the + // libusb event thread keeps servicing high-rate transfers promptly. + NSActivityOptions options = + NSActivityUserInitiated | NSActivityLatencyCritical; + + NSString* why = [NSString stringWithUTF8String:reason.c_str()]; + if (why == nil) why = @"DAQ acquisition"; + + id token = [[NSProcessInfo processInfo] beginActivityWithOptions:options + reason:why]; + // Hand the token to the C++ object; balanced by __bridge_transfer below. + token_ = (__bridge_retained void*)token; +} + +PowerAssertion::~PowerAssertion() { + if (token_) { + id token = (__bridge_transfer id)token_; + [[NSProcessInfo processInfo] endActivity:token]; + token_ = nullptr; + } +} + +} // namespace mccoutlet diff --git a/src/core/src/StreamThread.cpp b/src/core/src/StreamThread.cpp index b72866c..5815926 100644 --- a/src/core/src/StreamThread.cpp +++ b/src/core/src/StreamThread.cpp @@ -1,4 +1,5 @@ #include "mccoutlet/StreamThread.hpp" +#include "mccoutlet/PowerAssertion.hpp" #include namespace mccoutlet { @@ -97,6 +98,14 @@ DeviceInfo StreamThread::getDeviceInfo() const { } void StreamThread::threadFunction() { + // Keep the host awake for the whole streaming session. Idle system sleep + // suspends the USB bus and wedges the DAQ's in-flight transfers (uldaq then + // reports "still xfer pending" and the scan cannot be recovered), and App + // Nap can throttle the libusb event thread enough to overrun the device + // FIFO. Released automatically when this function returns (stop, error, or + // shutdown). No-op on non-Apple platforms. + PowerAssertion keep_awake("Streaming MCC DAQ to LSL"); + try { auto info = device_->getInfo(); LSLOutlet outlet(info); From fc2b41a8165aa64ecd1d6a4dbb942046daeef016 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Sat, 4 Jul 2026 14:25:41 -0400 Subject: [PATCH 5/5] Add raw-timestamp health check to the audio latency test The latency analysis loads XDF with dejitter_timestamps=True, which refits timestamps onto a clean monotonic line and therefore hides any defect in what the outlet actually emitted. Add check_timestamps(), which loads the raw timestamps (no sync, no dejitter) and reports negative values and non-monotonic (backward) steps with their count and magnitude. The check runs at the end of every live or --analyze-xdf run and is informational by default; pass --fail-on-bad-timestamps to exit non-zero when the MCC stream has negative or backward raw timestamps, for use as a regression gate. On existing recordings this surfaces the expected chunk-boundary back-dating artifact (~0.04% backward steps, up to ~9 ms) that liblsl produces when a chunk spans more sample-time than the wall clock elapsed since the previous push; it is erased by any dejittering consumer and does not affect the latency numbers. Co-Authored-By: Claude Fable 5 --- scripts/audio_latency_test/README.md | 24 ++++++++ scripts/audio_latency_test/analysis.py | 77 ++++++++++++++++++++++++++ scripts/audio_latency_test/run_test.py | 25 ++++++++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/scripts/audio_latency_test/README.md b/scripts/audio_latency_test/README.md index 2819eab..ae00d2c 100644 --- a/scripts/audio_latency_test/README.md +++ b/scripts/audio_latency_test/README.md @@ -65,8 +65,32 @@ run_test.py --labrecorder-cli /path/to/LabRecorderCLI # Re-analyze an existing recording (no hardware): run_test.py --analyze-xdf recordings/audio_latency_xxx.xdf + +# Verify timestamps and exit non-zero if the MCC stream is bad: +run_test.py --analyze-xdf recordings/audio_latency_xxx.xdf --fail-on-bad-timestamps ``` +## Timestamp verification + +Every run (live or `--analyze-xdf`) prints a **timestamp health check** for the +MCC stream. Unlike the latency analysis — which loads with +`dejitter_timestamps=True` and so *cleans up* the timestamps — this check loads +the **raw** timestamps exactly as the outlet emitted them +(`synchronize_clocks=False, dejitter_timestamps=False`) and reports: + +- **negative absolute timestamps** — a genuinely bad clock value; +- **non-monotonic (backward) steps** — consecutive samples whose timestamp goes + *backwards*. These are the artifact of stamping each chunk's most-recent + sample with `local_clock()` at read time and letting liblsl back-date the + rest; when one chunk's back-dated start lands before the previous chunk's end, + time runs backward. In a consumer that does **not** dejitter (e.g. Orion), or + across machines, those backward steps are what surface as *negative + timestamps* / negative inter-sample intervals. + +Pass `--fail-on-bad-timestamps` to make the run exit non-zero when the MCC +stream has negative or non-monotonic raw timestamps (useful as a regression +gate once the outlet is fixed to emit monotonic, device-anchored timestamps). + Outputs land in `scripts/audio_latency_test/recordings/`: `audio_latency_.xdf`, `latency_.png`, `latency_.npz`, and the `mcc_.log` CLI log. diff --git a/scripts/audio_latency_test/analysis.py b/scripts/audio_latency_test/analysis.py index 0683669..da65cf8 100644 --- a/scripts/audio_latency_test/analysis.py +++ b/scripts/audio_latency_test/analysis.py @@ -45,6 +45,83 @@ def load_from_xdf(path, mcc_name, marker_type="Markers", mcc_channel=0): return marker_times, mcc_ts, sig +# --------------------------------------------------------------------------- # +# Timestamp health check +# --------------------------------------------------------------------------- # +def check_timestamps(path, mcc_name, marker_type="Markers"): + """Verify the *raw* (un-synced, un-dejittered) timestamps are sound. + + ``load_from_xdf`` deliberately loads with ``dejitter_timestamps=True``, + which refits each stream's timestamps onto a clean monotonic line and so + *hides* upstream defects. This routine instead loads the timestamps exactly + as the outlet emitted them and checks for the two problems that surface + downstream (e.g. in Orion) as "negative timestamps": + + * **negative absolute timestamps** -- a genuinely bad clock value; + * **non-monotonic (backward) steps** -- consecutive samples whose timestamp + goes *backwards*. These become negative inter-sample intervals (and, in a + consumer that derives sample times by accumulating dt or that aligns + without dejittering, negative timestamps). They are the expected artifact + of stamping each chunk's most-recent sample with ``local_clock()`` at read + time and letting liblsl back-date the rest: when one chunk's back-dated + start lands before the previous chunk's end, time runs backward. + + Returns ``(ok, results)`` where ``ok`` is False if any stream has negative + timestamps, and ``results`` maps stream name -> stats dict. Prints a + human-readable summary. + """ + import pyxdf + + streams, _ = pyxdf.load_xdf( + path, synchronize_clocks=False, dejitter_timestamps=False) + + results = {} + ok = True + lines = ["Timestamp health check (raw, as emitted -- no sync/dejitter):"] + for s in streams: + name = s["info"]["name"][0] + ts = np.asarray(s["time_stamps"], dtype=float) + if ts.size == 0: + continue + neg = int(np.count_nonzero(ts < 0)) + dt = np.diff(ts) + back = dt < 0 + n_back = int(np.count_nonzero(back)) + max_back_ms = float(-dt[back].min() * 1e3) if n_back else 0.0 + results[name] = { + "n": int(ts.size), + "t_min": float(ts.min()), "t_max": float(ts.max()), + "n_negative": neg, + "n_backward": n_back, "max_backward_ms": max_back_ms, + "pct_backward": 100.0 * n_back / max(dt.size, 1), + } + if neg: + status = "FAIL (negative timestamps)" + ok = False + elif n_back: + status = "WARN (non-monotonic)" + else: + status = "OK" + detail = f" backward_steps={n_back}" + if n_back: + detail += (f" ({100.0 * n_back / max(dt.size, 1):.2f}% of samples," + f" max {max_back_ms:.3f} ms back)") + lines.append( + f" {name:14} n={ts.size:>8d} t_min={ts.min():14.4f}" + f" neg={neg}{detail} -> {status}") + + mcc = results.get(mcc_name) + if mcc is not None and mcc["n_negative"] == 0 and mcc["n_backward"] > 0: + lines.append( + " NOTE: no negative timestamps on this machine, but the MCC stream " + "is non-monotonic. Across machines (or in a consumer that does not " + "dejitter, e.g. Orion) those backward steps are what appear as " + "negative timestamps. Fix upstream by making the outlet emit " + "monotonic, device-anchored timestamps.") + print("\n".join(lines)) + return ok, results + + # --------------------------------------------------------------------------- # # Core: per-marker onset detection # --------------------------------------------------------------------------- # diff --git a/scripts/audio_latency_test/run_test.py b/scripts/audio_latency_test/run_test.py index 923bae3..b2be9d0 100644 --- a/scripts/audio_latency_test/run_test.py +++ b/scripts/audio_latency_test/run_test.py @@ -111,6 +111,11 @@ def parse_args(argv=None): p.add_argument("--analyze-xdf", default=None, help="Analyze an existing XDF and exit (no hardware)") + # Timestamp verification + p.add_argument("--fail-on-bad-timestamps", action="store_true", + help="Exit non-zero if the MCC stream has negative or " + "non-monotonic (backward) raw timestamps") + return p.parse_args(argv) @@ -156,6 +161,22 @@ def report(args, latencies, marker_times, mcc_ts, mcc_sig, info, tag): return stats +def verify_timestamps(args, xdf_path): + """Run the raw-timestamp health check; return an exit-code contribution. + + Always prints the report. Returns 1 (so callers can OR it into their exit + code) when --fail-on-bad-timestamps is set and the MCC stream has negative + or non-monotonic timestamps; otherwise 0. + """ + print() + ok, results = analysis.check_timestamps(xdf_path, args.mcc_name) + if not args.fail_on_bad_timestamps: + return 0 + mcc = results.get(args.mcc_name, {}) + bad = (not ok) or mcc.get("n_backward", 0) > 0 + return 1 if bad else 0 + + def _terminate(proc, timeout=5.0): if proc is None or proc.poll() is not None: return @@ -185,7 +206,7 @@ def run_analyze_only(args): tag = os.path.splitext(os.path.basename(args.analyze_xdf))[0] os.makedirs(args.outdir, exist_ok=True) report(args, latencies, marker_times, mcc_ts, mcc_sig, info, tag) - return 0 + return verify_timestamps(args, args.analyze_xdf) # --------------------------------------------------------------------------- # @@ -286,7 +307,7 @@ def run_live(args): window=args.window, pre=args.pre, thresh_frac=args.thresh_frac, abs_threshold=args.abs_threshold) report(args, latencies, marker_times, mcc_ts, mcc_sig, info_recs, tag) - return 0 + return verify_timestamps(args, xdf_path) finally: for closer in (