Skip to content

fix(audio): gate the post-device-error rebuild to a single owner#393

Merged
InstaZDLL merged 3 commits into
mainfrom
fix/wasapi-single-owner-rebuild
Jul 18, 2026
Merged

fix(audio): gate the post-device-error rebuild to a single owner#393
InstaZDLL merged 3 commits into
mainfrom
fix/wasapi-single-owner-rebuild

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Refs #365, parent #355.

The mechanism (this corrects #365's own analysis)

#365 assumed the two rebuild passes were concurrent. The timestamps say otherwise: the error at .341 plus the 300 ms delay lands exactly on the .653 pass — so the earlier pass at .377 is the deferred rebuild of a previous error, not a racing trigger.

What actually happens is a self-sustaining cascade:

  1. DeviceNotAvailable fires → a rebuild is scheduled 300 ms later.
  2. The rebuild re-opens WASAPI exclusive.
  3. This DAC resets its audio session on every exclusive grab → a fresh DeviceNotAvailable.
  4. That error schedules another rebuild → back to step 2.

The rebuild_in_progress debounce could never catch this, because the recovery is deferred: each error queues its own task, and those tasks never overlap — one finishes before the next begins, so every single one finds a free debounce slot. Eventually a pass opens exclusive while the previous stream is still settling and collides with AUDCLNT_E_DEVICE_IN_USE (0x8889000A); its shared fallback then fails too (device no longer available), leaving the engine and the Settings toggle inconsistent — which is exactly the "only closing and reopening WaveFlow resets the button" symptom in #355.

The #322 flap-storm suppression doesn't fire either, and correctly so: the reporter's events are hours apart, so 3 flaps never land inside the 12 s window. This isn't a storm, it's a one-off device reset that amplifies itself.

The fix

A RebuildGate that owns the whole arm → delay → run lifecycle instead of just the in-flight call:

  • Arming moves into the error callback, before the 300 ms delay. A burst of errors arms exactly one rebuild rather than queueing one task per error.
  • A 2 s settle window after each attempt. This is the part that actually breaks the cascade: on this hardware our own re-open is what provokes the next error, so reacting to it is what re-arms the loop. A genuine later failure (real unplug) still recovers once the window elapses.
  • Released via an RAII guard on every exit path of try_rebuild_after_device_error — including the early returns and the error paths — so a failed rebuild can't latch the gate shut and block all future recovery.

Clock-injected and unit-tested, same shape as the existing FlapWindow.

On the S16_LE / S24_4LE detail

#365 lists the format regression as point 3. It's a symptom, not a separate bug: the #174 negotiation ladder degrades precisely because the device is still held by the previous stream. It should resolve once the collision is gone — worth confirming in the reporter's next log rather than "fixing" separately.

Verification

  • cargo check -p waveflow --all-targets
  • cargo clippy -p waveflow --all-targets ✅ (no warnings)
  • 7 unit tests on the gate, including a replay of the reporter's ~300 ms error cadence: 10 errors collapse to 2 rebuilds instead of 10.

Two honest caveats.

  1. The app-crate test binary cannot run on this machine — it aborts with STATUS_ENTRYPOINT_NOT_FOUND (a known local Tauri DLL issue, environmental). The tests compile under --all-targets and I verified the identical state machine in a standalone binary outside the Tauri crate (7/7 passing), but CI is the real run.
  2. I could not reproduce the trigger. It needs hardware that resets its session on every exclusive grab. The reasoning is traced to the reporter's log line by line, but this should go out as a beta build for @jo-el414 to confirm before we call bug: test the WASAPI fix on 1.6.2-beta.1 - not fix ... #355 closed.

What to look for in his next log: a single rebuilding cpal output after DeviceNotAvailable per event, no 0x8889000A, and the negotiation staying on S24_4LE.

Summary by CodeRabbit

  • Correctifs
    • Réduit les reconstructions audio en cascade lors d’une perte temporaire du périphérique audio.
    • Introduit une fenêtre de stabilisation pour regrouper les tentatives et empêcher les rebuilds rapprochés.
    • Permet un rebuild différé uniquement lorsqu’il n’y en a pas déjà un en cours et après expiration du délai de stabilisation.
    • Améliore la gestion et le suivi des erreurs lors de la récupération audio (journaux plus adaptés).
    • Ajoute des tests unitaires pour valider le comportement du mécanisme de coalescence.

Refs #365, #355. A DAC that resets its session on every exclusive grab
sent the engine into a self-sustaining rebuild cascade.

The recovery is deferred: the cpal error callback scheduled the rebuild
300 ms after DeviceNotAvailable. Because each error queued its own task
and those tasks never overlap — one finishes before the next starts —
the rebuild_in_progress debounce always found a free slot, so every
error got its own rebuild. Each rebuild re-opened exclusive, which made
this device reset again, which armed yet another pass. Eventually one
opened exclusive while the previous stream was still settling and hit
AUDCLNT_E_DEVICE_IN_USE (0x8889000A); its shared fallback then failed
too, leaving the engine and the Settings toggle inconsistent.

Note this corrects the analysis in #365: the two passes are not
concurrent. .341 + 300 ms lands on the .653 pass, so the earlier one is
the deferred rebuild of a *previous* error, not a racing trigger.

Adds RebuildGate: arming now happens in the error callback, before the
delay, and covers the whole arm -> delay -> run lifecycle plus a 2 s
settle window afterwards. One burst of device errors therefore yields
one rebuild. The settle window is the part that actually breaks the
cascade, since our own re-open is what provokes the next error on this
hardware. A genuine later failure still recovers once the window passes.

Released through an RAII guard on every exit path of
try_rebuild_after_device_error, including the error and early-return
ones, so a failed rebuild cannot latch the gate shut.

The S16_LE-instead-of-S24_4LE detail in #365 is a symptom of the
collision, not a separate bug: the #174 negotiation ladder degrades
because the device is still held. It should resolve with the cascade.

Clock-injected and unit-tested like FlapWindow (7 cases, incl. a replay
of the reporter's ~300 ms error cadence: 10 errors collapse to 2
rebuilds instead of 10).

Green: cargo check --all-targets, cargo clippy --all-targets.
@InstaZDLL InstaZDLL added scope: backend Rust/Tauri backend (src-tauri/) type: fix Bug fix size: l 200-500 lines labels Jul 18, 2026
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@InstaZDLL, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 57 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 232f9a12-7f77-446b-a78c-e0072b18ad28

📥 Commits

Reviewing files that changed from the base of the PR and between 9e828e6 and 4680af8.

📒 Files selected for processing (1)
  • src-tauri/crates/app/src/audio/output.rs
📝 Walkthrough

Walkthrough

Le chemin de reconstruction audio après DeviceNotAvailable utilise désormais un gate partagé. Il limite l’armement à une reconstruction par rafale, applique une fenêtre de stabilisation et libère systématiquement l’état via un garde RAII.

Changes

Coordination des reconstructions audio

Layer / File(s) Summary
État et API du gate
src-tauri/crates/app/src/audio/engine.rs
RebuildGate contrôle l’armement et la fenêtre de stabilisation. AudioEngine l’initialise et expose try_arm_device_rebuild.
Exécution différée et libération
src-tauri/crates/app/src/audio/engine.rs, src-tauri/crates/app/src/audio/output.rs
Le callback DeviceNotAvailable tente d’armer le gate après 300 ms, puis la reconstruction libère systématiquement le gate via GateGuard.
Validation des transitions
src-tauri/crates/app/src/audio/engine.rs
Les tests couvrent les états armed et settle, le réarmement après expiration et la réduction des cascades de reconstruction.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

  • InstaZDLL/WaveFlow#218 — Modifie le même chemin de reconstruction automatique après DeviceNotAvailable avec un mécanisme de debounce.
  • InstaZDLL/WaveFlow#346 — Modifie également try_rebuild_after_device_error pour limiter les boucles de reconstruction.

Sequence Diagram(s)

sequenceDiagram
  participant cpal
  participant output_rs
  participant AudioEngine
  cpal->>output_rs: StreamError::DeviceNotAvailable
  output_rs->>output_rs: attendre 300 ms
  output_rs->>AudioEngine: try_arm_device_rebuild()
  AudioEngine-->>output_rs: true ou false
  output_rs->>AudioEngine: try_rebuild_after_device_error()
  AudioEngine->>AudioEngine: GateGuard::finish()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning La description est pertinente, mais elle n’utilise pas le modèle requis et manque les sections Résumé, Tests, Checklist et Liens. Reformate la PR selon le template: 1–3 puces de résumé, étapes de test reproductibles, checklist complète et section Liens remplie.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Le titre suit le format Conventional Commits et résume bien le gate ajouté pour éviter les rebuilds multiples.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/wasapi-single-owner-rebuild

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@InstaZDLL InstaZDLL self-assigned this Jul 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/app/src/audio/output.rs`:
- Around line 517-548: Move recovery orchestration out of the audio error
callback: in src-tauri/crates/app/src/audio/output.rs lines 517-548, replace
try_state, tauri::async_runtime::spawn, delays, and tracing logs with a
lock-free error signal consumed by a non-real-time worker; in
src-tauri/crates/app/src/audio/engine.rs lines 617-638, update
try_arm_device_rebuild so the callback path does not call rebuild_gate.lock(),
using an atomic or equivalent non-blocking gate while preserving
single-rebuild-per-burst behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 23509f98-39b9-447f-bddd-8029b4a98b54

📥 Commits

Reviewing files that changed from the base of the PR and between 4ae0dd7 and 4762185.

📒 Files selected for processing (2)
  • src-tauri/crates/app/src/audio/engine.rs
  • src-tauri/crates/app/src/audio/output.rs

Comment thread src-tauri/crates/app/src/audio/output.rs Outdated
…callback

Review follow-up on #393.

The previous revision looked the AudioEngine up in the cpal error callback
to arm the gate before the 300 ms delay. That narrowed recovery: an error
can fire while AudioEngine::new is still running, i.e. before the engine
is registered in Tauri's managed state, and the eager lookup silently
dropped the event for that window — a regression against the original
code, which resolved the engine after the delay.

Move the whole recovery orchestration into the deferred task: sleep, then
resolve the engine, then arm, then rebuild. Burst collapsing is preserved
because the gate still serialises — whichever task wakes first arms, and
the others hit either `armed` or the settle window. Verified for both the
~300 ms cadence from the report and a tight burst waking simultaneously.

As a side effect the error callback now only spawns the task; the engine
lookup, the gate, the rebuild and its logs all run off it.

Green: cargo check --all-targets, cargo clippy --all-targets.
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Review follow-up on #393.

try_rebuild_after_device_error is synchronous and can block for a long
time: it joins the old output thread — which in WASAPI exclusive mode may
sit in wait_for_event up to its 2 s timeout — and then opens the device
inline (COM init plus the #174 format negotiation ladder). Calling it
directly from the tokio task parked a runtime worker for that whole
duration.

Clone the Arc<AudioEngine> and run the rebuild through
tokio::task::spawn_blocking instead. The #365 gate and its early return
stay on the async side, unchanged, and the existing warning is preserved
inside the blocking task.

Green: cargo check --all-targets, cargo clippy --all-targets.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/app/src/audio/output.rs`:
- Around line 524-529: Update the spawned retry task around AudioEngine state
lookup so a missing engine after the initial 300 ms does not discard the pending
request. Keep retrying with bounded attempts or retain a wake-up signal until
try_state::<Arc<AudioEngine>>() succeeds, then add a test covering
initialization that exceeds the 300 ms delay.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 66c6aeb4-b575-4c79-8ee8-b26eebacc81f

📥 Commits

Reviewing files that changed from the base of the PR and between 4762185 and 9e828e6.

📒 Files selected for processing (2)
  • src-tauri/crates/app/src/audio/engine.rs
  • src-tauri/crates/app/src/audio/output.rs

Comment on lines 524 to +529
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Some(engine) = app_clone.try_state::<std::sync::Arc<super::AudioEngine>>() {
if let Err(err) = engine.try_rebuild_after_device_error() {
tracing::warn!(
%err,
"auto-rebuild after DeviceNotAvailable failed"
);
}
let Some(engine) = app_clone.try_state::<std::sync::Arc<super::AudioEngine>>()
else {
return;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Ne perdez pas l’erreur si l’engine reste non enregistré après 300 ms.

Le déplacement de try_state après le délai réduit la fenêtre de course, mais ne la ferme pas : si l’initialisation dure plus de 300 ms, cette tâche retourne et l’unique demande de reprise est définitivement perdue. Attendez l’enregistrement avec des tentatives bornées, ou conservez un signal de reprise jusqu’à ce que l’engine soit disponible, puis ajoutez un test couvrant ce délai.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/app/src/audio/output.rs` around lines 524 - 529, Update the
spawned retry task around AudioEngine state lookup so a missing engine after the
initial 300 ms does not discard the pending request. Keep retrying with bounded
attempts or retain a wake-up signal until try_state::<Arc<AudioEngine>>()
succeeds, then add a test covering initialization that exceeds the 300 ms delay.

@InstaZDLL
InstaZDLL merged commit c120f42 into main Jul 18, 2026
14 checks passed
@InstaZDLL
InstaZDLL deleted the fix/wasapi-single-owner-rebuild branch July 18, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) size: l 200-500 lines type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant