清扫与收束再加固: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:
@@ -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 指向可写的 PROCESSENTRY32W,dwSize 已初始化。
|
||||
// SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。返回 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user