From 3d9a512c70d21e64e8383d3feb49c14c276fab2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= <2295878+gqf2008@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:13:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(audio-win):=20install=20=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8D=95=E4=BE=8B=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E4=B8=8B=E4=B8=8D=E5=86=8D=E5=A0=86=E5=8F=A0=E5=B9=BD=E7=81=B5?= =?UTF-8?q?=E8=8A=82=E7=82=B9/=E6=BC=82=E7=A7=BB=E7=AB=AF=E7=82=B9=20GUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install 原先每次都「先清空全部节点、再新建一个」,真机上踩了两件事: 1. 清空那步会「假成功」——CM_Query_And_Remove_SubTreeW 回 CR_SUCCESS 而节点仍在 设备树(2a2f4bd 的 M-c 修复)。没清掉时,本次新建的节点与残留节点共存;而驱动侧 适配器是单例(adapter::create 只允许一个实例),第二个节点在 start_device 里 create 返 null,直接上报 STATUS_INSUFFICIENT_RESOURCES(0xC000009A)—— 设备管理器里 ROOT\MEDIA\0002/0003 的报错就是这么来的。 2. 即便清空成功,「删旧建新」也会换掉设备实例 ID,音频端点 GUID 随之漂移 (setupapi.dev.log 里每次 install 都有一对 Delete Device - SWD\MMDEVAPI\{...}), 正在使用该端点的应用会被静默打断。 改法: - 新增纯函数 `install_plan(existing_nodes) -> Create | Reuse | Recreate`: 0 个节点 → 新建;恰好 1 个 → 就地更新驱动、不重建节点(端点 GUID 保持不变); ≥2 个(历史残留)→ 先全清再重建; - 抽出 `create_device_node()`;`install()` 改为幂等流程;DiInstallDriverW 仍按 INF 硬件 ID 更新所有匹配设备,因此复用路径无需额外绑定动作; - 装完后断言「恰好一个节点」(最多 ~3s 轮询),否则非零退出——宁可 CLI 报错,也不要 静默留下「看着装好了、其实有个死节点」的现场; - README 补「驱动适配器是单例」已知限制,排查表新增「install 后出现多个 ROOT\MEDIA\000N / 0xC000009A」一行。 验证(Windows 本机):`cargo fmt --all -- --check` 干净;`cargo test -p vdev-audio-win` 10 passed(含新增 install_plan_is_idempotent);`cargo clippy -p vdev-audio-win --all-targets -- -D warnings` 干净。 --- crates/vdev-audio-win/README.md | 2 + crates/vdev-audio-win/cli/src/install.rs | 218 ++++++++++++++++------- 2 files changed, 159 insertions(+), 61 deletions(-) diff --git a/crates/vdev-audio-win/README.md b/crates/vdev-audio-win/README.md index 0e4f37e..d815141 100644 --- a/crates/vdev-audio-win/README.md +++ b/crates/vdev-audio-win/README.md @@ -94,6 +94,7 @@ $exe = ".\target\x86_64-pc-windows-msvc\release\vdev-audio-win.exe" - 环回积压:0.3.9.0 的 1 MB 环形缓冲 ≈ **5.46 s**;0.3.10.0 起 256 KB ≈ **1.37 s**(可按需再调); - 音量/静音只有采集 topology 有节点("记事本"语义,无 DSP 效果);渲染拓扑直通; - 驱动不暴露 IOCTL/WriteFile 面;宿主注音走端点推流(`inject`); +- 驱动侧设备适配器是**单例**(`adapter::create` 只允许一个实例):设备树里若出现第二个 `Root\vdev-audio` 节点,第二个会在 `start_device` 里直接上报 `STATUS_INSUFFICIENT_RESOURCES`(`0xC000009A`)——`install` 现已幂等,正常路径不会产生这种节点; - Driver Verifier(special pool + DDI compliance)专项尚未跑。 ## 排查 @@ -105,3 +106,4 @@ $exe = ".\target\x86_64-pc-windows-msvc\release\vdev-audio-win.exe" | 端点存在但 `IAudioClient` 报 `0x80070491` | 开流 pin 的 `KSPROPERTY_PIN_INTERFACES` 是否给了 `KSINTERFACE_STANDARD_LOOPED_STREAMING(1)`;数据范围是否带 `KSDATARANGE_ATTRIBUTES` + 信号处理模式属性(详见社区文档"案例七~十一") | | 能开流但没声音 / 采集无包 | 流是否真的拿到格式(`NewStream` 里应用格式,`bytes_per_sec` 不能是 0);`GetCurrentPadding` 是否下降 | | 装了但行为没变 | INF `DriverVer` 是否升过;`C:\Windows\System32\drivers\vdev_audio.sys` 时间戳 | +| `install` 后出现多个 `ROOT\MEDIA\000N` 节点 / 设备报 `0xC000009A` | 该驱动的设备适配器是**单例**:一个系统只允许一个设备节点,多出来的节点必然 `CM_PROB_FAILED_START`。0.3.10.0 之前的 CLI 卸载是「假成功」,会留下这种幽灵节点;现版本 `install` 已幂等(有节点就地更新、多节点先清后建、装完断言恰好一个),遇到残留直接 `uninstall` 清干净重装 | diff --git a/crates/vdev-audio-win/cli/src/install.rs b/crates/vdev-audio-win/cli/src/install.rs index 28ac5ac..a8b6163 100644 --- a/crates/vdev-audio-win/cli/src/install.rs +++ b/crates/vdev-audio-win/cli/src/install.rs @@ -352,39 +352,44 @@ fn remove_all_nodes() -> Result { Ok(removed) } -/// 安装驱动:清理旧节点 + 创建设备节点 + DiInstallDriverW 装入驱动存储 -pub fn install(inf_dir: &Path) -> Result<()> { - let inf_path = inf_dir.join("vdev-audio.inf"); - if !inf_path.exists() { - bail!("找不到 INF: {}", inf_path.display()); - } - - let removed = remove_all_nodes()?; - if removed > 0 { - println!("已清理 {removed} 个残留设备节点"); - } +/// 安装时的节点处置决策(纯函数,Windows 宿主可单测)。 +/// +/// 背景:`install` 原先每次都「先清空全部节点、再新建一个」,真机上踩了两件事: +/// +/// 1. 清空那步会「假成功」——`CM_Query_And_Remove_SubTreeW` 回 `CR_SUCCESS` 而 +/// 节点仍在设备树(2a2f4bd 的 M-c 修复)。一旦没清掉,本次新建的节点就与残留 +/// 节点共存;而驱动侧适配器是**单例**(`adapter::create` 只允许一个实例,见 +/// `crates/vdev-audio-win/driver/src/adapter.rs`),第二个节点在 `start_device` +/// 里 create 返回 null,直接上报 `STATUS_INSUFFICIENT_RESOURCES`——设备管理器 +/// 里 `ROOT\MEDIA\0002/0003` 的 `0xC000009A` 就是这么来的。 +/// 2. 即便清空成功,「删旧建新」也会换掉设备实例 ID,音频端点 GUID 随之漂移 +/// (每次 install 后 setupapi 日志里都有一对 `Delete Device - SWD\MMDEVAPI\{...}`), +/// 正在使用该端点的应用会被静默打断。 +/// +/// 所以安装必须**幂等**:没有节点才新建;已有一个节点就只更新驱动、不动节点; +/// 发现多个(历史残留)才先全清再重建。 +#[derive(Debug, PartialEq, Eq)] +enum InstallPlan { + /// 设备树里一个 `Root\vdev-audio` 节点都没有:需要新建一个 + Create, + /// 恰好一个节点:就地更新驱动,不重建节点(端点 GUID 保持不变) + Reuse, + /// 多个节点(历史残留):先全部清掉再重建 + Recreate, +} - // 从 INF 提取类 GUID 与类名 - let inf_wide: Vec = inf_path - .as_os_str() - .to_str() - .context("INF 路径不是合法 UTF-8")? - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - let mut class_guid = windows::core::GUID::zeroed(); - let mut class_name = [0u16; 256]; - unsafe { - SetupDiGetINFClassW( - windows::core::PCWSTR(inf_wide.as_ptr()), - &mut class_guid, - &mut class_name, - None, - ) +fn install_plan(existing_nodes: usize) -> InstallPlan { + match existing_nodes { + 0 => InstallPlan::Create, + 1 => InstallPlan::Reuse, + _ => InstallPlan::Recreate, } - .context("SetupDiGetINFClassW failed")?; +} - let devs = unsafe { SetupDiCreateDeviceInfoList(Some(&class_guid), None) } +/// 在 Media 类下新建一个 `Root\vdev-audio` 设备节点(`DIF_REGISTERDEVICE`), +/// 供随后的 `DiInstallDriverW` 绑定。 +fn create_device_node(class_guid: &windows::core::GUID, class_name: &[u16]) -> Result<()> { + let devs = unsafe { SetupDiCreateDeviceInfoList(Some(class_guid), None) } .context("SetupDiCreateDeviceInfoList failed")?; let result = (|| -> Result<()> { @@ -392,20 +397,14 @@ pub fn install(inf_dir: &Path) -> Result<()> { cbSize: std::mem::size_of::() as u32, ..Default::default() }; - let class_name_cstr: Vec = class_name - .iter() - .take_while(|&&c| c != 0) - .copied() - .chain(std::iter::once(0)) - .collect(); // 审查 L2 修复:删除 0xE0000207 → SetupDiOpenDeviceInfoW(类名) 的"死路径" // 回退——SetupDiOpenDeviceInfoW 要的是设备实例 ID,类名永远打不开设备 // (display 侧同款回退已标"死路径"并移除);创建失败直接如实报错。 unsafe { SetupDiCreateDeviceInfoW( devs, - windows::core::PCWSTR(class_name_cstr.as_ptr()), - &class_guid, + windows::core::PCWSTR(class_name.as_ptr()), + class_guid, None, None, DICD_GENERATE_ID, @@ -426,29 +425,6 @@ pub fn install(inf_dir: &Path) -> Result<()> { .context("SetupDiSetDeviceRegistryPropertyW(SPDRP_HARDWAREID) failed")?; unsafe { SetupDiCallClassInstaller(DIF_REGISTERDEVICE, devs, Some(&dev_info)) } .with_context(|| "DIF_REGISTERDEVICE failed")?; - - // DiInstallDriverW 装入驱动存储并安装 - let inf_path = std::fs::canonicalize(&inf_path).context("无法解析 INF 绝对路径")?; - let inf_path_wide: Vec = inf_path - .as_os_str() - .to_str() - .context("INF 路径不是合法 UTF-8")? - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - let mut reboot = windows::Win32::Foundation::BOOL(0); - unsafe { - DiInstallDriverW( - None, - windows::core::PCWSTR(inf_path_wide.as_ptr()), - DIIRFLAG_FORCE_INF, - Some(&mut reboot), - ) - } - .with_context(|| "DiInstallDriverW failed(内核驱动需测试签名或已签名证书)")?; - if reboot.as_bool() { - println!("系统提示需要重启以完成安装"); - } Ok(()) })(); @@ -456,6 +432,115 @@ pub fn install(inf_dir: &Path) -> Result<()> { result } +/// 安装驱动:幂等处置设备节点 + `DiInstallDriverW` 装入驱动存储 + 事后断言恰好一个节点 +pub fn install(inf_dir: &Path) -> Result<()> { + let inf_path = inf_dir.join("vdev-audio.inf"); + if !inf_path.exists() { + bail!("找不到 INF: {}", inf_path.display()); + } + + // 从 INF 提取类 GUID 与类名 + let inf_wide: Vec = inf_path + .as_os_str() + .to_str() + .context("INF 路径不是合法 UTF-8")? + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut class_guid = windows::core::GUID::zeroed(); + let mut class_name = [0u16; 256]; + unsafe { + SetupDiGetINFClassW( + windows::core::PCWSTR(inf_wide.as_ptr()), + &mut class_guid, + &mut class_name, + None, + ) + } + .context("SetupDiGetINFClassW failed")?; + let class_name_cstr: Vec = class_name + .iter() + .take_while(|&&c| c != 0) + .copied() + .chain(std::iter::once(0)) + .collect(); + + // 幂等处置:理由见 `install_plan` 的注释(删旧建新会漂移端点 GUID;残留节点 + // 会因驱动单例而上报 0xC000009A) + let existing = vdev_instance_ids()?; + let plan = install_plan(existing.len()); + match plan { + InstallPlan::Create => {} + InstallPlan::Reuse => { + println!( + "已存在设备节点 {},就地更新驱动(不重建节点,端点 GUID 保持不变)", + existing.join(", ") + ); + } + InstallPlan::Recreate => { + eprintln!( + "检测到 {} 个 vdev-audio 节点(应为 1):{},先全部清理后重建", + existing.len(), + existing.join(", ") + ); + let removed = remove_all_nodes()?; + println!("已清理 {removed} 个残留设备节点"); + } + } + if plan != InstallPlan::Reuse { + create_device_node(&class_guid, &class_name_cstr)?; + } + + // DiInstallDriverW 装入驱动存储并安装:它按 INF 的硬件 ID 更新**所有**匹配设备 + // (含刚新建的这个),所以复用路径不需要额外的绑定动作。 + let inf_path = std::fs::canonicalize(&inf_path).context("无法解析 INF 绝对路径")?; + let inf_path_wide: Vec = inf_path + .as_os_str() + .to_str() + .context("INF 路径不是合法 UTF-8")? + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut reboot = windows::Win32::Foundation::BOOL(0); + unsafe { + DiInstallDriverW( + None, + windows::core::PCWSTR(inf_path_wide.as_ptr()), + DIIRFLAG_FORCE_INF, + Some(&mut reboot), + ) + } + .with_context(|| "DiInstallDriverW failed(内核驱动需测试签名或已签名证书)")?; + if reboot.as_bool() { + println!("系统提示需要重启以完成安装"); + } + + // 事后断言:安装完成后必须**恰好一个**节点(设备注册/移除是异步的,给它最多 + // ~3s)。多节点意味着后建的节点必然因驱动单例失败——宁可 CLI 非零退出,也不要 + // 静默留下「看着装好了、其实有个死节点」的现场。 + let mut ids = vdev_instance_ids()?; + for _ in 0..6 { + if ids.len() == 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + ids = vdev_instance_ids()?; + } + if ids.len() != 1 { + bail!( + "安装后 vdev-audio 节点数为 {}(应为 1):{}(残留节点请先 uninstall 清理)", + ids.len(), + if ids.is_empty() { + "".to_string() + } else { + ids.join(", ") + } + ); + } + println!("虚拟声卡驱动已安装(设备节点 {})", ids[0]); + Ok(()) +} + /// 卸载:移除全部 Root\vdev-audio 残留节点,并断言零残留(有残留非零退出)。 /// 返回是否找到并移除。 pub fn uninstall() -> Result { @@ -576,4 +661,15 @@ mod tests { let ids2 = multi(&[r"Root\vdev"]); assert!(!hwid_matches(&ids2, r"Root\vdev-audio")); } + + /// 安装幂等决策回归:0 → 新建、1 → 就地更新、≥2 → 先清后建。 + /// 回归点是「每次 install 都删旧建新」——清空那步一旦假成功,节点就会堆成 + /// `ROOT\MEDIA\0001/0002/0003`,后建的节点因驱动单例上报 `0xC000009A`。 + #[test] + fn install_plan_is_idempotent() { + assert_eq!(install_plan(0), InstallPlan::Create); + assert_eq!(install_plan(1), InstallPlan::Reuse); + assert_eq!(install_plan(2), InstallPlan::Recreate); + assert_eq!(install_plan(3), InstallPlan::Recreate); + } }