Skip to content

Finalize recordings asynchronously with stop-time catch-up - #148

Draft
cboulay wants to merge 8 commits into
masterfrom
fix/pr146-finalization
Draft

cboulay wants to merge 8 commits into
masterfrom
fix/pr146-finalization

Conversation

@cboulay

@cboulay cboulay commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Builds on Stefan Appelhoff’s (@sappelhoff) work in #146, retaining its commits and integration tests. This draft proposes a follow-up design for the same GUI-freeze and incomplete-XDF-footer problems.

Stop now captures a recorder-clock cutoff and starts asynchronous finalization. Inlets stay subscribed while delayed pre-stop timestamps advance, so a stream that is catching up can continue beyond five seconds. Clock correction is used for cutoff comparisons without rewriting stored timestamps or correcting an already synchronized inlet twice. Bounded receive batches keep stop checks responsive.

The GUI distinguishes catching up, waiting, and closing the file. Finish now ends collection early while preserving normal footer/close handling; an explicit force-quit option remains available for stalled workers or file operations. The CLI timeout measures inactivity, and progress on one inlet cannot conceal another inlet’s stall. Completion is reported only after workers finish and the writer is flushed and closed.

Each stream has a two-second grace without advancing pre-stop timestamps, including a window for reordered samples. The footer records the cutoff and why collection ended; the GUI notes streams that ended without observing the cutoff. This is a bounded fallback for silent streams and unreliable clock/timestamp information, not a guarantee that upstream buffers are empty. Data arriving after the grace can be missed, and force quitting can still leave an incomplete file.

Validation on macOS with liblsl v1.18.0.b3:

  • GUI and CLI builds passed.
  • All 16 native/GUI/CLI tests passed, including stalled workers, independent stream progress, clock synchronization, non-advancing timestamps, and the Finish now/force-quit paths.
  • All eight real-stream scenarios passed, including catch-up beyond five seconds, reordered pre-stop arrivals, exclusion of post-stop data, XDF footer integrity, and exact timestamp preservation.

Related: #146

sappelhoff and others added 8 commits August 27, 2026 10:24
…ples

Follow-up to the interruptible-teardown work, addressing review feedback.

Shutdown flags are now published under the mutex that the condition
variable predicates read them under. Setting an atomic outside that mutex
and then notifying leaves a window in which a waiter that has just
evaluated its predicate as false enters the wait and misses the
notification, so the offset thread could still sleep out its full
five-second interval.

The blocking calls that a stop could not interrupt are now issued in
short slices that observe the shutdown flag:

- stream_inlet::info() was called twice with the default infinite
  timeout. close_stream() only stops the data receiver, so an
  unreachable metadata endpoint blocked a stop indefinitely. The info is
  now fetched once and reused for the header and the nominal rate.
- open_stream() could hold a stop for up to max_open_wait.
- time_correction() could hold it for the full query timeout.
- The watchlist resolver blocked for a whole resolve_interval; it now
  resolves briefly and waits out the rest interruptibly.
- The phase gates could park a stream for max_headers_wait with no way
  out, so a stream could lose its footer waiting for one that had hung.

Joining is bounded for the first time: try_join_once() called
std::thread::join(), which has no timeout, so polling it could never
enforce max_join_wait. Threads are now paired with a future that becomes
ready when the body returns, which can be waited on with a deadline.

Closing the inlets the moment stop is pressed discards everything still
buffered in them; a recording of 40 markers came back with 2. Inlets are
now closed only after the stream threads have been given a grace period
to drain and write their footers, and the transfer loop does a final
non-blocking pull on the way out, so a stop no longer costs samples that
had already arrived.

Also fixed along the way: record_offsets() wrote uninitialised offset and
timestamp values into the file when a time correction query timed out;
the inlet bookkeeping leaked a registration on every exception path; and
a stream that failed mid-recording was left without a footer although its
header was already on disk.

scripts/test_recording_teardown.py now covers a plain stop, a stop before
the first sample, a stop while subscribing to a source that has gone
away, repeated start/stop cycles, and that no buffered sample is lost. It
checks the exit status and the footers of every stream, holds one stated
teardown budget instead of documenting one and asserting another, and
runs on all three platforms in CI.
…in a gated stream

Addresses the second round of review on the teardown work.

The future-based join added in the previous commit is the first version
that can actually reach the detach path, because the blocking join() it
replaced never returned for a hung thread. Detached threads ran on a raw
`this` and wrote into the file, the mutexes and the offset lists, all of
which the destructor had already destroyed. Recording state and the
thread bodies now live in a shared implementation object that every
thread holds a reference to, so the state outlives a teardown that had
to leave a thread running, and the last one to finish closes the file.
A harness that stalls a stream thread past the join deadline faults with
an access violation and writes no footer before this change, and exits
cleanly with both footers intact after it.

The gate that lets a stream wait for another stream's header is released
on shutdown, which meant a stream could reach its transfer loop with the
shutdown already set, pull no first sample, and then skip the final
drain because it had no first timestamp to compare against -- writing a
zero-sample footer although its inlet had been subscribed and buffering
the whole time. The first sample now anchors the stream wherever it
arrives from, including from the drain, and the drain is unconditional.

Two things found while testing this:

Splitting the time correction query into network_poll_interval slices
was unsound: the query needs a round trip to complete, so restarting it
every 200 ms means it need never finish. It goes back to a single call
with the full budget, as before the teardown work. Teardown stays
bounded because the transfer thread now stops waiting for the offset
thread after the teardown grace period and leaves it running, which is
safe now that a thread left running keeps its state alive.

Recording threads all logged through unsynchronised << chains, so their
output interleaved mid-line. Lines are now composed and written under a
mutex.

scripts/test_recording_teardown.py gains a case for the gated stream: it
holds one stream at the headers gate behind another whose source has
gone away, buffers 40 samples into it and stops. Before the drain fix
that case records 0 of 40.
Poll clock correction without restarting liblsl's background measurement, and join every worker before releasing the writer. A slow worker must not leave the CLI with buffered output owned by a detached thread at process exit.

Add deterministic finalization regressions to CI and verify real clock offsets in the integration suite.
…ed exit

Keep the interface responsive while workers drain and the writer closes. Report finishing and stalled states, require confirmation for GUI force quit, and enforce a configurable CLI finalization deadline with nonzero exit on timeout.

Checkpoint complete XDF chunks, flush footers immediately, and report worker or output errors instead of premature success. Preserve interruptible waits and buffered-sample draining. Add backend, CLI, and Qt state-machine regressions including a permanently stalled worker and forced process exit.
@sappelhoff

Copy link
Copy Markdown
Contributor

Thanks -- @cboulay, I think this is an improvement, I iterated on a review with a Claude Opus 5 Max agent, please see the points below, especially 1-3.


Reviewed by source inspection against master, #146 and this branch at 292386a, plus liblsl v1.18.0 time_receiver semantics for the zero-timeout time_correction claim. I did not build or run either branch.

1. Sustained throughput is capped at ~2 kHz per stream

typed_transfer_loop pulls at most 1024 samples per cycle and then sleeps chunk_interval (500 ms) whenever it is not stopping. That is a ceiling of roughly 2048 samples/s. master and #146 issued one uncapped pull_chunk_multiplexed per cycle, so a cycle drained whatever had accumulated.

Any stream above ~2 kHz now falls permanently behind, the inlet backlog grows until liblsl's 360 s buffer is full, and samples are dropped at the source. 1 kHz-5 kHz EEG is common with this tool. Nothing in the suite detects it: EEG_RATE = 100.0.

Minimal fix: skip the chunk_interval wait when the batch filled the cap, so the bound limits stop-check latency rather than throughput. Better: keep pull_chunk_multiplexed on the !stopping path and use the per-sample path only during catch-up, where the per-sample cutoff comparison is actually needed.

2. shutdown_ is set at Stop, so the phase gates are open for the whole catch-up window

stop_and_join() calls impl::requestStop() as soon as Stop is requested, so shutdown_ is set for the entire catch-up period, which can now be seconds or minutes. Two constructs introduced in #146 assumed shutdown meant "finishing in ~100 ms" and no longer hold:

  • The || shutdown_.load() terms in enter_streaming_phase and enter_footers_phase drop the gate. They now fire on every stop, so a stream that finishes its grace early writes its footer while another is still writing data chunks. Unsorted output becomes the normal case rather than the hung-stream case. unsorted_ is never set (true in master too), so nothing records that it happened.
  • fetch_info allows only teardown_grace (300 ms) once shutdown_ is set. A stream with slow metadata therefore loses its header and its footer, at the moment the recorder is otherwise willing to wait a minute for a different stream to catch up.

Suggest separating "stop taking on new work" (watchlist resolve, offset queries) from "finalize now", and keying the phase gates and fetch_info on the latter.

3. Minimum Stop-to-finalized latency is >= 2 s, unconditionally

last_advance is reset when stopping first becomes true, and the loop can only exit after inactivity_grace (2 s) without an advancing pre-stop timestamp. Once saw_cutoff is true the stream has demonstrably passed the cutoff and every further sample is being discarded, yet it still burns the full grace. That is why --max-stop had to move from 1.0 s to 4.0 s.

Worth deciding explicitly. A shorter reorder window after saw_cutoff would keep the common stop fast without giving up reordering tolerance.

4. The "ended without confirming the cutoff" banner will fire on nearly every recording

if (end_reason != "cutoff_observed") ++control_->fallback_streams;, surfaced as "N stream(s) ended without confirming the cutoff".

A sparse marker stream never emits a post-cutoff sample, and neither does any stream whose source was closed before the operator pressed Stop, which is the usual order of events at the end of a session. Both end as inactivity_timeout. The warning is therefore on in the healthy case, and the signal is lost when it matters.

5. unmapped_samples is sticky and masks cutoff_observed

record_offsets waits offset_interval (5 s) before its first time_correction, unchanged from master. For any recording stopped inside the first 5 s, liblsl's time receiver has never run, so the first time_correction(0.0) throws and every sample pulled in that batch sets unmapped_samples = true. The flag is never cleared, so the footer reports clock_unavailable even though the correction arrives milliseconds later and the cutoff is then observed cleanly.

The test double returns 100.0 instantly for the queued modes, so no test sees this.

6. "Finish now" breaks before the final drain

if (stopping && control_->finish_collecting) break; sits at the top of the outer loop, before the pull batch. Samples already buffered in the inlet at that moment are lost, which is the failure the gated stream is drained case exists to prevent. The confirmation dialog states that it "finalizes the file with the samples already saved". One non-blocking drain before the break would match the wording.

7. Post-cutoff samples are discarded, which is a user-visible semantic change

Samples whose corrected timestamp exceeds the cutoff are dropped with continue. This is the intended design, but master and #146 wrote everything that was buffered. A paradigm that pushes a final marker as the operator presses Stop will silently lose it, and a wrong clock correction discards up to the grace window of legitimate data. Worth stating in the PR body and release notes as a behaviour change, not only in README prose.

8. Smaller items

  • tcpinterface.cpp: status replies with the state plus a newline, every other command replies OK with no terminator. The missing newline predates this PR, but the README now documents both, and a line-oriented client cannot use one read for both.
  • timer->start(100) with QFileInfo::size() still on the UI thread: 10 stats/s on the file being written, previously 1/s. Suggest splitting the size poll (1 Hz) from the finalization poll (100 ms).
  • confirmForceQuit runs a nested event loop while the 100 ms timer keeps firing statusUpdate(), which can currentRecording.reset(), open a second modal box and call close() underneath the open dialog. The && currentRecording guards hold, but it is fragile.
  • ~recording() is now only requestStop(), so ~MainWindow no longer blocks. Any quit path that bypasses closeEvent leaves the file at its last checkpoint. closeEvent covers the normal paths, so this is narrow.
  • The CLI has no equivalent of "Finish now"; its only escape is _Exit(3). Not a regression, just a parity gap in the new design.
  • tests/fake_lsl/lsl_cpp.h is a hand-written double that src/recording.cpp and src/clirecorder.cpp are compiled against. Deterministic and worth having, but the CLI it exercises is not the shipped binary, and the double already diverges from liblsl on time_correction(0.0), which is what hides point 5.

Worth noting

write_chunk now does last_timestamp = ts instead of last_timestamp = ts + sample_interval. The old form is in master and was carried into #146: it left last_timestamp one interval ahead after every elided sample, so deduction could only ever compress every second sample, and the footer's last_timestamp was off by one interval when the last sample was elided. Real pre-existing bug, fixed here and covered by case_delayed_upstream.

Also solid: XDFWriter::exceptions(badbit | failbit) turns a silently unwritable output path into a startup error the GUI reports; the periodic and forced flushes make a force-quit file recoverable; and joining every worker before file_.close() removes the detached-writer hazard from #146 without the lifetime gymnastics.

Suggested verification for the follow-up

  • For point 1, add a case at EEG_RATE = 5000 recording ~10 s and assert the recorded sample count is within a few percent of rate * duration. It should fail on the current head.
  • For point 3, assert the stop duration of case_normal_stop against a tightened --max-stop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants