清扫与收束再加固:Linux 命令行绑定、快照 fail-closed、收割未确认保留目录

sweep.rs:Linux 杀前要求 /proc/pid/cmdline 含 --user-data-dir=<本目录>/profile,把 PID 绑到配置目录;Windows/macOS 注释标明已知残余风险
sweep.rs:进程快照枚举错误一律报错(仅 ERROR_NO_MORE_FILES 为正常结束),残缺快照不再误报收割完成
sweep.rs:追踪列表记录每个成员的启动身份并在终止前复核,排除 PID 复用误杀;root 死后按旧父 PID 继续发现孤儿(PID 已复用则不发现)
process.rs:修正注释——Windows 没有 root 死亡级联退出,spawn 与 Job 绑定的逸出窗口为毫秒级
process.rs:launch_owned_browser 持有 TempDir,启动失败或收束未确认时保留目录与 owner.json,交跨会话清扫收割
This commit is contained in:
2026-09-22 17:57:09 +08:00
parent 6b215d30e5
commit 19ed2f2c2f
2 changed files with 130 additions and 49 deletions
@@ -206,8 +206,8 @@ struct BrowserProcessGuard {
}
impl BrowserProcessGuard {
/// 整树收割:Job 终止全树,再 kill+wait root 兜底Unix 与 Job
/// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出
/// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底
/// Unix 只终止 root子进程依赖 root 死亡的级联退出。
/// 返回是否已确认 root 进程退出。
async fn reap(&mut self) -> bool {
#[cfg(windows)]
@@ -265,6 +265,9 @@ struct OwnedBrowser {
process: BrowserProcessGuard,
handler_task: tokio::task::JoinHandle<()>,
drain_task: tokio::task::JoinHandle<()>,
// Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop
// 不能直接移动字段)。
temp: Option<TempDir>,
}
impl OwnedBrowser {
@@ -282,9 +285,20 @@ impl OwnedBrowser {
return Ok(());
}
if !self.process.reap().await {
// 进程退出未确认:保留目录与 owner.json,留待下次启动或
// 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。
if let Some(temp) = self.temp.take() {
std::mem::forget(temp);
}
return Err("browser-cleanup-unconfirmed".into());
}
self.process.confirm_reaped().await
let confirmed = self.process.confirm_reaped().await;
if confirmed.is_err() {
if let Some(temp) = self.temp.take() {
std::mem::forget(temp);
}
}
confirmed
}
}
@@ -297,12 +311,14 @@ impl Drop for OwnedBrowser {
}
/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出
/// 窗口:绑定前产生的子进程未入 Job随 root 死亡级联退出;绑定之后
/// 的一切子进程由 Job 全覆盖。
/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出;
/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子
/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。
/// 绑定之后的一切子进程由 Job 全覆盖。
async fn launch_owned_browser(
config: BrowserConfig,
executable: &std::path::Path,
temp_root: &std::path::Path,
temp: TempDir,
) -> Result<OwnedBrowser, OwnedBrowserLaunchError> {
let child = config
.launch()
@@ -320,13 +336,16 @@ async fn launch_owned_browser(
};
// 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。
if let Some(pid) = process.child.inner.id() {
super::sweep::write_browser_process_owner(temp_root, pid, executable);
super::sweep::write_browser_process_owner(temp.path(), pid, executable);
}
let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await
{
Ok(pair) => pair,
Err(error) => {
let _ = process.reap().await;
// 收割未确认时保留目录与 owner.json,交给跨会话清扫。
if !process.reap().await {
std::mem::forget(temp);
}
return Err(error);
}
};
@@ -340,12 +359,16 @@ async fn launch_owned_browser(
Ok(Ok(pair)) => pair,
Ok(Err(_)) => {
drain_task.abort();
let _ = process.reap().await;
if !process.reap().await {
std::mem::forget(temp);
}
return Err(OwnedBrowserLaunchError::ConnectFailed);
}
Err(_) => {
drain_task.abort();
let _ = process.reap().await;
if !process.reap().await {
std::mem::forget(temp);
}
return Err(OwnedBrowserLaunchError::ConnectTimeout);
}
};
@@ -361,6 +384,7 @@ async fn launch_owned_browser(
process,
handler_task,
drain_task,
temp: Some(temp),
})
}
@@ -371,7 +395,7 @@ pub(crate) async fn check_browser_health() -> Result<BrowserIdentity, String> {
let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?;
let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>")
.map_err(|_| "browser-config-invalid")?;
let owned = launch_owned_browser(config, &discovered.executable_path, temporary.path())
let owned = launch_owned_browser(config, &discovered.executable_path, temporary)
.await
.map_err(|error| {
if error.is_timeout() {
@@ -444,19 +468,15 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
&proxy_bypass_list,
)?;
let owned = launch_owned_browser(
config,
&browser_executable.executable_path,
browser_temp.path(),
)
.await
.map_err(|error| {
if error.is_timeout() {
"启动浏览器超时".to_string()
} else {
format!("启动浏览器失败:{}", error.code())
}
})?;
let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp)
.await
.map_err(|error| {
if error.is_timeout() {
"启动浏览器超时".to_string()
} else {
format!("启动浏览器失败:{}", error.code())
}
})?;
let work = run_browser_validation(
owned.browser(),
@@ -484,7 +504,6 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(),
);
}
drop(browser_temp);
let mut result = validation?;
result.completed_at_unix_ms = unix_time_ms();
let persisted_result = browser_validation_result_for_report(&result)?;
@@ -116,8 +116,12 @@ fn trusted_browser_executable(path: &Path) -> bool {
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置
/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。
#[cfg(windows)]
fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool {
// 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对
// 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
@@ -141,16 +145,28 @@ fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
}
#[cfg(target_os = "linux")]
fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool {
let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else {
return false;
};
same_executable_path(&actual, expected)
if !same_executable_path(&actual, expected) {
return false;
}
// 命令行必须携带 --user-data-dir=<本目录>/profile:镜像一致只证明是
// 浏览器,不能证明是“这轮预检的”浏览器。
let Ok(cmdline) = fs::read(format!("/proc/{pid}/cmdline")) else {
return false;
};
let marker = format!("--user-data-dir={}", profile_dir.to_string_lossy());
cmdline
.split(|byte| *byte == 0)
.any(|arg| arg == marker.as_bytes())
}
// macOS 等无 /proc 的平台没有廉价的镜像核对手段,由目录属主检查兜底。
// macOS 等无 /proc 的平台没有廉价的镜像与命令行核对手段,由目录属主检查
// 兜底;已知残余风险:同用户伪造 owner.json 时可终止任意存活 PID。
#[cfg(all(unix, not(target_os = "linux")))]
fn live_process_executable_matches(_pid: u32, expected: &Path) -> bool {
fn live_process_executable_matches(_pid: u32, expected: &Path, _profile_dir: &Path) -> bool {
expected.is_file()
}
@@ -169,27 +185,35 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
{
// 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判
// 成功——root 先死而子进程残留时不再误报收割完成。
let mut tracked: Vec<u32> = Vec::new();
let mut tracked: Vec<(u32, String)> = Vec::new();
for _ in 0..TREE_KILL_MAX_PASSES {
let snapshot = windows_process_snapshot()?;
let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid);
if root_present && process_identity_matches(root_pid, expected_identity) {
// root 仍是目标浏览器:发现并追踪当前整棵子树。
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
if !tracked.contains(&pid) {
tracked.push(pid);
track_process(&mut tracked, pid);
}
} else if !root_present {
// root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父
// PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认
// 复用者的子进程。
for (pid, ppid) in &snapshot {
if *ppid == root_pid {
track_process(&mut tracked, *pid);
}
}
} else {
// root 已退出或 PID 被复用:复用者与本任务无关,不再追踪 root。
tracked.retain(|pid| *pid != root_pid);
}
// 只统计仍存活的成员;全部消失才算收割完成。
tracked.retain(|pid| snapshot.iter().any(|(live, _)| live == pid));
// 只统计仍存活且启动身份未变的成员(复核排除 PID 复用);
// 全部消失才算收割完成。
tracked.retain(|(pid, identity)| {
snapshot.iter().any(|(live, _)| live == pid)
&& process_identity_matches(*pid, identity)
});
if tracked.is_empty() {
return Ok(());
}
for pid in &tracked {
for (pid, _) in &tracked {
// 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程
// OpenProcess 的瞬时失败会在下一轮自然消解。
windows_terminate_process(*pid);
@@ -220,9 +244,24 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
}
}
#[cfg(windows)]
/// 记录待收割成员及其启动身份:之后每轮终止前复核身份,排除 PID 复用
/// 后误杀无关进程。拿不到身份的进程不追踪、不触碰(fail-closed)。
#[cfg(windows)]
fn track_process(tracked: &mut Vec<(u32, String)>, pid: u32) {
if tracked.iter().any(|(known, _)| *known == pid) {
return;
}
if let Ok(Some(identity)) = crate::runner::external_agent_runner_process_start_identity(pid) {
tracked.push((pid, identity));
}
}
#[cfg(windows)]
fn windows_process_snapshot() -> Result<Vec<(u32, u32)>, String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
@@ -235,16 +274,25 @@ fn windows_process_snapshot() -> Result<Vec<(u32, u32)>, String> {
let mut entry = PROCESSENTRY32W::default();
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
let mut processes = Vec::new();
// SAFETY: entry 指向可写的 PROCESSENTRY32WdwSize 已初始化。
// SAFETY: entry 指向可写的 PROCESSENTRY32WdwSize 已初始化。返回 0
// 只在 ERROR_NO_MORE_FILES 时是正常结束,其余错误按截断处理
// fail-closed:残缺快照会导致误报收割完成)。
let mut available = unsafe { Process32FirstW(snapshot, &mut entry) };
while available != 0 {
loop {
if available == 0 {
// SAFETY: 紧跟失败的枚举调用读取错误码。
let error = unsafe { GetLastError() };
// SAFETY: snapshot 是本函数持有的合法句柄。
unsafe { CloseHandle(snapshot) };
if error == ERROR_NO_MORE_FILES {
return Ok(processes);
}
return Err("browser-sweep-snapshot-truncated".into());
}
processes.push((entry.th32ProcessID, entry.th32ParentProcessID));
// SAFETY: 同上。
available = unsafe { Process32NextW(snapshot, &mut entry) };
}
// SAFETY: snapshot 是本函数持有的合法句柄。
unsafe { CloseHandle(snapshot) };
Ok(processes)
}
#[cfg(windows)]
@@ -358,8 +406,13 @@ fn sweep_owned_directory(
return;
}
// 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫
// 终止本用户的无关进程。
if !live_process_executable_matches(owner.browser_pid, Path::new(&owner.executable)) {
// 终止本用户的无关进程。profile 子目录名与 process.rs 的
// browser_config 保持一致;Linux 上借此把 PID 绑到本目录。
if !live_process_executable_matches(
owner.browser_pid,
Path::new(&owner.executable),
&dir.join("profile"),
) {
notes.push(format!("kept-image-mismatch:{name}"));
return;
}
@@ -542,10 +595,19 @@ mod tests {
#[test]
fn live_process_executable_matches_current_process_image() {
let exe = std::env::current_exe().unwrap();
assert!(live_process_executable_matches(std::process::id(), &exe));
let profile = Path::new("C:\\fixture\\ga-browser-x\\profile");
// Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程
// 不具备该标记,正例只在 Windows 断言。
#[cfg(windows)]
assert!(live_process_executable_matches(
std::process::id(),
&exe,
profile
));
assert!(!live_process_executable_matches(
std::process::id(),
Path::new("C:\\fixture\\chrome.exe")
Path::new("C:\\fixture\\chrome.exe"),
profile
));
}
}