1111use std:: sync:: atomic:: { AtomicBool , Ordering } ;
1212use std:: sync:: Arc ;
1313use std:: thread:: { self , JoinHandle } ;
14- use std:: time:: Duration ;
14+ use std:: time:: { Duration , Instant } ;
1515
1616use windows:: core:: GUID ;
1717use windows:: Win32 :: Media :: Audio :: {
@@ -33,6 +33,10 @@ const CLSID_MM_DEVICE_ENUMERATOR: GUID = GUID::from_u128(0xBCDE0395_E52F_467C_8E
3333/// (windows crate 未导出,按 MSDN 定义;用于 EXTENSIBLE 格式识别,审查 S1 修复)。
3434const KSDATAFORMAT_SUBTYPE_IEEE_FLOAT : GUID = GUID :: from_u128 ( 0x00000003_0000_0010_8000_00AA00389B71 ) ;
3535
36+ /// 停止 join 有界等待(REQ-174 v0.7.5):WASAPI GetBuffer 卡在驱动层时
37+ /// 捕获线程无法及时退出,裸 join 永久阻塞停止链路;超时 detach 兜底。
38+ const STOP_JOIN_TIMEOUT : Duration = Duration :: from_secs ( 2 ) ;
39+
3640/// 捕获输出块(16kHz 单声道,定长 200ms)。
3741#[ derive( Debug , Clone ) ]
3842pub struct AudioChunk {
@@ -106,16 +110,35 @@ impl AudioLoopbackCapture {
106110 Ok ( Self { stop_flag, handle : Some ( handle) } )
107111 }
108112
109- /// 停止捕获并等待线程退出(join 超时保护:线程异常时不阻塞调用方)。
110- ///
111- /// @ai-context: 结构体实现 Drop 不能整体 move,故用 &mut + Option::take。
112- pub fn stop ( & mut self ) {
113- self . stop_flag . store ( true , Ordering :: SeqCst ) ;
114- if let Some ( handle) = self . handle . take ( ) {
115- let _ = handle. join ( ) ;
113+ /// 停止捕获并等待线程退出(join 超时保护:线程异常/卡死时不阻塞调用方)。
114+ ///
115+ /// @ai-context: REQ-174(v0.7.5):WASAPI GetBuffer 卡在驱动层时捕获线程
116+ /// 无法及时退出——裸 join 会永久卡死会话停止链路(会话31 实证:
117+ /// finish_session 未执行、live:status stopped 未发出、前端"采集中"
118+ /// 残留至重启兜底翻案)。改为有界等待 + 超时 detach(线程最终
119+ /// 由系统回收,资源不泄漏)。
120+ /// @ai-context: 结构体实现 Drop 不能整体 move,故用 &mut + Option::take。
121+ pub fn stop ( & mut self ) {
122+ self . stop_flag . store ( true , Ordering :: SeqCst ) ;
123+ if let Some ( handle) = self . handle . take ( ) {
124+ // REQ-174:有界等待替代裸 join——2s 内线程未退出即 detach(停止
125+ // 响应不被 WASAPI 阻塞拖死;已置 stop_flag,线程恢复后自行退出)
126+ let deadline = Instant :: now ( ) + STOP_JOIN_TIMEOUT ;
127+ while !handle. is_finished ( ) && Instant :: now ( ) < deadline {
128+ std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
129+ }
130+ if handle. is_finished ( ) {
131+ let _ = handle. join ( ) ; // 已退出:join 立即返回(取回线程结果)
132+ } else {
133+ // 超时 detach:不阻塞调用方(join 句柄 drop 即 detach)
134+ eprintln ! (
135+ "[AudioLoopback] 捕获线程 {:.1}s 内未退出,已 detach(WASAPI 阻塞,资源由系统回收)" ,
136+ STOP_JOIN_TIMEOUT . as_secs_f32( )
137+ ) ;
116138 }
117139 }
118140}
141+ }
119142
120143impl Drop for AudioLoopbackCapture {
121144 fn drop ( & mut self ) {
@@ -441,4 +464,51 @@ mod tests {
441464 assert_eq ! ( d6, d5) ;
442465 assert_eq ! ( d100, d5) ;
443466 }
467+
468+ /// 构造捕获句柄(测试用:注入自定义线程)。
469+ fn make_capture ( stop_flag : Arc < AtomicBool > , handle : JoinHandle < ( ) > ) -> AudioLoopbackCapture {
470+ AudioLoopbackCapture { stop_flag, handle : Some ( handle) }
471+ }
472+
473+ #[ test]
474+ fn stop_returns_promptly_when_thread_stuck ( ) {
475+ // Arrange:模拟 WASAPI GetBuffer 卡死的捕获线程(不检查 stop_flag,
476+ // 永不退出)——REQ-174 场景:裸 join 会永久阻塞停止链路
477+ let stop_flag = Arc :: new ( AtomicBool :: new ( false ) ) ;
478+ let handle = thread:: Builder :: new ( )
479+ . name ( "stuck-capture" . into ( ) )
480+ . spawn ( || loop {
481+ std:: thread:: sleep ( Duration :: from_millis ( 100 ) ) ;
482+ } )
483+ . unwrap ( ) ;
484+ let mut cap = make_capture ( stop_flag, handle) ;
485+ // Act:stop 有界等待(2s 超时 detach)
486+ let started = Instant :: now ( ) ;
487+ cap. stop ( ) ;
488+ // Assert:有界返回(不永久阻塞)、句柄已 take(detach 不泄漏)
489+ assert ! ( started. elapsed( ) < Duration :: from_secs( 3 ) , "stop 阻塞了 {:?}" , started. elapsed( ) ) ;
490+ assert ! ( cap. handle. is_none( ) ) ;
491+ }
492+
493+ #[ test]
494+ fn stop_joins_when_thread_exits_promptly ( ) {
495+ // Arrange:正常捕获线程——stop_flag 置位后快速退出
496+ let stop_flag = Arc :: new ( AtomicBool :: new ( false ) ) ;
497+ let flag = stop_flag. clone ( ) ;
498+ let handle = thread:: Builder :: new ( )
499+ . name ( "healthy-capture" . into ( ) )
500+ . spawn ( move || {
501+ while !flag. load ( Ordering :: SeqCst ) {
502+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
503+ }
504+ } )
505+ . unwrap ( ) ;
506+ let mut cap = make_capture ( stop_flag, handle) ;
507+ // Act:正常路径——join 立即返回(不等满超时)
508+ let started = Instant :: now ( ) ;
509+ cap. stop ( ) ;
510+ // Assert:远小于超时阈值(正常 join 而非 detach)
511+ assert ! ( started. elapsed( ) < Duration :: from_millis( 500 ) , "正常 join 耗时 {:?}" , started. elapsed( ) ) ;
512+ assert ! ( cap. handle. is_none( ) ) ;
513+ }
444514}
0 commit comments