fix: 删除 unsafe impl Send,cpal stream 钉到专用线程#17
Merged
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
该 PR 针对 native/audio-engine 中 InnerPlayer 通过 unsafe impl Send 绕过 rodio::OutputStream(底层 cpal::Stream)线程约束的问题,改为将 OutputStream 的创建/持有/drop 固定在专用 OS 线程中,从根源上消除跨线程访问导致的潜在 UB(尤其是 macOS CoreAudio 场景),并用编译期断言锁定 InnerPlayer: Send。
Changes:
- 新增
audio_output.rs:用audio-output-owner专用线程独占rodio::OutputStream,对外仅暴露可跨线程使用的OutputStreamHandle。 InnerPlayer用AudioOutput替换原有stream/stream_handle字段,移除unsafe impl Send,并加入编译期Send断言。- 输出设备枚举/默认设备查询逻辑从
player.rs移到audio_output模块,lib.rs对外 API 调整为调用新模块。
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| native/audio-engine/src/player.rs | 用 AudioOutput 替换输出流字段,删除 unsafe impl Send,并在创建 Sink 等路径接入新输出句柄 |
| native/audio-engine/src/lib.rs | 引入 audio_output 模块并改用其实现获取输出设备列表/默认设备名 |
| native/audio-engine/src/audio_output.rs | 新增跨线程安全音频输出封装:专用线程创建并持有/释放 OutputStream,主线程仅持有 OutputStreamHandle |
Comment on lines
178
to
189
| /// 确保音频输出已就绪,未初始化或设备丢失时重新打开;返回当前输出 | ||
| fn ensure_output(&mut self) -> Result<&AudioOutput> { | ||
| if self.output.is_none() { | ||
| self.output = Some(AudioOutput::new(self.selected_device_name.as_deref())?); | ||
| } | ||
| Ok(self.stream_handle.as_ref().unwrap()) | ||
| } | ||
|
|
||
| /// 根据选择的设备名称构建音频输出流 | ||
| fn build_output_stream( | ||
| device_name: Option<&String>, | ||
| ) -> Result<(OutputStream, OutputStreamHandle)> { | ||
| match device_name { | ||
| Some(name) => { | ||
| let host = cpal::default_host(); | ||
| let device = host | ||
| .output_devices() | ||
| .context("Failed to enumerate output devices")? | ||
| .find(|d| d.name().map(|n| n == *name).unwrap_or(false)) | ||
| .with_context(|| format!("Output device '{}' not found", name))?; | ||
| OutputStream::try_from_device(&device).context("Failed to open named output device") | ||
| } | ||
| None => OutputStream::try_default().context("Failed to open default output device"), | ||
| // 上面分支若进入则 output 必为 Some;否则原本就是 Some | ||
| // 写成 match 而非 unwrap:让借用检查通过且无 panic 路径 | ||
| match self.output { | ||
| Some(ref output) => Ok(output), | ||
| None => Err(anyhow::anyhow!("audio output not initialized")), | ||
| } | ||
| } |
| } | ||
| } | ||
|
|
||
| /// 在调用线程构建 cpal/rodio 输出流 |
Comment on lines
419
to
424
| self.stop_internal(); | ||
|
|
||
| // 重建音频输出(使用用户选择的设备或系统默认) | ||
| let (stream, stream_handle) = Self::build_output_stream(self.selected_device_name.as_ref())?; | ||
| self.stream = Some(stream); | ||
| self.stream_handle = Some(stream_handle); | ||
| // 旧 AudioOutput 在赋值时被 drop,其 owner 线程随之退出并 drop 旧 cpal::Stream | ||
| self.output = Some(AudioOutput::new(self.selected_device_name.as_deref())?); | ||
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
InnerPlayer持有rodio::OutputStream(包装cpal::Stream),rodio docs 明确该类型在所有平台都是!Send + !Sync。原代码用unsafe impl Send for InnerPlayer { }绕过类型系统,注释自称"运行时严格保证 InnerPlayer 不跨线程访问"。但
AudioPlayer.load/seek是#[napi] async fn,napi-rs 用的是多线程 tokio runtime,.await后 Future 可能在任意 worker thread 恢复:```rust
async fn load(...) {
{ let p = self.inner.lock(); ... } // thread A
spawn_blocking(...).await?;
{ let p = self.inner.lock(); ... } // thread A / B / N?
}
```
phase 3 的 `inner.lock()` 可能在另一线程上执行,等价于跨线程访问 InnerPlayer。在 macOS CoreAudio 上是真 UB(参考 cpal#516),Windows/Linux 上"现在凑合能跑"但仍违反 cpal 契约。
修复
新增
audio_output.rs模块封装AudioOutput:`InnerPlayer` 把 `stream: Option` + `stream_handle: Option` 两字段合并成 `output: Option` 一字段。其余所有字段本来就是 Send,因此 `InnerPlayer` 自动满足 `Send`,`unsafe impl Send` 彻底删除。
新增编译期断言锁死:
```rust
const _: fn() = || {
fn assert_send<T: Send>() {}
assert_send::();
};
```
未来任何人加 !Send 字段,编译会立即失败。
跨平台保证
实现纯 std(`std::thread` + `std::sync::mpsc`),无任何平台特定代码:
cpal::Stream 的创建、持有、drop 三阶段全部钉死在同一 OS 线程。
Apollo Rust 规范对照
验证
测试计划
🤖 Generated with Claude Code