From f3090a311576ad0513ab2d5ff30e0b28dec5ddb2 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 10 Sep 2026 21:34:42 +0200 Subject: [PATCH 1/4] fix(audio): drop a borrow no ci job was in a position to see Clippy runs on the Linux slot only, on the stated grounds that its answer cannot differ by operating system. That holds for portable code and not for code behind a cfg: the Windows backend is never compiled where clippy runs, so needless_borrow sat in it from the day the shared period fill landed. The macOS backend has no job at all and is linted nowhere. --- src-tauri/crates/app/src/audio/wasapi_exclusive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs index 3327e0ee..9e098946 100644 --- a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs +++ b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs @@ -807,7 +807,7 @@ fn run_event_loop( // left uncredited. It lives in `output` so the three // exclusive paths and the cpal callback can't drift apart. let written = - super::output::fill_pcm_period(&shared, &mut consumer, &mut samples, channels); + super::output::fill_pcm_period(shared, &mut consumer, &mut samples, channels); // Pack `samples` into the byte layout the negotiated // exclusive format expects (#174). Hot path: no From 1d54240fc23a6cc7f060d2b2dcfd91b4ec42cda6 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 10 Sep 2026 21:34:42 +0200 Subject: [PATCH 2/4] fix(audio): release the device before reopening it exclusively (#604) A device switch opens the new stream before tearing the old one down, so a failed open costs nothing and the stream being listened to survives. The premise is that the two streams target different endpoints. A device that has gone away breaks it: when the pinned name is no longer enumerated, pick_device falls back to the default endpoint, which can be the one the old stream already holds exclusively. Windows then refuses the open against ourselves, we drop to shared mode, and nothing says why. The rule for this already existed and both other rebuild paths consult it; the switch path did not. Three things measured on Windows 11 rather than assumed, because the comment being corrected here asserted the second one backwards. An exclusive client does block a second exclusive open of the same endpoint. A shared client blocks nothing: an exclusive Initialize succeeds while one of our own shared streams is open and running. And the block clears about 19 milliseconds after the client is dropped, which rules out a release still in flight as the explanation for a loop lasting seconds. What this does not establish is that the silence the reporter describes has the same cause as the storm of failed opens in their log. They are two distinct moments and only one symptom was reported. --- src-tauri/crates/app/src/audio/engine.rs | 101 +++++++++++++++++------ 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index aa89bbe4..a5248aaf 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -1192,19 +1192,63 @@ impl AudioEngine { .load(std::sync::atomic::Ordering::Acquire); let position_ms = self.shared.current_position_ms(); - // Step 2 — open the new output thread first. The old one is - // still running, which is fine: PipeWire / PulseAudio / ALSA - // dmix all support multiple concurrent streams, and the two - // streams target different devices anyway. If this fails we - // return immediately without disturbing the working stream. - let (producer, handle) = spawn_output_with_mode( + // Step 2 — release the old stream first when the new one cannot be + // opened alongside it, then open the replacement. + // + // Spawn-first is the order we want for a device *switch*: the two + // streams target different endpoints, so a failed open costs + // nothing and the stream the user is listening to survives. PipeWire, + // PulseAudio and ALSA dmix all take concurrent streams, and Windows + // has no quarrel with two clients on two endpoints. + // + // A vanished device breaks that premise, and #604 is what it looks + // like. When the name we were asked for is no longer enumerated, + // `pick_device` falls back to the **default** endpoint — which can + // be the one the old stream is holding exclusively. The open is then + // refused with `AUDCLNT_E_DEVICE_IN_USE` against ourselves, we drop + // to shared mode, and nothing tells the user why. + // + // Measured on Windows 11 rather than assumed: an exclusive client + // does block a second exclusive open of the same endpoint, a + // *shared* client does not block one at all, and the block clears + // about 19 ms after the client is dropped. So the conflict is real, + // it is ours, and releasing first is enough to clear it. + let entering_exclusive = self + .exclusive_output + .load(std::sync::atomic::Ordering::Relaxed); + let pre_release = + must_release_before_reopening(guard.as_ref().map(|h| h.exclusive), entering_exclusive); + // Stop is sent here rather than twice: the step below skips its own + // send when this one already happened. + let stopped_early = pre_release && was_playing; + if pre_release { + if was_playing { + self.cmd_tx + .send(AudioCmd::Stop) + .map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?; + } + if let Some(old) = guard.take() { + old.stop(); + } + } + + let (producer, handle) = match spawn_output_with_mode( self.shared.clone(), self.app.clone(), device_name, - self.exclusive_output - .load(std::sync::atomic::Ordering::Relaxed), + entering_exclusive, None, - )?; + ) { + Ok(pair) => pair, + Err(err) => { + // After a pre-release there is no output at all, and the + // toggle must stop claiming one (#405). Without one the old + // stream is still installed and still playing, and this + // no-ops. + self.publish_output_lost_if_gone(&guard); + return Err(err); + } + }; // Step 3 — interrupt any current playback. The decoder will // walk back out of `play_track` and start polling for fresh @@ -1216,7 +1260,7 @@ impl AudioEngine { // has died (engine teardown / crash). Tear the freshly opened // output back down so it doesn't outlive the engine. let send_result = (|| { - if was_playing { + if was_playing && !stopped_early { self.cmd_tx .send(AudioCmd::Stop) .map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?; @@ -1550,21 +1594,29 @@ impl AudioEngine { /// until it lets go (#322, then #405 for the other direction). /// - **We are entering exclusive on macOS.** Hog mode is recorded as a /// *pid*, and the client it would have to evict here is our own cpal -/// stream, in this very process — so it evicts nothing. Windows kicks -/// the shared client off when the endpoint is seized, and on Linux the -/// reservation protocol makes the sound server hand the card over; -/// macOS has neither. The new AudioUnit then comes up on a device our -/// old one is still driving and renders nothing: no sound, and a -/// position counter frozen where it stood. +/// stream, in this very process — so it evicts nothing. On Linux the +/// reservation protocol makes the sound server hand the card over, and +/// on Windows a shared client is simply no obstacle (below); macOS has +/// neither. The new AudioUnit then comes up on a device our old one is +/// still driving and renders nothing: no sound, and a position counter +/// frozen where it stood. /// /// Measured on a MacBook Air, and only on the toggle. Armed before /// launch the same code opens on an idle device and plays, which is /// what made this look like a backend fault rather than an ordering /// one. /// -/// Releasing first is safe in the second case because -/// [`spawn_output_with_mode`] falls back to shared mode on its own, so -/// the caller still comes back holding a stream. +/// A **shared** stream is deliberately not a reason to release first, and +/// that is measured rather than assumed. On Windows 11, `Initialize` in +/// exclusive mode succeeds while one of our own shared clients is open and +/// running on the same endpoint; only an *exclusive* client draws +/// `AUDCLNT_E_DEVICE_IN_USE`, and it stops doing so about 19 ms after that +/// client is dropped. Keeping spawn-first here is what preserves the +/// rollback for the ordinary case. +/// +/// Releasing first is safe wherever it applies because +/// [`spawn_output_with_mode`] falls back to shared mode on its own, so the +/// caller still comes back holding a stream. fn must_release_before_reopening(old_is_exclusive: Option, entering_exclusive: bool) -> bool { match old_is_exclusive { None => false, @@ -1669,11 +1721,12 @@ mod reopen_order_tests { #[test] fn entering_exclusive_over_a_shared_stream_depends_on_the_platform() { - // Windows evicts the shared client when the endpoint is seized and - // Linux asks the sound server for the card, so both keep the - // spawn-first order and the rollback it buys. macOS records hog - // mode against a pid and would be asked to evict this very - // process, so it cannot. + // A shared client blocks nothing on Windows (measured: an + // exclusive `Initialize` succeeds alongside one) and Linux asks + // the sound server for the card, so both keep the spawn-first + // order and the rollback it buys. macOS records hog mode against + // a pid and would be asked to evict this very process, so it + // cannot. assert_eq!( must_release_before_reopening(Some(false), true), cfg!(target_os = "macos") From 3d7b6212f084996358beeb1b4adb8c30877bc9db Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 22:25:39 +0200 Subject: [PATCH 3/4] fix(audio): reopen the previous device when a switch that released first cannot open the new one Releasing an exclusive stream before opening the replacement is what clears the self-collision in #604, but it gave up the rollback that spawn-first had: when the new device would not open at all, not even in shared mode, the user was left with no output. set_output_device now reopens the previous device in that case, resumes playback there and still returns the error, so the command does not persist the choice. The rollback is keyed on the release rather than on comparing endpoints. A device name is no endpoint identity: the Linux picker lists ALSA hint names, and resolve_hw_device maps default, plughw:0,0 and hw:0,0 to one card. A wrong "different" verdict would put the collision back. OutputHandle.device_name claimed to hold the resolved device. It holds the request, and has to: the picker highlights it, and a rebuild or a DoP reopen goes back to it, so the pin survives until the device returns. The docs still described set_output_device as spawn-first and repeated the claim that Windows evicts the shared client when the endpoint is seized, which this branch had already replaced with a measurement. --- docs/architecture/audio.md | 2 +- docs/features/playback.md | 6 ++- src-tauri/crates/app/src/audio/engine.rs | 66 +++++++++++++++++++++--- src-tauri/crates/app/src/audio/output.rs | 9 ++-- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/docs/architecture/audio.md b/docs/architecture/audio.md index c22602cc..c97ff050 100644 --- a/docs/architecture/audio.md +++ b/docs/architecture/audio.md @@ -106,7 +106,7 @@ The period has its own, quieter effect: one period is drained from the ring in a `open_and_run_pcm` takes hog mode and leaves the device's **physical format exactly as it found it**, reading the rate and channel count the device already runs at and publishing them for the decoder to meet. The DoP path does pin the format — a marker cadence that gets resampled is noise — but re-clocking a device the whole machine shares is a price only that cadence justifies. -**Hog mode is registered against a PID**, so it does not evict a stream from our own process. The engine's spawn-before-release order — which works on Windows (the seized endpoint kicks the outgoing shared client off) and on Linux (the reservation makes the server hand the card back) — produced an `AudioUnit` here that rendered nothing at all: no sound, position counter frozen. The release-first rule in [playback / Output-stream lifecycle](../features/playback.md#output-stream-lifecycle--recovery) now covers this case too. +**Hog mode is registered against a PID**, so it does not evict a stream from our own process. The engine's spawn-before-release order — which works on Windows (a shared client doesn't block the exclusive open at all) and on Linux (the reservation makes the server hand the card back) — produced an `AudioUnit` here that rendered nothing at all: no sound, position counter frozen. The release-first rule in [playback / Output-stream lifecycle](../features/playback.md#output-stream-lifecycle--recovery) now covers this case too. ### Shared by all three diff --git a/docs/features/playback.md b/docs/features/playback.md index 91c35357..00c5c865 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -86,17 +86,19 @@ Three paths replace the output stream, and they must all end in the same place: | Path | Trigger | Order | | ---------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `set_output_device` | user picks another endpoint | spawn first, then release — the two streams target different devices, so a failed spawn can roll back to the working one | +| `set_output_device` | user picks another endpoint | order from `must_release_before_reopening`; a failed open after releasing first reopens the previous device | | `set_exclusive_output` | user toggles the mode | order from `must_release_before_reopening` — see below | | `force_rebuild_output` | automatic recovery after a device error | the same rule | [`must_release_before_reopening`](../../src-tauri/crates/app/src/audio/engine.rs) owns the order, and answers "release first" in **two** cases: - **The old stream is exclusive**, on any platform. It owns the device outright, so nothing — shared _or_ exclusive — can open that device until it lets go. Re-opening the **same** endpoint while an exclusive stream still holds it always fails, and when that failure landed inside `set_exclusive_output` the command returned `Err` before persisting anything, leaving the toggle latched on the mode the user was trying to leave (#405). This is the #322 / #405 lesson. -- **We are entering exclusive on macOS**, even from a *shared* stream. CoreAudio registers hog mode against a **PID**, not a stream, so the client it would have to evict is our own cpal stream in this very process — and it evicts nothing. The new `AudioUnit` then comes up on a device the old one is still driving and renders nothing: no sound, position counter frozen. Windows kicks the shared client off when the endpoint is seized and Linux's reservation protocol makes the sound server hand the card over, so neither needs the widening — which is exactly why the macOS case stayed hidden until PCM hog mode existed. +- **We are entering exclusive on macOS**, even from a *shared* stream. CoreAudio registers hog mode against a **PID**, not a stream, so the client it would have to evict is our own cpal stream in this very process — and it evicts nothing. The new `AudioUnit` then comes up on a device the old one is still driving and renders nothing: no sound, position counter frozen. On Windows a shared client is no obstacle at all (measured on Windows 11: an exclusive `Initialize` succeeds alongside one of our own shared streams on the same endpoint) and Linux's reservation protocol makes the sound server hand the card over, so neither needs the widening — which is exactly why the macOS case stayed hidden until PCM hog mode existed. Spawn-first is the order we want everywhere else: a failed open then costs nothing, because the stream the user is listening to is still installed and still playing. Releasing first costs less than it looks in the macOS case, because `spawn_output_with_mode` falls back to shared mode on its own — so a refused exclusive open still leaves the caller holding a stream. It is only when the shared fallback *also* fails that there is no output thread at all, and that path is the one described at the end of this section. +`set_output_device` needs the first case too (#604): a pinned device that has vanished falls back to the default endpoint, which can be the very one the old exclusive stream still holds. Releasing first there gives up the rollback spawn-first had, so the switch buys it back: when the new device won't open at all, it reopens the previous one and still returns the error, so the new choice isn't persisted. It decides on the release, not by comparing endpoints — a name is no identity (ALSA reaches one card as `default`, `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict would put the collision back. + Device loss reaches the recovery path from two independent places, since the two backends have separate failure surfaces: - **cpal shared** — the stream's `err_fn` callback fires on an arbitrary thread. diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index a5248aaf..435062da 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -1221,6 +1221,10 @@ impl AudioEngine { // Stop is sent here rather than twice: the step below skips its own // send when this one already happened. let stopped_early = pre_release && was_playing; + // The device to put back if the replacement will not open at all. + // Only a pre-release needs one: spawn-first leaves the old stream + // installed when the new one fails. + let mut previous_device = None; if pre_release { if was_playing { self.cmd_tx @@ -1228,10 +1232,15 @@ impl AudioEngine { .map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?; } if let Some(old) = guard.take() { + previous_device = Some(old.device_name.clone()); old.stop(); } } + // Set when the replacement failed and the previous device came back + // instead: playback carries on there, and the caller still gets the + // error, so the new choice is not persisted. + let mut switch_error = None; let (producer, handle) = match spawn_output_with_mode( self.shared.clone(), self.app.clone(), @@ -1241,12 +1250,50 @@ impl AudioEngine { ) { Ok(pair) => pair, Err(err) => { - // After a pre-release there is no output at all, and the - // toggle must stop claiming one (#405). Without one the old - // stream is still installed and still playing, and this - // no-ops. - self.publish_output_lost_if_gone(&guard); - return Err(err); + // Releasing first gave up the rollback that spawn-first gets + // for free, so buy it back by reopening the previous device. + // A target that will not open at all, such as a headset still + // listed but already gone, must not cost the user the output + // they were listening to. + // + // Keyed on the release rather than on comparing endpoints: a + // name is no identity (ALSA reaches one card as `default`, + // `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict + // would put #604 back. + let reopened = previous_device.and_then(|previous| { + spawn_output_with_mode( + self.shared.clone(), + self.app.clone(), + previous, + entering_exclusive, + None, + ) + .inspect_err(|reopen_err| { + tracing::warn!( + %reopen_err, + "set_output_device: the previous device did not reopen either" + ); + }) + .ok() + }); + match reopened { + Some(pair) => { + tracing::warn!( + %err, + "set_output_device: the new device did not open, back on the previous one" + ); + switch_error = Some(err); + pair + } + None => { + // No output at all now, and the toggle must stop + // claiming one (#405). Without a pre-release the old + // stream is still installed and still playing, and + // this no-ops. + self.publish_output_lost_if_gone(&guard); + return Err(err); + } + } } }; @@ -1348,7 +1395,12 @@ impl AudioEngine { } } - Ok(()) + // Back on the previous device after a failed switch: playback is + // restored, but the switch itself did not happen. + match switch_error { + Some(err) => Err(err), + None => Ok(()), + } } /// Flip the WASAPI Exclusive Mode preference and re-open the diff --git a/src-tauri/crates/app/src/audio/output.rs b/src-tauri/crates/app/src/audio/output.rs index c026da7c..537a18ae 100644 --- a/src-tauri/crates/app/src/audio/output.rs +++ b/src-tauri/crates/app/src/audio/output.rs @@ -527,9 +527,12 @@ pub(super) fn fill_pcm_period( pub struct OutputHandle { pub shutdown_tx: Sender<()>, pub join: JoinHandle<()>, - /// Resolved device name actually used by this output thread — - /// `None` means the OS default device. Saved so a hot-swap can - /// no-op when the user picks the same device again. + /// Device name this output was *asked* for — `None` means the OS + /// default. It stays the request even when the backend fell back to + /// the default because the name was no longer enumerated: the picker + /// highlights it, a same-device pick no-ops on it, and a rebuild or a + /// DoP reopen goes back to it, so the user's pin survives until the + /// device returns. That also makes it no endpoint identity. pub device_name: Option, /// Whether this handle really owns its device — WASAPI Exclusive /// Mode on Windows, a raw `hw:` handle on Linux. The user From 72caf5653a5244b8ca9e9e612b39b4d0d828d8e5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 22:37:14 +0200 Subject: [PATCH 4/4] fix(audio): schedule a rebuild when neither the new nor the previous device reopens When a switch that released first cannot open the new device and the previous one will not reopen either, the engine was left with no output and nothing scheduled to bring one back. set_exclusive_output already schedules a rebuild of the pinned device in that position; the switch now does the same with the previous device, passed explicitly because the release emptied the output handle. It adds no pause notification and no separate resume point. set_exclusive_output has neither, the command already returns the error to the device menu the user just used, and the rebuild resumes under force_rebuild_output's usual rule. --- docs/features/playback.md | 2 +- src-tauri/crates/app/src/audio/engine.rs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/features/playback.md b/docs/features/playback.md index 00c5c865..0f31987e 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -97,7 +97,7 @@ Three paths replace the output stream, and they must all end in the same place: Spawn-first is the order we want everywhere else: a failed open then costs nothing, because the stream the user is listening to is still installed and still playing. Releasing first costs less than it looks in the macOS case, because `spawn_output_with_mode` falls back to shared mode on its own — so a refused exclusive open still leaves the caller holding a stream. It is only when the shared fallback *also* fails that there is no output thread at all, and that path is the one described at the end of this section. -`set_output_device` needs the first case too (#604): a pinned device that has vanished falls back to the default endpoint, which can be the very one the old exclusive stream still holds. Releasing first there gives up the rollback spawn-first had, so the switch buys it back: when the new device won't open at all, it reopens the previous one and still returns the error, so the new choice isn't persisted. It decides on the release, not by comparing endpoints — a name is no identity (ALSA reaches one card as `default`, `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict would put the collision back. +`set_output_device` needs the first case too (#604): a pinned device that has vanished falls back to the default endpoint, which can be the very one the old exclusive stream still holds. Releasing first there gives up the rollback spawn-first had, so the switch buys it back: when the new device won't open at all, it reopens the previous one and still returns the error, so the new choice isn't persisted. If the previous one won't reopen either, it schedules the same rebuild `set_exclusive_output` does, aimed explicitly at that previous device. It decides on the release, not by comparing endpoints — a name is no identity (ALSA reaches one card as `default`, `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict would put the collision back. Device loss reaches the recovery path from two independent places, since the two backends have separate failure surfaces: diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index 435062da..cc52d284 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -1260,7 +1260,7 @@ impl AudioEngine { // name is no identity (ALSA reaches one card as `default`, // `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict // would put #604 back. - let reopened = previous_device.and_then(|previous| { + let reopened = previous_device.clone().and_then(|previous| { spawn_output_with_mode( self.shared.clone(), self.app.clone(), @@ -1291,6 +1291,18 @@ impl AudioEngine { // stream is still installed and still playing, and // this no-ops. self.publish_output_lost_if_gone(&guard); + // Same recovery as `set_exclusive_output`'s: retry the + // previous device once the OS has settled, instead of + // leaving the engine with no output until the user + // picks again. Passed explicitly because the release + // emptied `self.output`, so a self-resolve would + // reopen the OS default instead of that device (#405). + if let Some(previous) = previous_device { + super::output::schedule_device_rebuild( + &self.app, + super::output::RebuildTarget::Device(previous), + ); + } return Err(err); } }