Skip to content

fix: 删除 unsafe impl Send,cpal stream 钉到专用线程#17

Merged
imsyy merged 2 commits into
devfrom
fix/audio-engine-thread-safety
May 11, 2026
Merged

fix: 删除 unsafe impl Send,cpal stream 钉到专用线程#17
imsyy merged 2 commits into
devfrom
fix/audio-engine-thread-safety

Conversation

@imsyy
Copy link
Copy Markdown
Member

@imsyy imsyy commented May 11, 2026

背景

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

  • 内部 spawn 名为 `audio-output-owner` 的专用 std 线程
  • 该线程独占持有 `rodio::OutputStream`,在该线程创建、持有、drop
  • 对外只暴露 `Send` 的 `OutputStreamHandle`(rodio docs 承诺 Send + Sync + Clone)
  • `Drop` impl 时先关 channel 让 owner 线程退出(在线程内 drop stream),再 `join()` 等线程结束,保证确定性释放

`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`),无任何平台特定代码:

  • Windows WASAPI:COM init 限定在 owner 线程内
  • Linux ALSA:snd_pcm 句柄独占于 owner 线程
  • macOS CoreAudio:AudioUnitInitialize/Dispose 都在 owner 线程,满足 Apple 文档要求

cpal::Stream 的创建、持有、drop 三阶段全部钉死在同一 OS 线程。

Apollo Rust 规范对照

  • 错误处理:anyhow + `?` 传播;非测试代码无 unwrap/expect
  • 所有权:函数参数用 `Option<&str>`,仅在跨线程移动时必要 clone
  • 文档:模块 `//!` 说明设计 + 公共 API 有 `# Arguments` / `# Errors` / `# Examples`
  • 日志:`tracing::debug!/warn!` 结构化字段标记线程生命周期与失败路径
  • 资源安全:`Drop` impl 保证 cpal stream 释放是同步、确定性的

验证

  • `cargo check`:通过
  • `cargo clippy --all-targets --all-features --locked -- -D warnings`:零警告
  • 编译期 `assert_send::()`:通过
  • 跨平台官方文档核对(rodio docs.rs / cpal issue tracker):设计前提全部成立

测试计划

  • Windows:`pnpm dev` 启动后切换输出设备、播放本地/流媒体歌曲、快速切歌、seek
  • Linux:构建 + 跑同样场景
  • macOS:构建 + 跑同样场景(特别关注 set_output_device 不再 "device busy")

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 11, 2026 09:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

该 PR 针对 native/audio-engineInnerPlayer 通过 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
  • InnerPlayerAudioOutput 替换原有 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 thread native/audio-engine/src/player.rs Outdated
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")),
}
}
Comment thread native/audio-engine/src/audio_output.rs Outdated
}
}

/// 在调用线程构建 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>
@imsyy imsyy merged commit 59193b3 into dev May 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants