fix(audio): close the teardown orphan race by wiring in the ledger
Teardown used to `event_task.abort()` and then read three `Option<u32>`s. The task's work was a synchronous `pactl` call with no await point, so the abort could not land until the load had already returned: teardown saw `None`, unloaded the sink, and the task then stored the new module's id into a mutex nobody would read again. An orphan loopback, pointing at a sink that no longer existed. Three changes close it: - Loads and unloads are `tokio::process::Command` with `kill_on_drop` and a bound, so cancellation is expressible at all. They are deliberately not `select!`ed against a cancel signal — dropping a completed load's index on the floor is the defect, not the fix. Cancellation happens by dropping the future, and the permit's `Drop` turns that into a question. - `Routing::shutdown` is async and *awaits* the event task through `&mut JoinHandle`, falling back to abort-then-await. Dropping the handle would detach the task, which is how a load could still land after teardown believed it had finished. It then runs two reconcile-then-unload rounds: one round can raise exactly one new question, and a second settles it. - `Drop` stays as the narrower synchronous backstop for the paths that never reach `shutdown`. It cannot await or reconcile, so when the ledger is left unexplained it says so and names `--repair`. A load whose outcome cannot be observed is now distinguished from one the server refused: a clean non-zero `pactl` exit abandons the permit (nothing was created), while a signal death, a timeout, an unreadable index or `PA_INVALID_INDEX` all leave it unsettled for reconciliation. Two live gates, both A/B against the real module table: teardown leaves it byte-identical with both modules carrying owner tokens, and a load cancelled mid-flight is reconciled rather than orphaned. The second asserts the slot is pending *before* reconciling, so it cannot pass by aborting before the load ever began. Both mutate global state, so they need `--test-threads=1` — running them in parallel makes each see the other's modules, which is how the first run failed. 273 tests, clippy clean under `-D warnings`, `--doctor` all checks pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,7 @@ impl CaptureHandle {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Some(audio) = self.audio.take() {
|
||||
audio.shutdown();
|
||||
audio.shutdown().await;
|
||||
}
|
||||
if let Some(serve) = self.serve.take() {
|
||||
serve.shutdown().await;
|
||||
|
||||
Reference in New Issue
Block a user