From 271616804f9e3efd917988d00979ff528c62fca5 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 11:27:36 +0200 Subject: [PATCH 01/25] subprojects: update openssl to 3.0.10-1 --- subprojects/openssl.wrap | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/subprojects/openssl.wrap b/subprojects/openssl.wrap index 873d55106e..e775bb104f 100644 --- a/subprojects/openssl.wrap +++ b/subprojects/openssl.wrap @@ -1,15 +1,14 @@ [wrap-file] -directory = openssl-3.0.8 -source_url = https://www.openssl.org/source/openssl-3.0.8.tar.gz -source_filename = openssl-3.0.8.tar.gz -source_hash = 6c13d2bf38fdf31eac3ce2a347073673f5d63263398f1f69d0df4a41253e4b3e -patch_filename = openssl_3.0.8-3_patch.zip -patch_url = https://wrapdb.mesonbuild.com/v2/openssl_3.0.8-3/get_patch -patch_hash = 300da189e106942347d61a4a4295aa2edbcf06184f8d13b4cee0bed9fb936963 -source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/openssl_3.0.8-3/openssl-3.0.8.tar.gz -wrapdb_version = 3.0.8-3 +directory = openssl-3.0.10 +source_url = https://www.openssl.org/source/openssl-3.0.10.tar.gz +source_filename = openssl-3.0.10.tar.gz +source_hash = 1761d4f5b13a1028b9b6f3d4b8e17feb0cedc9370f6afe61d7193d2cdce83323 +source_fallback_url = https://wrapdb.mesonbuild.com/v2/openssl_3.0.10-1/get_source/openssl-3.0.10.tar.gz +patch_filename = openssl_3.0.10-1_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/openssl_3.0.10-1/get_patch +patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/openssl_3.0.10-1/openssl_3.0.10-1_patch.zip +patch_hash = 2d142b7e3b1ac092cf67cb4891594c4a2d044aa92624c617a8dcbfe4f056d907 +wrapdb_version = 3.0.10-1 [provide] -libcrypto = libcrypto_dep -libssl = libssl_dep -openssl = openssl_dep +dependency_names = libcrypto, libssl, openssl From cadedb3a28d963281e6a7cbcd69375adf63c804f Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 11:27:44 +0200 Subject: [PATCH 02/25] subprojects: update sqlite3 to 3.53.4-1 --- subprojects/sqlite3.wrap | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/subprojects/sqlite3.wrap b/subprojects/sqlite3.wrap index 55186ed25e..ce5b8446f2 100644 --- a/subprojects/sqlite3.wrap +++ b/subprojects/sqlite3.wrap @@ -1,14 +1,14 @@ [wrap-file] -directory = sqlite-amalgamation-3530300 -source_url = https://www.sqlite.org/2026/sqlite-amalgamation-3530300.zip -source_filename = sqlite-amalgamation-3530300.zip -source_hash = 646421e12aac110282ef8cc68f1a62d4bb15fc7b8f09da0b53e29ee690500431 -source_fallback_url = https://wrapdb.mesonbuild.com/v2/sqlite3_3.53.3-1/get_source/sqlite-amalgamation-3530300.zip -patch_filename = sqlite3_3.53.3-1_patch.zip -patch_url = https://wrapdb.mesonbuild.com/v2/sqlite3_3.53.3-1/get_patch -patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/sqlite3_3.53.3-1/sqlite3_3.53.3-1_patch.zip -patch_hash = acd1a0cca89d7d4b50375002e61eaf67f5a432b6801721fa38cab05a9f1e0385 -wrapdb_version = 3.53.3-1 +directory = sqlite-amalgamation-3530400 +source_url = https://www.sqlite.org/2026/sqlite-amalgamation-3530400.zip +source_filename = sqlite-amalgamation-3530400.zip +source_hash = 1e71ddf93849c6a6ecf58b827c0692073d2dd7ee40196158068f7b29f422e87d +source_fallback_url = https://wrapdb.mesonbuild.com/v2/sqlite3_3.53.4-1/get_source/sqlite-amalgamation-3530400.zip +patch_filename = sqlite3_3.53.4-1_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/sqlite3_3.53.4-1/get_patch +patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/sqlite3_3.53.4-1/sqlite3_3.53.4-1_patch.zip +patch_hash = fe8033e209d350fa74e52ce1398253b373e7cfdd6809bc9f304e409e76f108af +wrapdb_version = 3.53.4-1 [provide] dependency_names = sqlite3 From 944d0a8b1e215a76e99d1552c4ae61ea286403b5 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 11:05:01 +0200 Subject: [PATCH 03/25] output/PipeWire: zero-initialize the spa_audio_info_dsd variable spa_format_audio_dsd_parse() only sets the fields that are present in the `spa_pod`. Fields that are omitted there will be left initialized. I guess zero-initializing the struct is the best way to fix this. --- NEWS | 1 + src/output/plugins/PipeWireOutputPlugin.cxx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 07aa88c6a8..cd829e017e 100644 --- a/NEWS +++ b/NEWS @@ -13,6 +13,7 @@ ver 0.24.14 (not yet released) * output - osx: fix format selection bugs - osx: fix volume truncation + - pipewire: fix uninitialized variable * Windows - enable the "mpg123" decoder plugin - fix shutdown in console mode diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 328a4276f3..6a546408e0 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -645,7 +645,7 @@ inline void PipeWireOutput::DsdFormatChanged(const struct spa_pod ¶m) noexcept { uint32_t media_type, media_subtype; - struct spa_audio_info_dsd dsd; + struct spa_audio_info_dsd dsd{}; if (spa_format_parse(¶m, &media_type, &media_subtype) >= 0 && media_type == SPA_MEDIA_TYPE_audio && From dccf8ca82877810977fe5d7fbedcc3d5529cf5bc Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 11:10:22 +0200 Subject: [PATCH 04/25] output/PipeWire: document locking requirements for field `error_message` --- src/output/plugins/PipeWireOutputPlugin.cxx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 6a546408e0..519107c5dd 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -53,6 +53,12 @@ class PipeWireOutput final : AudioOutput { struct pw_thread_loop *thread_loop = nullptr; struct pw_stream *stream; + /** + * If #disconnected, this contains a human-readable + * description of the problem. Used by CheckThrowError(). + * + * Protected by #thread_loop's lock. + */ std::string error_message; std::byte pod_buffer[1024]; @@ -170,6 +176,9 @@ class PipeWireOutput final : AudioOutput { } private: + /** + * Caller must lock the #thread_loop. + */ void CheckThrowError() { if (disconnected) { if (error_message.empty()) From c6b305c150725a3500f8f780bc46d7b133a5c28f Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 11:12:50 +0200 Subject: [PATCH 05/25] output/PipeWire: move CheckThrowError() in SendTag() under lock protection --- NEWS | 1 + src/output/plugins/PipeWireOutputPlugin.cxx | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS b/NEWS index cd829e017e..91ce9e6641 100644 --- a/NEWS +++ b/NEWS @@ -14,6 +14,7 @@ ver 0.24.14 (not yet released) - osx: fix format selection bugs - osx: fix volume truncation - pipewire: fix uninitialized variable + - pipewire: fix thread-safety bug * Windows - enable the "mpg123" decoder plugin - fix shutdown in console mode diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 519107c5dd..d60cb57c19 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -930,8 +930,6 @@ PipeWireOutput::SetMixer(PipeWireMixer &_mixer) noexcept void PipeWireOutput::SendTag(const Tag &tag) { - CheckThrowError(); - static constexpr struct { TagType mpd; const char *pipewire; @@ -959,6 +957,7 @@ PipeWireOutput::SendTag(const Tag &tag) struct spa_dict dict = SPA_DICT_INIT(items.data(), (uint32_t)items.size()); const PipeWire::ThreadLoopLock lock(thread_loop); + CheckThrowError(); auto rc = pw_stream_update_properties(stream, &dict); if (rc < 0) From be443ad528d9af7e81774a7c7bb6fe8e237721f8 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 11:43:13 +0200 Subject: [PATCH 06/25] output/PipeWire: fix (unreachable) buffer leak --- src/output/plugins/PipeWireOutputPlugin.cxx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index d60cb57c19..a8c4d0880f 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -766,8 +766,17 @@ PipeWireOutput::Process() noexcept auto &d = buffer.datas[0]; const std::span dest{reinterpret_cast(d.data), d.maxsize}; - if (dest.data() == nullptr) + if (dest.data() == nullptr) { + /* this is not supposed to happen: due to + PW_STREAM_FLAG_MAP_BUFFERS, libpipewire maps all + buffers for us, except for DmaBufs which are not + marked mappable, and we never negotiate DmaBuf; but + just in case, give the buffer back instead of + leaking it */ + d.chunk->size = 0; + pw_stream_queue_buffer(stream, b); return; + } std::size_t chunk_size = frame_size; From d1c1c055a1f0a52b0619b94e1ba1f1178d60c245 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 12:00:02 +0200 Subject: [PATCH 07/25] output/PipeWire: check for pw_properties_new() failure --- src/output/plugins/PipeWireOutputPlugin.cxx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index a8c4d0880f..b914b070a9 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -506,6 +506,8 @@ PipeWireOutput::Open(AudioFormat &audio_format) PW_KEY_APP_NAME, "Music Player Daemon", PW_KEY_APP_ICON_NAME, "mpd", nullptr); + if (props == nullptr) + throw MakeErrno("pw_properties_new() failed"); pw_properties_setf(props, PW_KEY_NODE_NAME, "mpd.%s", name); From d50b88171360fd1d1682cb2531c7e2fd27c86f2a Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 12:13:17 +0200 Subject: [PATCH 08/25] output/PipeWire: do not acquire the thread_loop lock in Delay() This was never necessary (the `paused` field is only accessed in the output thread) and could cause deadlocks with the PipeWire mixer plugin. --- NEWS | 1 + src/output/plugins/PipeWireOutputPlugin.cxx | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/NEWS b/NEWS index 91ce9e6641..a511277e3b 100644 --- a/NEWS +++ b/NEWS @@ -15,6 +15,7 @@ ver 0.24.14 (not yet released) - osx: fix volume truncation - pipewire: fix uninitialized variable - pipewire: fix thread-safety bug + - pipewire: fix deadlock bug * Windows - enable the "mpg123" decoder plugin - fix shutdown in console mode diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index b914b070a9..3179f24dc4 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -819,8 +819,6 @@ PipeWireOutput::Process() noexcept std::chrono::steady_clock::duration PipeWireOutput::Delay() const noexcept { - const PipeWire::ThreadLoopLock lock(thread_loop); - auto result = std::chrono::steady_clock::duration::zero(); if (paused) /* idle while paused */ From 79b58812c8a877d95dfc1efc2b57712f392b1ae8 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 17:52:16 +0200 Subject: [PATCH 09/25] output/{alsa,PipeWire}: defer xrun logging to the output thread In a real-time thread which is already under pressure due to xruns, we shouldn't do any logging I/O to log these xruns. Instead, set an atomic_bool and let the output thread do this. --- NEWS | 2 ++ src/output/plugins/AlsaOutputPlugin.cxx | 20 ++++++++++++++++++-- src/output/plugins/PipeWireOutputPlugin.cxx | 19 ++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/NEWS b/NEWS index a511277e3b..da0f7f7581 100644 --- a/NEWS +++ b/NEWS @@ -11,8 +11,10 @@ ver 0.24.14 (not yet released) - mad: ignore implausible Xing frame counts - modplug, openmpt: fix error handling bug * output + - alsa: remove logging calls from the real-time thread - osx: fix format selection bugs - osx: fix volume truncation + - pipewire: remove logging calls from the real-time thread - pipewire: fix uninitialized variable - pipewire: fix thread-safety bug - pipewire: fix deadlock bug diff --git a/src/output/plugins/AlsaOutputPlugin.cxx b/src/output/plugins/AlsaOutputPlugin.cxx index 3b93d36c38..3263b6cdb5 100644 --- a/src/output/plugins/AlsaOutputPlugin.cxx +++ b/src/output/plugins/AlsaOutputPlugin.cxx @@ -233,6 +233,13 @@ class AlsaOutput final std::atomic_bool paused; + /** + * Set to true from a real-time thread to ask the output + * thread to log an xrun which required the producer to + * generate silence. + */ + std::atomic_bool silence_inserted; + public: AlsaOutput(EventLoop &loop, const ConfigBlock &block); @@ -765,6 +772,7 @@ void AlsaOutput::Open(AudioFormat &audio_format) { paused = false; + silence_inserted.store(false, std::memory_order_relaxed); #ifdef ENABLE_DSD bool dop; @@ -1215,6 +1223,13 @@ AlsaOutput::Play(std::span src) assert(!src.empty()); assert(src.size() % in_frame_size == 0); + if (silence_inserted.load(std::memory_order_relaxed)) { + silence_inserted.store(false, std::memory_order_relaxed); + + if (throttle_silence_log.CheckUpdate(std::chrono::seconds(5))) + LogWarning(alsa_output_domain, "Decoder is too slow; playing silence to avoid xrun"); + } + paused = false; const size_t max_frames = LockWaitWriteAvailable(); @@ -1332,8 +1347,9 @@ try { return; } - if (throttle_silence_log.CheckUpdate(std::chrono::seconds(5))) - LogWarning(alsa_output_domain, "Decoder is too slow; playing silence to avoid xrun"); + /* this is a real-time thread, so we must not log + here; let the output thread do it */ + silence_inserted.store(true, std::memory_order_relaxed); /* insert some silence if the buffer has not enough data yet, to avoid ALSA xrun */ diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 3179f24dc4..96ae6fad19 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -144,6 +145,13 @@ class PipeWireOutput final : AudioOutput { bool drained; + /** + * Set to true from a real-time thread to ask the output + * thread to log an xrun which required the producer to + * generate silence. + */ + std::atomic_bool silence_inserted; + explicit PipeWireOutput(const ConfigBlock &block); public: @@ -492,6 +500,7 @@ PipeWireOutput::Open(AudioFormat &audio_format) restore_volume = true; paused = false; + silence_inserted.store(false, std::memory_order_relaxed); /* stay inactive (PW_STREAM_FLAG_INACTIVE) until the ring buffer has been filled */ @@ -797,7 +806,9 @@ PipeWireOutput::Process() noexcept nbytes = max_chunks * chunk_size; PcmSilence(dest.first(nbytes), sample_format); - LogWarning(pipewire_output_domain, "Decoder is too slow; playing silence to avoid xrun"); + /* this is a real-time thread, so we must not log + here; let the output thread do it */ + silence_inserted.store(true, std::memory_order_relaxed); } auto &chunk = *d.chunk; @@ -830,6 +841,12 @@ PipeWireOutput::Delay() const noexcept std::size_t PipeWireOutput::Play(std::span src) { + if (silence_inserted.load(std::memory_order_relaxed)) { + silence_inserted.store(false, std::memory_order_relaxed); + + LogWarning(pipewire_output_domain, "Decoder is too slow; playing silence to avoid xrun"); + } + const PipeWire::ThreadLoopLock lock(thread_loop); paused = false; From b9cedbb9dbe15bc87107f04ec2554f4f86f3eeea Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 18:05:34 +0200 Subject: [PATCH 10/25] output/PipeWire: throttle xrun/silence log messages --- src/output/plugins/PipeWireOutputPlugin.cxx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 96ae6fad19..3209f04fd5 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -10,6 +10,7 @@ #include "pcm/Silence.hxx" #include "lib/fmt/ExceptionFormatter.hxx" #include "system/Error.hxx" +#include "time/PeriodClock.hxx" #include "util/BitReverse.hxx" #include "util/Domain.hxx" #include "util/RingBuffer.hxx" @@ -152,6 +153,8 @@ class PipeWireOutput final : AudioOutput { */ std::atomic_bool silence_inserted; + PeriodClock throttle_silence_log; + explicit PipeWireOutput(const ConfigBlock &block); public: @@ -844,7 +847,8 @@ PipeWireOutput::Play(std::span src) if (silence_inserted.load(std::memory_order_relaxed)) { silence_inserted.store(false, std::memory_order_relaxed); - LogWarning(pipewire_output_domain, "Decoder is too slow; playing silence to avoid xrun"); + if (throttle_silence_log.CheckUpdate(std::chrono::seconds(5))) + LogWarning(pipewire_output_domain, "Decoder is too slow; playing silence to avoid xrun"); } const PipeWire::ThreadLoopLock lock(thread_loop); From ac5bde99170c4915c80a22b679fd4502eab3874f Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 18:19:18 +0200 Subject: [PATCH 11/25] output/PipeWire: clear ring buffer after pw_stream_flush() Clearing the ring buffer is not thread-safe, thus we are only allowed to do that after pw_stream_flush() has ensured that Process() will not be called again (and has possibly waited until a currently running Process() has returned). --- NEWS | 2 +- src/output/plugins/PipeWireOutputPlugin.cxx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/NEWS b/NEWS index da0f7f7581..66901b4089 100644 --- a/NEWS +++ b/NEWS @@ -16,7 +16,7 @@ ver 0.24.14 (not yet released) - osx: fix volume truncation - pipewire: remove logging calls from the real-time thread - pipewire: fix uninitialized variable - - pipewire: fix thread-safety bug + - pipewire: fix thread-safety bugs - pipewire: fix deadlock bug * Windows - enable the "mpg123" decoder plugin diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 3209f04fd5..7208087329 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -914,9 +914,6 @@ PipeWireOutput::Cancel() noexcept if (drained) return; - /* clear MPD's ring buffer */ - ring_buffer.Clear(); - /* clear libpipewire's buffer */ pw_stream_flush(stream, false); drained = true; @@ -929,6 +926,11 @@ PipeWireOutput::Cancel() noexcept active = false; pw_stream_set_active(stream, false); } + + /* clear MPD's ring buffer; this must be done only after the + "process" callback has been disabled, because + RingBuffer::Clear() is not thread-safe */ + ring_buffer.Clear(); } bool From ad0a3616f3c2704c0ea0f1f251cc05c7cfcc7490 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 18:41:43 +0200 Subject: [PATCH 12/25] test/util/TestRingBuffer: add tests for ReadFramesTo(), WriteFramesFrom() --- test/util/TestRingBuffer.cxx | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/test/util/TestRingBuffer.cxx b/test/util/TestRingBuffer.cxx index e5c5f4416f..07ac25d7a5 100644 --- a/test/util/TestRingBuffer.cxx +++ b/test/util/TestRingBuffer.cxx @@ -114,3 +114,104 @@ TEST(RingBuffer, ReadFromWriteTo) EXPECT_EQ(b.WriteAvailable(), 4U); EXPECT_EQ(b.ReadAvailable(), 0U); } + +TEST(RingBuffer, ReadFramesTo) +{ + RingBuffer b{8}; + + EXPECT_EQ(b.WriteFrom(std::span{"abcdefgh"sv}), 8U); + // "abcdefgh_" + + { + /* the destination buffer is not a multiple of the + frame size; only whole frames may be read */ + std::array d; + EXPECT_EQ(b.ReadFramesTo(d, 3), 3U); + // "___defgh_" + + EXPECT_EQ(ToStringView(d).substr(0, 3), "abc"sv); + } + + EXPECT_EQ(b.ReadAvailable(), 5U); + + { + /* this time, the amount of available data is not a + multiple of the frame size */ + std::array d; + EXPECT_EQ(b.ReadFramesTo(d, 3), 3U); + // "______gh_" + + EXPECT_EQ(ToStringView(d).substr(0, 3), "def"sv); + } + + EXPECT_EQ(b.ReadAvailable(), 2U); + + { + /* not enough data for one frame */ + std::array d; + EXPECT_EQ(b.ReadFramesTo(d, 3), 0U); + } + + EXPECT_EQ(b.ReadAvailable(), 2U); + + /* now check the same with a read which wraps around the end + of the ring buffer */ + + EXPECT_EQ(b.WriteFrom(std::span{"ijklmn"sv}), 6U); + // "jklmn_ghi" + + EXPECT_EQ(b.ReadAvailable(), 8U); + + { + std::array d; + EXPECT_EQ(b.ReadFramesTo(d, 2), 4U); + // "_klmn____" + + EXPECT_EQ(ToStringView(d).substr(0, 4), "ghij"sv); + } + + EXPECT_EQ(b.ReadAvailable(), 4U); + + { + std::array d; + EXPECT_EQ(b.ReadFramesTo(d, 2), 4U); + // "_________" + + EXPECT_EQ(ToStringView(d), "klmn"sv); + } + + EXPECT_EQ(b.ReadAvailable(), 0U); +} + +TEST(RingBuffer, WriteFramesFrom) +{ + RingBuffer b{8}; + + { + /* the source buffer is not a multiple of the frame + size; only whole frames may be written */ + EXPECT_EQ(b.WriteFramesFrom(std::span{"abcde"sv}, 3), 3U); + // "abc______" + + EXPECT_EQ(b.ReadAvailable(), 3U); + EXPECT_EQ(ToStringView(b.Read()), "abc"sv); + } + + { + /* this time, the amount of free space is not a + multiple of the frame size */ + EXPECT_EQ(b.WriteFramesFrom(std::span{"defghijk"sv}, 3), 3U); + // "abcdef___" + + EXPECT_EQ(b.WriteAvailable(), 2U); + EXPECT_EQ(ToStringView(b.Read()), "abcdef"sv); + } + + { + /* not enough space for one frame */ + EXPECT_EQ(b.WriteFramesFrom(std::span{"ghi"sv}, 3), 0U); + + EXPECT_EQ(b.WriteAvailable(), 2U); + EXPECT_EQ(ToStringView(b.Read()), "abcdef"sv); + } +} From af32a13cefcd59cf60c4174cb59c44b45a6b4d00 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 18:43:21 +0200 Subject: [PATCH 13/25] util/RingBuffer: fix partial frames in ReadFramesTo(), WriteFramesFrom() Both methods rounded down the amount of available data/space to a multiple of frame_size, but not the caller's buffer. If the ring buffer held more data (or more free space) than the caller's buffer could hold, that buffer was used as-is, and the return value was not a multiple of frame_size, violating the documented contract. The only user is the PipeWire output plugin, which could therefore queue a chunk containing a partial frame whenever the PipeWire buffer size was not a multiple of the frame size (tripping the assertion in Process()). In native DSD mode, the frame size gets multiplied by the interleave value, which makes an unaligned buffer size much more likely, and PostProcessDsd() would then read and write past the end of the chunk. --- NEWS | 1 + src/util/RingBuffer.hxx | 24 +++++++++--------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/NEWS b/NEWS index 66901b4089..6cb52d0f10 100644 --- a/NEWS +++ b/NEWS @@ -18,6 +18,7 @@ ver 0.24.14 (not yet released) - pipewire: fix uninitialized variable - pipewire: fix thread-safety bugs - pipewire: fix deadlock bug + - pipewire: fix ring buffer corruption bug * Windows - enable the "mpg123" decoder plugin - fix shutdown in console mode diff --git a/src/util/RingBuffer.hxx b/src/util/RingBuffer.hxx index 03216aba9c..5fc26ad614 100644 --- a/src/util/RingBuffer.hxx +++ b/src/util/RingBuffer.hxx @@ -176,14 +176,11 @@ public: std::size_t WriteFramesFrom(std::span src, std::size_t frame_size) noexcept { // TODO optimize, eliminate duplicate atomic reads - std::size_t available = WriteAvailable(); - std::size_t frames_available = available / frame_size; - std::size_t rounded_available = frames_available * frame_size; - - if (rounded_available < src.size()) - src = src.first(rounded_available); - - return WriteFrom(src); + const std::size_t available = std::min(WriteAvailable(), src.size()); + const std::size_t rounded_available = + available - available % frame_size; + + return WriteFrom(src.first(rounded_available)); } /** @@ -277,14 +274,11 @@ public: std::size_t ReadFramesTo(std::span dest, std::size_t frame_size) noexcept { // TODO optimize, eliminate duplicate atomic reads - std::size_t available = ReadAvailable(); - std::size_t frames_available = available / frame_size; - std::size_t rounded_available = frames_available * frame_size; + const std::size_t available = std::min(ReadAvailable(), dest.size()); + const std::size_t rounded_available = + available - available % frame_size; - if (rounded_available < dest.size()) - dest = dest.first(rounded_available); - - return ReadTo(dest); + return ReadTo(dest.first(rounded_available)); } /** From 4fec5a3d5f0b9e57aa960c5dc16f2493db5ba922 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 18:59:29 +0200 Subject: [PATCH 14/25] output/PipeWire: check for pw_thread_loop_start() errors --- src/output/plugins/PipeWireOutputPlugin.cxx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 7208087329..9aa0635f00 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -369,7 +369,12 @@ PipeWireOutput::Enable() if (thread_loop == nullptr) throw MakeErrno("pw_thread_loop_new() failed"); - pw_thread_loop_start(thread_loop); + if (int error = pw_thread_loop_start(thread_loop); error < 0) { + pw_thread_loop_destroy(thread_loop); + thread_loop = nullptr; + + throw PipeWire::MakeError(error, "pw_thread_loop_start() failed"); + } stream = nullptr; } From ae75cefd972dcafd044cb8f2b618dd7882e44f37 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 19:09:20 +0200 Subject: [PATCH 15/25] output/PipeWire: use `if` with initializer --- src/output/plugins/PipeWireOutputPlugin.cxx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 9aa0635f00..fd7eb968ad 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -996,8 +996,7 @@ PipeWireOutput::SendTag(const Tag &tag) const PipeWire::ThreadLoopLock lock(thread_loop); CheckThrowError(); - auto rc = pw_stream_update_properties(stream, &dict); - if (rc < 0) + if (int error = pw_stream_update_properties(stream, &dict); error < 0) LogWarning(pipewire_output_domain, "Error updating properties"); } From d2b7bd87462eee17e7b04aa2582c0450442834ef Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 19:09:43 +0200 Subject: [PATCH 16/25] output/PipeWire: SendTag() throws on error Let the caller decide what to do. The actual caller does log the error, so there's no point in doing it from inside the plugin. --- src/output/plugins/PipeWireOutputPlugin.cxx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index fd7eb968ad..9b314f2d0a 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -997,7 +997,7 @@ PipeWireOutput::SendTag(const Tag &tag) CheckThrowError(); if (int error = pw_stream_update_properties(stream, &dict); error < 0) - LogWarning(pipewire_output_domain, "Error updating properties"); + throw PipeWire::MakeError(error, "pw_stream_update_properties() failed"); } void From 012edc4c30ec7180e19ad7c73af0b174a388ba78 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 19:22:14 +0200 Subject: [PATCH 17/25] output/alsa: remove option `thesycon_dsd_workaround` This piece of code has been a no-op due to a bug in commit 9bcd425a8587deeb74fb8f186e2cf6ad640b778b - and for five years, nobody has noticed the regression. That can only mean nobody uses or needs this workaround (anymore). Let's remove this piece of bad code! --- NEWS | 1 + doc/plugins.rst | 5 - src/output/plugins/AlsaOutputPlugin.cxx | 124 ------------------------ 3 files changed, 1 insertion(+), 129 deletions(-) diff --git a/NEWS b/NEWS index 6cb52d0f10..d638df22ce 100644 --- a/NEWS +++ b/NEWS @@ -12,6 +12,7 @@ ver 0.24.14 (not yet released) - modplug, openmpt: fix error handling bug * output - alsa: remove logging calls from the real-time thread + - alsa: remove option "thesycon_dsd_workaround" - osx: fix format selection bugs - osx: fix volume truncation - pipewire: remove logging calls from the real-time thread diff --git a/doc/plugins.rst b/doc/plugins.rst index f895078575..513da70bae 100644 --- a/doc/plugins.rst +++ b/doc/plugins.rst @@ -901,11 +901,6 @@ The `Advanced Linux Sound Architecture (ALSA) `_ p ("stop" or "pause") in DSD mode (native DSD or DoP). This is a workaround for some DACs which emit noise when stopping DSD playback. - * - **thesycon_dsd_workaround yes|no** - - If enabled, enables a workaround for a bug in Thesycon USB - audio receivers. On these devices, playing DSD512 or PCM - causes all subsequent attempts to play other DSD rates to fail, - which can be fixed by briefly playing PCM at 44.1 kHz. * - **allowed_formats F1 F2 ...** - Specifies a list of allowed audio formats, separated by a space. All items may contain asterisks as a wild card, and may be followed by "=dop" to enable DoP (DSD over PCM) for this particular format. The first matching format is used, and if none matches, MPD chooses the best fallback of this list. diff --git a/src/output/plugins/AlsaOutputPlugin.cxx b/src/output/plugins/AlsaOutputPlugin.cxx index 3263b6cdb5..38ed887c4c 100644 --- a/src/output/plugins/AlsaOutputPlugin.cxx +++ b/src/output/plugins/AlsaOutputPlugin.cxx @@ -164,16 +164,6 @@ class AlsaOutput final * Are we currently draining with #stop_dsd_silence? */ bool in_stop_dsd_silence; - - /** - * Enable the DSD sync workaround for Thesycon USB audio - * receivers? On this device, playing DSD512 or PCM causes - * all subsequent attempts to play other DSD rates to fail, - * which can be fixed by briefly playing PCM at 44.1 kHz. - */ - const bool thesycon_dsd_workaround; - - bool need_thesycon_dsd_workaround = thesycon_dsd_workaround; #endif /** @@ -447,8 +437,6 @@ AlsaOutput::AlsaOutput(EventLoop &_loop, const ConfigBlock &block) /* legacy name from MPD 0.18 and older: */ block.GetBlockValue("dsd_usb", false)), stop_dsd_silence(block.GetBlockValue("stop_dsd_silence", false)), - thesycon_dsd_workaround(block.GetBlockValue("thesycon_dsd_workaround", - false)), #endif close_on_pause(block.GetBlockValue("close_on_pause", true)) { @@ -677,97 +665,6 @@ BestMatch(const std::forward_list &haystack, return haystack.front(); } -#ifdef ENABLE_DSD - -static void -Play_44_1_Silence(snd_pcm_t *pcm) -{ - snd_pcm_hw_params_t *hw; - snd_pcm_hw_params_alloca(&hw); - - int err; - - err = snd_pcm_hw_params_any(pcm, hw); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_any() failed"); - - err = snd_pcm_hw_params_set_access(pcm, hw, - SND_PCM_ACCESS_RW_INTERLEAVED); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_set_access() failed"); - - err = snd_pcm_hw_params_set_format(pcm, hw, SND_PCM_FORMAT_S16); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_set_format() failed"); - - unsigned channels = 1; - err = snd_pcm_hw_params_set_channels_near(pcm, hw, &channels); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_set_channels_near() failed"); - - constexpr snd_pcm_uframes_t rate = 44100; - err = snd_pcm_hw_params_set_rate(pcm, hw, rate, 0); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_set_rate() failed"); - - snd_pcm_uframes_t buffer_size = 1; - err = snd_pcm_hw_params_set_buffer_size_near(pcm, hw, &buffer_size); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_set_buffer_size_near() failed"); - - snd_pcm_uframes_t period_size = 1; - int dir = 0; - err = snd_pcm_hw_params_set_period_size_near(pcm, hw, &period_size, - &dir); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params_set_period_size_near() failed"); - - err = snd_pcm_hw_params(pcm, hw); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_hw_params() failed"); - - snd_pcm_sw_params_t *sw; - snd_pcm_sw_params_alloca(&sw); - - err = snd_pcm_sw_params_current(pcm, sw); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_sw_params_current() failed"); - - err = snd_pcm_sw_params_set_start_threshold(pcm, sw, period_size); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_sw_params_set_start_threshold() failed"); - - err = snd_pcm_sw_params(pcm, sw); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_sw_params() failed"); - - err = snd_pcm_prepare(pcm); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_prepare() failed"); - - AllocatedArray buffer{channels * period_size}; - buffer = std::span{}; - - /* play at least 250ms of silence */ - for (snd_pcm_uframes_t remaining_frames = rate / 4;;) { - auto n = snd_pcm_writei(pcm, buffer.data(), - period_size); - if (n < 0) - throw Alsa::MakeError(err, "snd_pcm_writei() failed"); - - if (snd_pcm_uframes_t(n) >= remaining_frames) - break; - - remaining_frames -= snd_pcm_uframes_t(n); - } - - err = snd_pcm_drain(pcm); - if (err < 0) - throw Alsa::MakeError(err, "snd_pcm_drain() failed"); -} - -#endif - void AlsaOutput::Open(AudioFormat &audio_format) { @@ -809,22 +706,6 @@ AlsaOutput::Open(AudioFormat &audio_format) pcm_name, snd_pcm_type_name(snd_pcm_type(pcm))); -#ifdef ENABLE_DSD - if (need_thesycon_dsd_workaround && - audio_format.format == SampleFormat::DSD && - audio_format.sample_rate <= 256 * 44100 / 8) { - LogDebug(alsa_output_domain, "Playing some 44.1 kHz silence"); - - try { - Play_44_1_Silence(pcm); - } catch (...) { - LogError(std::current_exception()); - } - - need_thesycon_dsd_workaround = false; - } -#endif - PcmExport::Params params; try { @@ -845,11 +726,6 @@ AlsaOutput::Open(AudioFormat &audio_format) use_dsd = audio_format.format == SampleFormat::DSD; in_stop_dsd_silence = false; - if (thesycon_dsd_workaround && - (!use_dsd || - audio_format.sample_rate > 256 * 44100 / 8)) - need_thesycon_dsd_workaround = true; - if (params.dsd_mode == PcmExport::DsdMode::DOP) LogDebug(alsa_output_domain, "DoP (DSD over PCM) enabled"); #endif From a3c3627476825ba7de3a35c09abb1b8a40b763ea Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 19:33:36 +0200 Subject: [PATCH 18/25] output/alsa: cancel the silence_timer in LockCaughtError() Avoid assertion failure in OnSilenceTimer(). --- src/output/plugins/AlsaOutputPlugin.cxx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/output/plugins/AlsaOutputPlugin.cxx b/src/output/plugins/AlsaOutputPlugin.cxx index 38ed887c4c..fc15389ce3 100644 --- a/src/output/plugins/AlsaOutputPlugin.cxx +++ b/src/output/plugins/AlsaOutputPlugin.cxx @@ -25,6 +25,7 @@ #include "event/InjectEvent.hxx" #include "event/FineTimerEvent.hxx" #include "event/Call.hxx" +#include "event/Loop.hxx" #include "util/RingBuffer.hxx" #include "Log.hxx" @@ -367,8 +368,12 @@ class AlsaOutput final snd_pcm_sframes_t WriteFromPeriodBuffer() noexcept; void LockCaughtError() noexcept { + assert(GetEventLoop().IsInside()); + period_buffer.Clear(); + silence_timer.Cancel(); + const std::lock_guard lock{mutex}; error = std::current_exception(); active = false; From aa340902b355487ba037d1a308fce708769f12b1 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 20:36:00 +0200 Subject: [PATCH 19/25] output/alsa: allow interrupting Drain() --- src/output/plugins/AlsaOutputPlugin.cxx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/output/plugins/AlsaOutputPlugin.cxx b/src/output/plugins/AlsaOutputPlugin.cxx index fc15389ce3..3afcf53f28 100644 --- a/src/output/plugins/AlsaOutputPlugin.cxx +++ b/src/output/plugins/AlsaOutputPlugin.cxx @@ -963,7 +963,7 @@ AlsaOutput::Drain() Activate(); - cond.wait(lock, [this]{ return !drain || !active; }); + cond.wait(lock, [this]{ return !drain || !active || interrupted; }); if (error) std::rethrow_exception(error); From 58f80a361b027b778b583d5935cfade668bac642 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 20:48:00 +0200 Subject: [PATCH 20/25] output/alsa: reset PcmExport after draining PcmExport may hold incomplete DSD blocks until enough data arrives to convert it. Cancel() discards it via CancelInternal(), but the drain paths did not, even though the stream is just as discontinuous afterwards: after Drain(), AudioOutputControl reuses the open device for the next song if the audio format is unchanged, so AlsaOutput::Open() (which would have reset the exporter) is never called, and the leftover bytes of the finished song get prepended to the next one. This affects the Cancel() code path with "stop_dsd_silence" as well, which drains instead of calling snd_pcm_drop() and therefore never reached CancelInternal(). --- NEWS | 1 + src/output/plugins/AlsaOutputPlugin.cxx | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/NEWS b/NEWS index d638df22ce..731634e7fd 100644 --- a/NEWS +++ b/NEWS @@ -13,6 +13,7 @@ ver 0.24.14 (not yet released) * output - alsa: remove logging calls from the real-time thread - alsa: remove option "thesycon_dsd_workaround" + - alsa: fix corruption bug with "stop_dsd_silence" - osx: fix format selection bugs - osx: fix volume truncation - pipewire: remove logging calls from the real-time thread diff --git a/src/output/plugins/AlsaOutputPlugin.cxx b/src/output/plugins/AlsaOutputPlugin.cxx index 3afcf53f28..c97788979b 100644 --- a/src/output/plugins/AlsaOutputPlugin.cxx +++ b/src/output/plugins/AlsaOutputPlugin.cxx @@ -965,6 +965,11 @@ AlsaOutput::Drain() cond.wait(lock, [this]{ return !drain || !active || interrupted; }); + /* the stream is discontinuous now; discard the incomplete + block which may still be inside the PcmExport instance, + because it would otherwise be prepended to the next song */ + pcm_export->Reset(); + if (error) std::rethrow_exception(error); } @@ -1016,6 +1021,7 @@ AlsaOutput::Cancel() noexcept in_stop_dsd_silence = true; drain = true; cond.wait(lock, [this]{ return !drain || !active; }); + pcm_export->Reset(); return; } #endif From e34f619409af6ac299636b44a88d952a7915cdc7 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 21:01:32 +0200 Subject: [PATCH 21/25] lib/nfs/Error: fall back to strerror() if nfs_get_error() returns an empty string If there's no known error message, nfs_get_error() returns an empty string, but never NULL. But I'm leaving the NULL check in there, just in case.. --- src/lib/nfs/Error.cxx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/nfs/Error.cxx b/src/lib/nfs/Error.cxx index 5ec812c287..b8fe7b8974 100644 --- a/src/lib/nfs/Error.cxx +++ b/src/lib/nfs/Error.cxx @@ -34,7 +34,7 @@ FormatNfsClientError(int err, struct nfs_context *nfs, void *data, const char *msg2 = (const char *)data; if (data == nullptr || *(const char *)data == 0) { msg2 = nfs_get_error(nfs); - if (msg2 == nullptr) + if (msg2 == nullptr || *msg2 == 0) msg2 = strerror(-err); } From 2c8d697e204cd2598befa6bfc887125bea329023 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 21:20:23 +0200 Subject: [PATCH 22/25] lib/nfs/Blocking: unregister the lease on timeout When the operation is being canceled due to timeout, we need to unregister the lease, because nothing else does. --- NEWS | 2 ++ src/lib/nfs/Blocking.cxx | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 731634e7fd..209a645f52 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,8 @@ ver 0.24.14 (not yet released) * storage - curl: bound WebDAV PROPFIND responses +* storage + - nfs: fix use-after-free bug after timeout * input - qobuz: use HTTPS for API requests * decoder diff --git a/src/lib/nfs/Blocking.cxx b/src/lib/nfs/Blocking.cxx index 5ac20ffe48..c8c3def538 100644 --- a/src/lib/nfs/Blocking.cxx +++ b/src/lib/nfs/Blocking.cxx @@ -14,8 +14,11 @@ BlockingNfsOperation::Run() [this](){ connection.AddLease(*this); }); /* wait for completion */ - if (!LockWaitFinished()) + if (!LockWaitFinished()) { + BlockingCall(connection.GetEventLoop(), + [this](){ connection.RemoveLease(*this); }); throw std::runtime_error("Timeout"); + } /* check for error */ if (error) From 63e1f6775ec41ab3548a98c5dd49840312850424 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 21:23:59 +0200 Subject: [PATCH 23/25] lib/nfs/Base: use StringIsEqual() instead of strcmp() --- src/lib/nfs/Base.cxx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/nfs/Base.cxx b/src/lib/nfs/Base.cxx index 3faf4c90a0..c3a34453a7 100644 --- a/src/lib/nfs/Base.cxx +++ b/src/lib/nfs/Base.cxx @@ -2,6 +2,7 @@ // Copyright The Music Player Daemon Project #include "Base.hxx" +#include "util/StringAPI.hxx" #include // for std::copy() #include @@ -33,7 +34,7 @@ nfs_check_base(const char *server, const char *path) noexcept assert(server != nullptr); assert(path != nullptr); - return strcmp(nfs_base_server.data(), server) == 0 && + return StringIsEqual(nfs_base_server.data(), server) && memcmp(nfs_base_export_name.data(), path, nfs_base_export_name_length) == 0 && (path[nfs_base_export_name_length] == 0 || From cfb6de7fe58e8b9f0bdfca0dfae7961cf5d44e7b Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 21:25:16 +0200 Subject: [PATCH 24/25] lib/nfs/Base: use StringStartsWith() instead of memcmp() memcmp() is allowed to work in reverse direction, so it may read past the end of the string. StringStartsWith() (a wrapper for strncmp()) is safe. --- src/lib/nfs/Base.cxx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib/nfs/Base.cxx b/src/lib/nfs/Base.cxx index c3a34453a7..ea851c1347 100644 --- a/src/lib/nfs/Base.cxx +++ b/src/lib/nfs/Base.cxx @@ -3,13 +3,12 @@ #include "Base.hxx" #include "util/StringAPI.hxx" +#include "util/StringCompare.hxx" #include // for std::copy() #include #include -#include - static std::array nfs_base_server; static std::array nfs_base_export_name; static size_t nfs_base_export_name_length; @@ -35,8 +34,7 @@ nfs_check_base(const char *server, const char *path) noexcept assert(path != nullptr); return StringIsEqual(nfs_base_server.data(), server) && - memcmp(nfs_base_export_name.data(), path, - nfs_base_export_name_length) == 0 && + StringStartsWith(path, {nfs_base_export_name.data(), nfs_base_export_name_length}) && (path[nfs_base_export_name_length] == 0 || path[nfs_base_export_name_length] == '/') ? path + nfs_base_export_name_length From eb4c468c5f6e962d50f15826e790e24f4d053083 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 11 Aug 2026 21:32:24 +0200 Subject: [PATCH 25/25] test/run_storage: add command "idle" To test NFS mount cancellation. --- test/meson.build | 1 + test/run_storage.cxx | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/test/meson.build b/test/meson.build index 84c52b0a31..587aa9bc67 100644 --- a/test/meson.build +++ b/test/meson.build @@ -217,6 +217,7 @@ if enable_database executable( 'run_storage', 'run_storage.cxx', + 'ShutdownHandler.cxx', '../src/TagSave.cxx', include_directories: inc, dependencies: [ diff --git a/test/run_storage.cxx b/test/run_storage.cxx index 4ef2c8179d..bf156d810a 100644 --- a/test/run_storage.cxx +++ b/test/run_storage.cxx @@ -1,8 +1,10 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright The Music Player Daemon Project +#include "ShutdownHandler.hxx" #include "cmdline/OptionDef.hxx" #include "cmdline/OptionParser.hxx" +#include "event/Loop.hxx" #include "event/Thread.hxx" #include "ConfigGlue.hxx" #include "tag/Tag.hxx" @@ -47,6 +49,7 @@ static constexpr auto usage_text = R"(Usage: run_storage [OPTIONS] COMMAND URI . --verbose Available commands: + idle URI ls URI PATH stat URI PATH cat URI PATH @@ -132,6 +135,24 @@ MakeStorage(EventLoop &event_loop, const char *uri) return storage; } +static int +Idle(Path config_path, const char *storage_uri) +{ + EventLoop event_loop; + const ShutdownHandler shutdown_handler{event_loop}; + + GlobalInit init{config_path}; + + auto storage = MakeStorage(init.GetEventLoop(), storage_uri); + + fprintf(stderr, "Waiting for SIGINT/SIGTERM\n"); + event_loop.Run(); + + /* now let the destructors of #storage and #init shut + everything down */ + return EXIT_SUCCESS; +} + static int Ls(Storage &storage, const char *path) { @@ -266,6 +287,20 @@ try { const auto c = ParseCommandLine(argc, argv); SetLogThreshold(c.verbose ? LogLevel::DEBUG : LogLevel::INFO); + + if (StringIsEqual(c.command, "idle")) { + /* this command needs to initialize things in a + different order, therefore it is handled before + #GlobalInit is constructed */ + + if (c.args.size() != 1) { + fputs(usage_text, stderr); + return EXIT_FAILURE; + } + + return Idle(c.config_path, c.args[0]); + } + GlobalInit init{c.config_path}; if (StringIsEqual(c.command, "ls")) {