Skip to content

Commit 0834b38

Browse files
committed
fix(live): 停止状态残留根治(REQ-174~176)
- REQ-174:audio_loopback stop() join 加 2s 有界等待超时 detach(兑现注释承诺;WASAPI GetBuffer 卡死时停止链路不再永久阻塞) - REQ-175:App.tsx 全局采集徽标监听 session:fusing 兜底(融合开始=采集已停;live:status stopped 丢失时不再残留采集中) - REQ-176:list_sessions 拉取时 mark_stale_recording 兜底(线程已死但 DB 停留 recording 的会话自动标 failed,无需重启翻案;running_session_id 排除进行中会话) - 单测 +6(join 超时/正常 join;残留标记跳过进行中/全标/幂等);1066 测试全绿 + clippy 新代码零警告 + 前端构建通过
1 parent f081f8a commit 0834b38

6 files changed

Lines changed: 181 additions & 8 deletions

File tree

‎app/src-tauri/src/capture/audio_loopback.rs‎

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use std::sync::atomic::{AtomicBool, Ordering};
1212
use std::sync::Arc;
1313
use std::thread::{self, JoinHandle};
14-
use std::time::Duration;
14+
use std::time::{Duration, Instant};
1515

1616
use windows::core::GUID;
1717
use 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 修复)。
3434
const 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)]
3842
pub 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

120143
impl 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
}

‎app/src-tauri/src/commands_session.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ pub async fn list_sessions(
5959
offset: Option<u64>,
6060
) -> Result<Vec<SessionListItem>, String> {
6161
let limit = limit.unwrap_or(50).min(LIST_LIMIT_MAX);
62+
// REQ-176(v0.7.5):残留 recording 会话兜底——线程已死但 DB 停留
63+
// recording(停止链路异常/崩溃,会话31 实证)→ 列表拉取即翻案,
64+
// 无需重启(running_session_id 排除进行中会话,绝不误标)
65+
let running_id = state.live_session.running_session_id();
66+
state
67+
.db
68+
.mark_stale_recording(running_id)
69+
.map_err(|e| e.to_string())?;
6270
state
6371
.db
6472
.list_sessions(keyword.as_deref(), limit, offset.unwrap_or(0))

‎app/src-tauri/src/db_sessions.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,30 @@ impl Db {
324324
)?;
325325
Ok(affected)
326326
}
327+
328+
/// 残留 recording 会话兜底标记(REQ-176 v0.7.5):跳过进行中的会话 id。
329+
///
330+
/// @ai-context: 与 mark_interrupted_sessions 同语义,但**无需重启**——列表
331+
/// 拉取即翻案。会话31 实证:停止链路异常(audio.stop join 无
332+
/// 超时卡死)时线程未走完 finish_session,DB 停留 recording、
333+
/// 前端"采集中"残留,此前只能等下次启动兜底。running_id 为
334+
/// 真正运行中的会话 id(live_session.running_session_id),
335+
/// 进行中会话绝不误标。
336+
pub fn mark_stale_recording(&self, running_id: Option<i64>) -> Result<usize> {
337+
let conn = self.conn.lock().expect("db lock poisoned");
338+
let affected = match running_id {
339+
Some(id) => conn.execute(
340+
"UPDATE sessions SET status = ?1, ended_at = COALESCE(ended_at, ?2)
341+
WHERE status = ?3 AND id != ?4",
342+
params![SESSION_STATUS_FAILED, unix_seconds(), SESSION_STATUS_RECORDING, id],
343+
)?,
344+
None => conn.execute(
345+
"UPDATE sessions SET status = ?1, ended_at = COALESCE(ended_at, ?2) WHERE status = ?3",
346+
params![SESSION_STATUS_FAILED, unix_seconds(), SESSION_STATUS_RECORDING],
347+
)?,
348+
};
349+
Ok(affected)
350+
}
327351
}
328352

329353
/// 转义 LIKE 通配符(复用 db.rs 实现,审查 L7 收敛)。

‎app/src-tauri/src/db_sessions_tests.rs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,52 @@ fn mark_interrupted_sessions_flags_recording() {
221221
assert!(fetched.ended_at.is_some());
222222
}
223223

224+
#[test]
225+
fn mark_stale_recording_skips_running() {
226+
// Arrange:running_id 对应的 recording 会话不得误标(REQ-176 防御)
227+
let db = mem_db();
228+
let running = db.create_session(&new_session("进行中")).unwrap();
229+
let stale = db.create_session(&new_session("残留")).unwrap();
230+
// Act:running_id=Some(running.id)——残留标 failed,进行中不动
231+
let affected = db.mark_stale_recording(Some(running.id)).expect("mark");
232+
let running_fetched = db.get_session(running.id).unwrap().unwrap();
233+
let stale_fetched = db.get_session(stale.id).unwrap().unwrap();
234+
// Assert
235+
assert_eq!(affected, 1);
236+
assert_eq!(running_fetched.status, "recording");
237+
assert!(running_fetched.ended_at.is_none());
238+
assert_eq!(stale_fetched.status, "failed");
239+
assert!(stale_fetched.ended_at.is_some());
240+
}
241+
242+
#[test]
243+
fn mark_stale_recording_no_running_flags_all() {
244+
// Arrange:无运行中会话(running_id=None)——全部 recording 标 failed
245+
let db = mem_db();
246+
let a = db.create_session(&new_session("残留A")).unwrap();
247+
let b = db.create_session(&new_session("残留B")).unwrap();
248+
// Act
249+
let affected = db.mark_stale_recording(None).expect("mark");
250+
// Assert
251+
assert_eq!(affected, 2);
252+
assert_eq!(db.get_session(a.id).unwrap().unwrap().status, "failed");
253+
assert_eq!(db.get_session(b.id).unwrap().unwrap().status, "failed");
254+
}
255+
256+
#[test]
257+
fn mark_stale_recording_idempotent() {
258+
// Arrange:重复调用不重复计数(同 mark_interrupted_sessions 幂等)
259+
let db = mem_db();
260+
let stale = db.create_session(&new_session("残留")).unwrap();
261+
// Act
262+
let first = db.mark_stale_recording(None).expect("mark");
263+
let second = db.mark_stale_recording(None).expect("mark again");
264+
// Assert
265+
assert_eq!(first, 1);
266+
assert_eq!(second, 0);
267+
assert_eq!(db.get_session(stale.id).unwrap().unwrap().status, "failed");
268+
}
269+
224270
#[test]
225271
fn session_notes_tables_coexist() {
226272
// Arrange:会话与笔记共享同一数据库(向后兼容验证)

‎app/src-tauri/src/live_session.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,20 @@ impl LiveSessionManager {
382382
None => None,
383383
}
384384
}
385+
386+
/// 真正在运行的会话 id(REQ-176 v0.7.5):线程已退出的残留返回 None。
387+
///
388+
/// @ai-context: 与 active_session_id 的区别——active_session_id 对已退出
389+
/// 残留返回其 id(供上层"刚结束"语义使用);本方法只认
390+
/// "线程未退出"。残留回收(mark_stale_recording)用它区分
391+
/// 进行中会话与会话31 类残留(线程异常退出但 DB 停留 recording)。
392+
pub fn running_session_id(&self) -> Option<i64> {
393+
let guard = self.active.lock().expect("live session lock poisoned");
394+
match guard.as_ref() {
395+
Some(a) if !a.thread.is_finished() => Some(a.session_id),
396+
_ => None,
397+
}
398+
}
385399
}
386400

387401
/// 有界等待预备线程就绪(P3):Ready→Ok;Failed/超时→Err(原因)。

‎app/src/App.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ function App() {
5353
}
5454
}),
5555
);
56+
// REQ-175(v0.7.5):停止残留兜底——融合开始 = 采集已停的可靠信号
57+
// (session:fusing 在会话停止后无条件发出;live:status stopped 可能
58+
// 因线程卡死/事件丢失而不到达——会话31 实证"采集中"残留至重启翻案)
59+
unlisteners.push(
60+
await listen<number>("session:fusing", () => {
61+
if (disposed) return;
62+
setCapturing(false);
63+
setRecovering(false);
64+
setPaused(false);
65+
}),
66+
);
5667
// 2026-08 A1:暂停/恢复(全局徽标显示"⏸ 已暂停")
5768
unlisteners.push(
5869
await listen("live:paused", () => {

0 commit comments

Comments
 (0)