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:
+7
-12
@@ -191,6 +191,13 @@ impl ModuleLedger {
|
||||
}
|
||||
|
||||
/// The current state of one slot. Absent keys read as [`SlotState::Vacant`].
|
||||
///
|
||||
/// Test-only: production code never needs to look a slot up, because every
|
||||
/// decision that depends on one is made *by* the ledger — `begin_load`
|
||||
/// refuses a busy slot and says which state refused, `begin_unload` returns
|
||||
/// nothing for a slot holding nothing. An accessor callers could branch on
|
||||
/// would invite exactly the check-then-act races the permit removes.
|
||||
#[cfg(test)]
|
||||
pub fn state(&self, shape: Shape) -> SlotState {
|
||||
self.slots
|
||||
.lock()
|
||||
@@ -200,10 +207,6 @@ impl ModuleLedger {
|
||||
.unwrap_or(SlotState::Vacant)
|
||||
}
|
||||
|
||||
fn set(&self, shape: Shape, state: SlotState) {
|
||||
self.slots.lock().unwrap().insert(shape, state);
|
||||
}
|
||||
|
||||
/// Take permission to load `shape`, moving the slot to
|
||||
/// [`SlotState::Loading`].
|
||||
///
|
||||
@@ -372,14 +375,6 @@ pub struct LoadPermit {
|
||||
}
|
||||
|
||||
impl LoadPermit {
|
||||
pub fn shape(&self) -> Shape {
|
||||
self.shape
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &OwnerToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// Record that the server created the module at `index`.
|
||||
///
|
||||
/// Fails on [`PA_INVALID_INDEX`], leaving the permit unsettled so that
|
||||
|
||||
Reference in New Issue
Block a user