fix(rb-06): native capture worker is joined on stop
The accumulator task was fire-and-forget — `tokio::spawn` without
retaining the JoinHandle. `stop_native_capture` sent a stop signal,
slept 50ms, and returned; the worker could still be running its
final flush and appending to `all_samples` when the next
`start_native_capture` cleared it. Rapid start→stop→start could
leak tail samples from one session into another.
Replace `NativeCaptureState.stop_tx` with `worker:
AsyncMutex<Option<CaptureWorker>>`, where CaptureWorker owns both
the stop sender and the spawned task's JoinHandle. New helper
`stop_worker(worker)` sends stop, drops the sender, and `.await`s
the join. Both the prior-worker tear-down in `start_native_capture`
and `stop_native_capture` itself go through the helper, so the
worker is always fully terminated before any downstream read or
next-session cleanup.
AsyncMutex (not std::sync::Mutex) because the stop path awaits
while holding the lock. Also drops the 50ms sleep from
stop_native_capture — the join is an exact barrier.
Two regression tests:
- stop_worker_awaits_full_termination_no_writes_after_join:
synthetic worker with an atomic counter and a flush marker.
After stop_worker the flush must have run and no further
writes may appear.
- stop_worker_is_idempotent_on_a_worker_that_has_already_exited:
tasks that stop themselves must still join cleanly.
A full cpal-backed start→stop→start integration test is not
feasible in Linux CI without an audio device. The component tests
cover the invariant the real flow depends on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,13 +18,12 @@ should be mirrored as real GitHub issues on `jakejars/kon`.
|
||||
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | large |
|
||||
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | large |
|
||||
|
||||
## MAJOR (6 open, 3 resolved)
|
||||
## MAJOR (5 open, 4 resolved)
|
||||
|
||||
| # | File | Area | Fix scope |
|
||||
|---|---|---|---|
|
||||
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | large |
|
||||
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | medium |
|
||||
| RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | medium |
|
||||
| RB-08 | [power-assertion-macos-objc2.md](power-assertion-macos-objc2.md) | `src-tauri/commands/power.rs` | medium |
|
||||
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | medium |
|
||||
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | medium |
|
||||
@@ -34,6 +33,7 @@ should be mirrored as real GitHub issues on `jakejars/kon`.
|
||||
| # | File | Area | Resolution |
|
||||
|---|---|---|---|
|
||||
| RB-02 | [c3-migrations-atomicity.md](c3-migrations-atomicity.md) | `crates/storage/migrations.rs` | Each migration now runs inside a `pool.begin()` / `tx.commit()` transaction alongside its `schema_version` insert. Regression test injects a poisoned v9 migration and asserts neither the partial schema change nor the version row persists. DRY'd `run_migrations_up_to` test helper onto the same code path. |
|
||||
| RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | `NativeCaptureState.stop_tx` replaced by `worker: AsyncMutex<Option<CaptureWorker>>`. `CaptureWorker` bundles the stop sender and the spawned task's `JoinHandle`; `stop_worker(worker)` sends stop then `await`s termination. Both `start_native_capture` (prior-worker stop) and `stop_native_capture` use the helper. Removed the 50ms sleep — the join barrier is exact. Two regression tests cover the lifecycle guarantee and the already-exited case. |
|
||||
| RB-07 | [runtime-capabilities-accelerators.md](runtime-capabilities-accelerators.md) | `src-tauri/commands/models.rs` | Introduced `compose_accelerators(whisper_enabled, loader_available, target)` as a pure helper; `supported_accelerators()` reads `cfg(feature = "whisper")`, `vulkan_loader_available()`, and target OS then delegates. `get_runtime_capabilities` uses it in place of the hard-coded `["cpu", "vulkan"]`. Whisper's `supports_gpu` now follows the feature flag. Five regression tests cover all permutations. |
|
||||
| RB-09 | [decoder-partial-audio-on-error.md](decoder-partial-audio-on-error.md) | `crates/audio/decode.rs` | Packet-loop now propagates all non-EOF `SymphoniaError`s as `AudioDecodeFailed`; per-packet decode errors bubble via `?`. Mock-`MediaSource` regression test confirms mid-stream I/O errors surface instead of returning partial audio. |
|
||||
| RB-12 | [hotkey-linux-device-filter.md](hotkey-linux-device-filter.md) | `crates/hotkey/linux.rs` | Extracted `device_supports_combo` helper; `try_attach_device` now reads the configured `HotkeyCombo` from the watch channel and checks support for that trigger key. Four regression tests land in `linux::tests`. |
|
||||
|
||||
@@ -4,6 +4,47 @@
|
||||
**Path:** `src-tauri/src/commands/audio.rs:46-228`
|
||||
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
|
||||
**Labels:** release-blocker, major, concurrency, audio
|
||||
**Status:** RESOLVED (2026-04-22)
|
||||
|
||||
## Resolution
|
||||
|
||||
Introduced `CaptureWorker { stop_tx, join: JoinHandle<()> }` as the
|
||||
single handle type retained in state. `NativeCaptureState.stop_tx`
|
||||
(a `std::sync::Mutex<Option<Sender>>`) became `worker:
|
||||
tokio::sync::Mutex<Option<CaptureWorker>>` — the async mutex is
|
||||
required because the stop path awaits the join while holding the
|
||||
lock, and holding a blocking mutex across an await is a bug pattern
|
||||
we don't want to ship.
|
||||
|
||||
New helper `stop_worker(worker)` sends the stop signal, drops the
|
||||
sender, then `join.await`s the task. Errors from join (panic /
|
||||
cancellation) are logged and swallowed; the caller needs the
|
||||
synchronisation barrier, not the task's return value.
|
||||
|
||||
Both lifecycle paths route through the helper:
|
||||
|
||||
- `start_native_capture` — before opening a new capture, if a
|
||||
previous worker is resident, stop it and await termination.
|
||||
This removes the race where the old worker's final flush could
|
||||
append to `all_samples` after the new path cleared it.
|
||||
- `stop_native_capture` — take the worker, stop_worker, then read
|
||||
`all_samples`. The previous 50ms sleep is no longer needed — the
|
||||
join barrier is exact.
|
||||
|
||||
Two regression tests in `commands::audio::tests`:
|
||||
|
||||
- `stop_worker_awaits_full_termination_no_writes_after_join` —
|
||||
synthetic worker bumps an atomic counter in a loop, applies a
|
||||
flush marker at exit. Post-stop-worker the flush marker must be
|
||||
set and no further writes must appear on a subsequent sleep.
|
||||
- `stop_worker_is_idempotent_on_a_worker_that_has_already_exited` —
|
||||
a task that finished on its own must still be join-able without
|
||||
hang or panic.
|
||||
|
||||
The full cpal-backed start→stop→start integration test the original
|
||||
issue asks for is not feasible in a Linux CI without an audio
|
||||
device. The component test above covers the underlying invariant
|
||||
the real workflow depends on.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
Reference in New Issue
Block a user