Skip to content

Commit c55bdef

Browse files
committed
fix(review): second_pass 生命周期(panic 收尾/单飞/取消收敛)+ 采集孤儿清理 + 图类型白名单
1 parent 775cf78 commit c55bdef

4 files changed

Lines changed: 74 additions & 21 deletions

File tree

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ fn run_second_pass(
101101
let mut inserted = 0usize;
102102
for (i, (start_ms, end_ms)) in windows.iter().enumerate() {
103103
if job.is_cancelled() {
104-
let _ = app.emit("session:refine2:aborted", session_id);
104+
// 取消:已完成的窗保留为 pending 草稿(重跑会清理);payload 与
105+
// 其余终态事件同构(对象含 sessionId——前端监听按对象消费)
106+
let _ = app.emit(
107+
"session:refine2:aborted",
108+
serde_json::json!({ "sessionId": session_id }),
109+
);
105110
return Ok(inserted);
106111
}
107112
let from = (*start_ms * sample_rate as u64 / 1000) as usize;
@@ -169,24 +174,46 @@ pub fn second_pass_start(
169174
return Err("未找到会话落盘音频(离线精修仅支持有 S4 音频的实时捕获会话)".to_string());
170175
}
171176
let job = Pass2Job::new();
177+
// 单锁内原子“检查-注册”(审查 M4:防双击/跨窗口同跑):
178+
// 会话级防重 + 全局单飞(引擎池 ASR worker 串行,多会话并行只会互相排队)
172179
{
173180
let mut jobs = state.second_pass_jobs.lock().map_err(|_| "任务注册表锁中毒".to_string())?;
181+
if jobs.contains_key(&session_id) {
182+
return Err("该会话已有第二遍任务进行中".to_string());
183+
}
184+
if !jobs.is_empty() {
185+
return Err("已有其他会话的第二遍在跑(引擎串行)——请先等待完成或取消".to_string());
186+
}
174187
jobs.insert(session_id, job.clone());
175188
}
176189
let app = app.clone();
177190
let db = state.db.clone();
178191
let engines = state.engines.clone();
179192
let path = audio_path.clone();
193+
// 线程内取消标志副本(run 移走 job 后 wrapper 仍需读取消态)
194+
let cancel_flag = job.clone();
180195
let spawn = std::thread::Builder::new()
181196
.name("entropy-second-pass".into())
182197
.spawn(move || {
183-
let result = run_second_pass(app.clone(), db.clone(), engines, path, session_id, job);
198+
// panic 收尾(审查 M4):catch_unwind 保证注册表清理与终态事件
199+
// ——panic 永不残留 running 卡死态
200+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
201+
run_second_pass(app.clone(), db.clone(), engines, path, session_id, job)
202+
}))
203+
.unwrap_or_else(|_| {
204+
Err("第二遍内部错误(panic)——已清理注册表,请重试".to_string())
205+
});
184206
// 无论成败先移除运行标记(注册表与线程一一对应)
185207
if let Ok(mut jobs) = app.state::<AppState>().second_pass_jobs.lock() {
186208
jobs.remove(&session_id);
187209
}
188210
match result {
189211
Ok(n) => {
212+
// 取消路径已发 aborted——不再补发 done(避免“已取消”被覆盖闪烁)
213+
if cancel_flag.is_cancelled() {
214+
eprintln!("[SecondPass] 会话 {} 第二遍已取消(保留 {} 条已完成草稿)", session_id, n);
215+
return;
216+
}
190217
let _ = app.emit(
191218
"session:refine2:done",
192219
serde_json::json!({ "sessionId": session_id, "proposals": n }),

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,23 @@ fn capture_inner(st: &AppState, url: &str) -> Result<WebCaptureView, String> {
100100
kind: Some("web".to_string()),
101101
})
102102
.map_err(|e| e.to_string())?;
103-
st.db
104-
.insert_web_page(&WebPage {
105-
session_id: session.id,
106-
url: url.to_string(),
107-
site: page.site.clone(),
108-
author: page.author.clone(),
109-
published: page.published.clone(),
110-
markdown: if page.ok { page.markdown.clone() } else { String::new() },
111-
raw_html: if page.ok { None } else { Some(html.clone()) },
112-
extracted_ok: page.ok,
113-
fetched_at: now,
114-
})
115-
.map_err(|e| e.to_string())?;
103+
if let Err(e) = st.db.insert_web_page(&WebPage {
104+
session_id: session.id,
105+
url: url.to_string(),
106+
site: page.site.clone(),
107+
author: page.author.clone(),
108+
published: page.published.clone(),
109+
markdown: if page.ok { page.markdown.clone() } else { String::new() },
110+
raw_html: if page.ok { None } else { Some(html.clone()) },
111+
extracted_ok: page.ok,
112+
fetched_at: now,
113+
}) {
114+
// 审查 M5:页面落库失败 → 清理孤儿会话(重试不产生重复/不可转笔记残留)
115+
if let Err(clean_err) = st.db.delete_session(session.id) {
116+
eprintln!("[WebCapture] 孤儿会话清理失败 {}: {}", session.id, clean_err);
117+
}
118+
return Err(format!("页面落库失败(已清理本次会话): {}", e));
119+
}
116120
// 会话域广播(列表即时可见)
117121
crate::notify::emit_changed(&st.app, crate::notify::DataDomain::Sessions);
118122
Ok(WebCaptureView {

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,12 +306,26 @@ fn ingest_from_extension(
306306
let mut markdown = p.markdown.clone();
307307
let notes_images = data_dir.join("notes-images");
308308
let _ = std::fs::create_dir_all(&notes_images);
309+
let mut written_files: Vec<String> = Vec::new();
309310
for img in &p.images {
310311
if let Some(bytes) = crate::web_inbox::data_uri_bytes(&img.data_base64) {
311312
let mime = img.data_base64.split_once(';').map(|(m, _)| m).unwrap_or("data:image/png");
312-
let ext = mime.rsplit('/').next().unwrap_or("png");
313+
let raw_ext = mime.rsplit('/').next().unwrap_or("png").to_ascii_lowercase();
314+
// 审查 L1:扩展名白名单映射(内容类型断言——禁止 svg+xml/.html 落盘)
315+
let ext = match raw_ext.as_str() {
316+
"png" | "apng" => "png",
317+
"jpg" | "jpeg" => "jpg",
318+
"gif" => "gif",
319+
"webp" => "webp",
320+
"bmp" => "bmp",
321+
_ => {
322+
eprintln!("[WebInbox] 拒绝非白名单图片类型: {}", raw_ext);
323+
continue;
324+
}
325+
};
313326
let filename = format!("web-{}-{}.{}", session.id, crate::web_inbox::short_hash(&bytes), ext);
314327
if std::fs::write(notes_images.join(&filename), bytes).is_ok() {
328+
written_files.push(filename.clone());
315329
// 替换 md 中 `![name](data:...)` 引用为相对路径(编辑器同解析基座)
316330
markdown = markdown.replace(
317331
&format!("]({})", img.data_base64),
@@ -320,7 +334,7 @@ fn ingest_from_extension(
320334
}
321335
}
322336
}
323-
db.insert_web_page(&WebPage {
337+
if let Err(e) = db.insert_web_page(&WebPage {
324338
session_id: session.id,
325339
url: p.url.clone().unwrap_or_else(|| "".to_string()),
326340
site: p.site.clone(),
@@ -330,8 +344,16 @@ fn ingest_from_extension(
330344
raw_html: None,
331345
extracted_ok: true,
332346
fetched_at: now,
333-
})
334-
.map_err(|e| e.to_string())?;
347+
}) {
348+
// 审查 M5:页面落库失败 → 清理孤儿会话与本次已落图文件
349+
for f in &written_files {
350+
let _ = std::fs::remove_file(notes_images.join(f));
351+
}
352+
if let Err(clean_err) = db.delete_session(session.id) {
353+
eprintln!("[WebInbox] 孤儿会话清理失败 {}: {}", session.id, clean_err);
354+
}
355+
return Err(format!("页面落库失败(已清理本次会话与图文件): {}", e));
356+
}
335357
if let Some(app) = app {
336358
crate::notify::emit_changed(app, crate::notify::DataDomain::Sessions);
337359
}

‎app/src/components/SecondPassPanel.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ export default function SecondPassPanel({ sessionId, onChanged, onClose }: Props
119119
setErr(`第二遍失败: ${e.payload.error}`);
120120
void reload();
121121
}),
122-
listen<number>("session:refine2:aborted", (e) => {
123-
if (e.payload !== sessionId) return;
122+
listen<{ sessionId: number }>("session:refine2:aborted", (e) => {
123+
if (e.payload.sessionId !== sessionId) return;
124124
setRunningMsg("已取消(已完成窗口保留为待裁决草稿)");
125125
void reload();
126126
}),

0 commit comments

Comments
 (0)