Fix ALSA playback clock after device recovery - #55
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Playback accounting remains vulnerable to callback ordering, stream flushes, and partial writes during device loss.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Fixes ALSA playback-clock drift after device recovery by retiring frames discarded during outages.
Changes:
- Adds saturating discarded-frame accounting.
- Reports discarded frames after ALSA recovery.
- Adds gap and overflow unit tests.
File summaries
| File | Description |
|---|---|
src/alsa_sink.cpp |
Tracks and reports discarded ALSA frames. |
src/sink_recovery.cpp |
Implements saturating frame accounting. |
src/sink_recovery.h |
Declares discarded-frame state and APIs. |
tests/sink_recovery_test.cpp |
Tests one-time reporting and saturation. |
Review details
Suppressed comments (1)
src/alsa_sink.cpp:618
- Accounting only in the already-closed-device branch misses frames accepted earlier in a
write()call that later detects device loss. In that caseframes_doneis returned to the producer, but the normal callback is skipped becauserecover_()closedpcm_; those frames remain permanently buffered even after this gap is retired. When a write exits because recovery closed the device, add its already-writtenframes_doneto the discarded count as well.
const size_t consumed = (frame == 0) ? length : length - (length % frame);
if (frame != 0) {
this->recovery_.discard_frames(static_cast<uint32_t>(consumed / frame));
}
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
poll() reported the gap after releasing the lock, so the sync task's first real write could report first and still carry the whole outage. Take the gap with the first device timestamp instead and report both in one call. Also drop the gap on clear() and configure(), and count frames written just before the device was lost.
|
I pushed two commits on top of Igor's fix that address the Copilot review. The outage gap is now retired together with the first real device timestamp instead of from @mguaylam, could you rerun your test from #54 with this branch on the OpenWrt box? Start playback, unplug the USB DAC, plug it back in, and check that:
I'm keeping this PR in draft until that hardware test passes. The same gap in the Pulse, PortAudio and PipeWire sinks is tracked in #58. |
|
Also big thank you to @iibaranov-IG for jumping in! Appreciate the energy and contribution! |
|
Tested on the OpenWrt box from #54. Playback resumes on its own, with no XRUN loop and no sync cycling. Setup The host is a plain home router, a D-Link DIR-3040 (MediaTek MT7621AT, dual-core MIPS at 880 MHz, no FPU, 256 MB RAM), with the DAC on one of its USB ports. It routes and serves WiFi while playing.
My OpenWrt package normally works around this bug by restarting the player when a playback device appears. That workaround was moved out of the way for this test, so the recovery below is this branch's alone. What happened: playing, DAC unplugged at 00:09:40, plugged back in at 00:09:54. Nothing after that: no underrun, no further sync message, and audio was audible and steady.
For contrast, on v0.1.6 the same sequence left One observation, not a blocker: a single Happy to rerun anything, including a longer outage or a second replug during the same stream. |
|
Thanks — and that's normal. At reopen the playhead is correct, but the sink swallowed the pipeline's lookahead during the outage, so the next chunk isn't due for ~10.8 s. The sync task reports that once and fills silence until the timeline We may be able to do better by pacing the discard during an outage so the lookahead survives & I opened #63 for that. If you have time: try unplugging and replugging twice in the same stream. That path uses the remaining retry budget and retires a second gap, which your run didn't reach. |
…ack-clock Keep the gap accounting and take main's comment style for it; the sink's own changes on main were formatting only.
|
Second replug tested on the same DIR-3040, now on head First outage recovers, second one never does. The first cycle is what you designed for: one After the second unplug there is nothing at all: no Reading
So the four remaining rescans of This is not something this PR introduced: the same |
|
Lets open a new issue, and keep that conversation going @mguaylam |
Fixes #54.
What goes wrong
While an ALSA device is absent,
AlsaAudioSink::write()deliberately returns the discarded byte count so the Sendspin sync task does not spin. The sync task therefore adds every discarded frame tobuffered_frames.Before this change, nothing retired those frames. When the reopened PCM accepted its first real write, the normal playback callback combined the new ALSA finish timestamp with the entire outage-sized backlog. After the reporter's 14-second gap at 48 kHz, that projected the playhead 672,000 frames into the future. The resulting hard-sync cycle matches the reported short writes, XRUNs, and alternating
Lost sync/Regained syncmessages.What changes
SinkRecoverykeeps a saturating count of the frames discarded during an outage.That count is retired by the first
write()after recovery that gets a real device timestamp. It takes the count underdevice_mutex_, together with thesnd_pcm_delay()reading, and then fires a singleon_frames_played(gap + frames_done, finish_us)after unlocking, saturating atUINT32_MAX. A write that gets no timestamp leaves the count pending.poll()never reports the gap itself. Reporting it there — after releasing the mutex — would let the sync task's first real write report ahead of it, which both reintroduces the outage-sized backlog on that first timestamp and can deliver timestamps out of order. Because the gap now travels with the write, there is no second callback to race, and the timestamp the player keeps is the device's own finish time rather than a wall-clock reading.poll()keeps its original structure.Two narrower leaks are closed with it:
clear(), before its null-PCM early return, and at the top ofconfigure(), so it goes whether or not the open succeeds. A gap from an ended or reconfigured stream can no longer be retired against the next one. Neither call refills the recovery budget, which staysreset()'s job.snd_pcm_writei()accepted beforerecover_()closed the device in the samewrite()call are added to the count.write()returns those bytes, but no timestamp would ever have reported them.Tests
22
SinkRecoverytests, including the report's 14 seconds at 48 kHz retired exactly once, saturation instead of wrapping, the gap surviving a recovered rescan until a timed write takes it, dropping the gap leaving both a live and a spent budget untouched, andreset()clearing it.Verification
-DSENDSPIN_CLI_WERROR=ONand ALSA, Pulse and PipeWire enabled, against the v0.8.0 library pin; full suite 434/434.c5b5279: shellcheck, clang-format, and builds for linux-x86_64, x86_64-nomdns, x86_64-pipewire-min, armv7, arm64 and macos-arm64.a6b1c23; everything merged since is comment and formatting only (full report in the comments): playback resumes on its own with no XRUN loop and no sync cycling. The PCM stayedRUNNINGacross 144 samples andhw_ptradvanced continuously for about 5 minutes after recovery, in the same process. The reopen attempts landed 2 s, 4 s and 8 s after the loss, matchingSINK_RESCAN_DELAY_MSand its doubling.Recovery still costs one bounded realignment: the playhead is correct at reopen, but the audio the sink swallowed during the outage included the pipeline's lookahead, so the next chunk is not due yet and the sync task reports one error and fills silence until the timeline catches up. That is tracked separately in #63.
Compatibility
Nothing breaks for CLI users. No flags, config-file keys, control-socket protocol, state-store format or exit codes change, and no default moves. The only behaviour change is confined to an ALSA device that dies mid-stream: the frames discarded during the outage are now reported as played once the device is back, so the player's playback clock is no longer projected forward by the length of the outage.
The same discard-without-retire path exists in the Pulse, PortAudio and PipeWire sinks and is tracked in #58.