Merge pull request '修复 Web 预检浏览器失败恢复' (#591) from fix/web-preflight-browser-recovery into master
Project CI / AI game creator shell Rust crates (push) Successful in 2m40s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 4m51s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m24s
Project CI / Backend tests (push) Failing after 3m56s
Project CI / Frontend tests (push) Successful in 3m5s
Project CI / AI game creator shell web tests (push) Successful in 2m56s
Project CI / Repository checks (push) Failing after 4m3s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / AI game creator shell Rust crates (push) Successful in 2m40s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 4m51s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m24s
Project CI / Backend tests (push) Failing after 3m56s
Project CI / Frontend tests (push) Successful in 3m5s
Project CI / AI game creator shell web tests (push) Successful in 2m56s
Project CI / Repository checks (push) Failing after 4m3s
Project CI / Native shell tests (push) Successful in 6m54s
Reviewed-on: #591
This commit was merged in pull request #591.
This commit is contained in:
@@ -86,7 +86,7 @@ maud = "0.27.0"
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::discovery::system_browser_candidates;
|
||||
use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX};
|
||||
#[cfg(windows)]
|
||||
use crate::process_session::WindowsProcessJob;
|
||||
|
||||
const OWNER_FILE: &str = "owner.json";
|
||||
const OWNER_SCHEMA: &str = "agc-browser-process.v1";
|
||||
@@ -34,13 +36,17 @@ pub(super) struct BrowserProcessOwner {
|
||||
|
||||
/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写,
|
||||
/// 该目录之后按旧残留只删不杀。
|
||||
pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) {
|
||||
pub(super) fn write_browser_process_owner(
|
||||
temp_root: &Path,
|
||||
browser_pid: u32,
|
||||
executable: &Path,
|
||||
) -> bool {
|
||||
let identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(browser_pid);
|
||||
let owner_identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(std::process::id());
|
||||
let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let owner = BrowserProcessOwner {
|
||||
schema_version: OWNER_SCHEMA.into(),
|
||||
@@ -52,9 +58,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex
|
||||
created_unix_ms: super::evidence::unix_time_ms(),
|
||||
};
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&owner) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let _ = fs::write(temp_root.join(OWNER_FILE), bytes);
|
||||
fs::write(temp_root.join(OWNER_FILE), bytes).is_ok()
|
||||
}
|
||||
|
||||
fn read_browser_process_owner(dir: &Path) -> Option<BrowserProcessOwner> {
|
||||
@@ -115,34 +121,142 @@ fn trusted_browser_executable(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
|
||||
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
|
||||
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
|
||||
/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置
|
||||
/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。
|
||||
/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC
|
||||
/// 实例或用户 Edge 误认成本轮浏览器。
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool {
|
||||
// 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对
|
||||
// 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。
|
||||
fn windows_process_command_line(pid: u32) -> Option<String> {
|
||||
use std::ffi::c_void;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
struct UnicodeString {
|
||||
length: u16,
|
||||
maximum_length: u16,
|
||||
buffer: *const u16,
|
||||
}
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
unsafe extern "system" {
|
||||
fn NtQueryInformationProcess(
|
||||
process: *mut c_void,
|
||||
information_class: u32,
|
||||
information: *mut c_void,
|
||||
information_length: u32,
|
||||
return_length: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) };
|
||||
if process.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut required = 0;
|
||||
let _ =
|
||||
unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) };
|
||||
if required == 0 || required > 64 * 1024 {
|
||||
unsafe { CloseHandle(process) };
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u8; required as usize];
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process,
|
||||
60,
|
||||
buffer.as_mut_ptr().cast(),
|
||||
required,
|
||||
&mut required,
|
||||
)
|
||||
};
|
||||
unsafe { CloseHandle(process) };
|
||||
if status != 0 {
|
||||
return None;
|
||||
}
|
||||
let command_line = unsafe { &*(buffer.as_ptr().cast::<UnicodeString>()) };
|
||||
if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let text = unsafe {
|
||||
std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2)
|
||||
};
|
||||
String::from_utf16(text).ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_command_line_arguments(command_line: &str) -> Option<Vec<String>> {
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::UI::Shell::CommandLineToArgvW;
|
||||
|
||||
let wide = command_line
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let mut count = 0;
|
||||
let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) };
|
||||
if argv.is_null() || count < 0 {
|
||||
if !argv.is_null() {
|
||||
unsafe { LocalFree(argv.cast()) };
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let result = unsafe { std::slice::from_raw_parts(argv, count as usize) }
|
||||
.iter()
|
||||
.map(|argument| {
|
||||
if argument.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut length = 0;
|
||||
unsafe {
|
||||
while *argument.add(length) != 0 {
|
||||
length += 1;
|
||||
}
|
||||
String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok()
|
||||
}
|
||||
})
|
||||
.collect::<Option<Vec<_>>>();
|
||||
unsafe { LocalFree(argv.cast()) };
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool {
|
||||
const MARKER: &str = "--user-data-dir=";
|
||||
let Some(arguments) = windows_command_line_arguments(command_line) else {
|
||||
return false;
|
||||
};
|
||||
arguments.iter().enumerate().any(|(index, argument)| {
|
||||
let value = argument.strip_prefix(MARKER).or_else(|| {
|
||||
(argument == "--user-data-dir")
|
||||
.then(|| arguments.get(index + 1).map(String::as_str))
|
||||
.flatten()
|
||||
});
|
||||
value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
// SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
let mut buffer = [0u16; 1024];
|
||||
let mut length = buffer.len() as u32;
|
||||
// SAFETY: buffer 可写,length 先传入容量、返回实际长度。
|
||||
let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
|
||||
// SAFETY: handle 是本函数持有的合法句柄。
|
||||
unsafe { CloseHandle(handle) };
|
||||
if okay == 0 || length == 0 {
|
||||
return false;
|
||||
}
|
||||
let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize]));
|
||||
same_executable_path(&actual, expected)
|
||||
&& windows_process_command_line(pid)
|
||||
.is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -181,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> {
|
||||
pub(super) fn kill_browser_process_tree(
|
||||
root_pid: u32,
|
||||
expected_identity: &str,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判
|
||||
@@ -190,15 +307,17 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
|
||||
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) {
|
||||
if root_present {
|
||||
if !process_identity_matches(root_pid, expected_identity) {
|
||||
return Err("browser-sweep-root-identity-mismatch".into());
|
||||
}
|
||||
// root 仍是目标浏览器:发现并追踪当前整棵子树。
|
||||
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
|
||||
track_process(&mut tracked, pid);
|
||||
}
|
||||
} else if !root_present {
|
||||
} else {
|
||||
// root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父
|
||||
// PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认
|
||||
// 复用者的子进程。
|
||||
// PID 上的孤儿子进程。PID 已被复用时不会进入此分支。
|
||||
for (pid, ppid) in &snapshot {
|
||||
if *ppid == root_pid {
|
||||
track_process(&mut tracked, *pid);
|
||||
@@ -323,6 +442,62 @@ fn windows_process_tree_pids_from(
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn ensure_browser_tree_in_job(
|
||||
root_pid: u32,
|
||||
root_identity: &str,
|
||||
job: &WindowsProcessJob,
|
||||
) -> Result<(), String> {
|
||||
for _ in 0..TREE_KILL_MAX_PASSES {
|
||||
let snapshot = windows_process_snapshot()?;
|
||||
if !snapshot.iter().any(|(pid, _)| *pid == root_pid)
|
||||
|| !process_identity_matches(root_pid, root_identity)
|
||||
{
|
||||
return Err("browser-job-root-identity-unconfirmed".into());
|
||||
}
|
||||
let tree = windows_process_tree_pids_from(&snapshot, root_pid)?;
|
||||
if tree.is_empty() {
|
||||
return Err("browser-job-root-exited-before-coverage".into());
|
||||
}
|
||||
let mut members = Vec::with_capacity(tree.len());
|
||||
for pid in tree {
|
||||
let identity = if pid == root_pid {
|
||||
root_identity.to_string()
|
||||
} else {
|
||||
crate::process_identity::external_agent_runner_process_start_identity(pid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())?
|
||||
};
|
||||
members.push((pid, identity));
|
||||
}
|
||||
for (pid, identity) in members {
|
||||
if !process_identity_matches(pid, &identity) {
|
||||
return Err("browser-job-process-identity-changed".into());
|
||||
}
|
||||
if !job.contains_pid(pid)? {
|
||||
job.assign_pid(pid)?;
|
||||
}
|
||||
if !process_identity_matches(pid, &identity) {
|
||||
return Err("browser-job-process-identity-changed".into());
|
||||
}
|
||||
}
|
||||
|
||||
let verified = windows_process_snapshot()?;
|
||||
let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?;
|
||||
if process_identity_matches(root_pid, root_identity)
|
||||
&& !verified_tree.is_empty()
|
||||
&& verified_tree
|
||||
.iter()
|
||||
.all(|pid| job.contains_pid(*pid).unwrap_or(false))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
|
||||
}
|
||||
Err("browser-job-tree-coverage-unconfirmed".into())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_terminate_process(pid: u32) {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
@@ -468,6 +643,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_write_failure_leaves_no_partial_ownership_record() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let missing_root = root.path().join("missing");
|
||||
assert!(!write_browser_process_owner(
|
||||
&missing_root,
|
||||
std::process::id(),
|
||||
&std::env::current_exe().unwrap(),
|
||||
));
|
||||
assert!(!missing_root.join(OWNER_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -548,6 +735,90 @@ mod tests {
|
||||
assert!(trusted_browser_executable(&installed));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_browser_cleanup_requires_the_exact_profile_argument() {
|
||||
let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#);
|
||||
assert!(command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(command_line_contains_profile(
|
||||
r#""msedge.exe" "--user-data-dir=C:\Users\tester\App Data\ga-browser-1\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(!command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(!command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#,
|
||||
profile
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn browser_job_adopts_children_created_before_assignment() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("cmd.exe")
|
||||
.args([
|
||||
"/c",
|
||||
"ping",
|
||||
"127.0.0.1",
|
||||
"-n",
|
||||
"60",
|
||||
"&",
|
||||
"ping",
|
||||
"127.0.0.1",
|
||||
"-n",
|
||||
"60",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn fixture");
|
||||
let pid = child.id();
|
||||
let identity = crate::process_identity::external_agent_runner_process_start_identity(pid)
|
||||
.expect("fixture identity")
|
||||
.expect("fixture identity present");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job");
|
||||
ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree");
|
||||
let snapshot = windows_process_snapshot().expect("snapshot after adoption");
|
||||
let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption");
|
||||
assert!(tree
|
||||
.iter()
|
||||
.all(|member| job.contains_pid(*member).expect("job membership")));
|
||||
job.terminate().expect("terminate fixture job");
|
||||
let _ = child.wait();
|
||||
assert!(job.is_empty().expect("fixture job empty"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn kill_browser_process_tree_rejects_root_identity_mismatch() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("cmd.exe")
|
||||
.args(["/c", "ping", "127.0.0.1", "-n", "60"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn fixture");
|
||||
let pid = child.id();
|
||||
let result = kill_browser_process_tree(pid, "not-the-fixture-identity");
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert_eq!(
|
||||
result.expect_err("identity mismatch must fail closed"),
|
||||
"browser-sweep-root-identity-mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn kill_browser_process_tree_reaps_fixture_tree() {
|
||||
@@ -600,10 +871,8 @@ mod tests {
|
||||
fn live_process_executable_matches_current_process_image() {
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let profile = Path::new("C:\\fixture\\ga-browser-x\\profile");
|
||||
// Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程
|
||||
// 不具备该标记,正例只在 Windows 断言。
|
||||
#[cfg(windows)]
|
||||
assert!(live_process_executable_matches(
|
||||
// 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。
|
||||
assert!(!live_process_executable_matches(
|
||||
std::process::id(),
|
||||
&exe,
|
||||
profile
|
||||
|
||||
@@ -311,6 +311,13 @@ async fn host_npm(
|
||||
fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
if error.contains("未发现可用的") {
|
||||
"web-preflight-browser-missing"
|
||||
} else if error.starts_with("browser-recovery-") {
|
||||
"web-preflight-browser-recovery-failed"
|
||||
} else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid")
|
||||
{
|
||||
"web-preflight-browser-environment-failed"
|
||||
} else if error.starts_with("browser-launch-failed:") {
|
||||
"web-preflight-browser-launch-failed"
|
||||
} else if error.contains("启动浏览器超时") {
|
||||
"web-preflight-browser-launch-timeout"
|
||||
} else if error.contains("启动浏览器失败") {
|
||||
@@ -342,6 +349,13 @@ fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_validation_diagnostic(error: &str) -> Option<&str> {
|
||||
(error.starts_with("browser-recovery-")
|
||||
|| error.starts_with("browser-launch-failed:")
|
||||
|| error.starts_with("browser-cleanup-unconfirmed:"))
|
||||
.then_some(error)
|
||||
}
|
||||
|
||||
pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let started = Instant::now();
|
||||
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
|
||||
@@ -362,18 +376,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
|
||||
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
|
||||
}).await;
|
||||
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
|
||||
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
|
||||
let result = validation
|
||||
.map_err(|error| {
|
||||
let code = browser_validation_failure_code(&error);
|
||||
browser_validation_diagnostic(&error)
|
||||
.map(|diagnostic| format!("{code};diagnostic={diagnostic}"))
|
||||
.unwrap_or_else(|| code.to_string())
|
||||
})?;
|
||||
if !result.passed || result.viewport_results.len() != 2 {
|
||||
return Err("web-preflight-page-check-failed".into());
|
||||
}
|
||||
let mut pngs = Vec::new();
|
||||
for viewport in result.viewport_results {
|
||||
let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); }
|
||||
image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?;
|
||||
pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()}));
|
||||
let bytes = fs::read(&viewport.screenshot_path)
|
||||
.map_err(|_| "web-preflight-screenshot-missing")?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 {
|
||||
return Err("web-preflight-screenshot-invalid".into());
|
||||
}
|
||||
image::load_from_memory(&bytes)
|
||||
.map_err(|_| "web-preflight-screenshot-invalid")?;
|
||||
pngs.push(json!({
|
||||
"viewport": viewport.viewport,
|
||||
"passed": viewport.passed,
|
||||
"pngBytes": bytes.len(),
|
||||
}));
|
||||
}
|
||||
Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}}))
|
||||
}.await;
|
||||
let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code}));
|
||||
Ok::<_, String>(json!({
|
||||
"schemaVersion": "agc-web-creation-preflight.v1",
|
||||
"status": "ready",
|
||||
"runtime": {
|
||||
"source": runtime.source,
|
||||
"nodeVersion": node,
|
||||
"npmVersion": npm,
|
||||
},
|
||||
"build": {"status": "ready", "kind": "npm-node-fixture"},
|
||||
"browser": {"status": "ready", "viewports": pngs},
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
let mut result = run.unwrap_or_else(|error| {
|
||||
let (code, diagnostic) = error
|
||||
.split_once(";diagnostic=")
|
||||
.map_or((error.as_str(), None), |(code, diagnostic)| {
|
||||
(code, Some(diagnostic))
|
||||
});
|
||||
let mut blocked = json!({
|
||||
"schemaVersion": "agc-web-creation-preflight.v1",
|
||||
"status": "blocked",
|
||||
"code": code,
|
||||
});
|
||||
if let Some(diagnostic) = diagnostic {
|
||||
blocked["diagnostic"] = json!(diagnostic);
|
||||
}
|
||||
blocked
|
||||
});
|
||||
result["elapsedMs"] = json!(started.elapsed().as_millis());
|
||||
result
|
||||
}
|
||||
@@ -382,9 +438,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
pub(crate) async fn preflight_web_game_creation() -> Result<Value, String> {
|
||||
let result = host_web_creation_preflight().await;
|
||||
if result["status"] != "ready" {
|
||||
let diagnostic = result["diagnostic"]
|
||||
.as_str()
|
||||
.map(|value| format!(";诊断:{value}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Web 游戏环境预检未通过:{};尚未启动生成",
|
||||
result["code"].as_str().unwrap_or("web-preflight-failed")
|
||||
"Web 游戏环境预检未通过:{}{};尚未启动生成",
|
||||
result["code"].as_str().unwrap_or("web-preflight-failed"),
|
||||
diagnostic,
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
@@ -548,16 +609,40 @@ mod tests {
|
||||
browser_validation_failure_code("启动浏览器失败:2"),
|
||||
"web-preflight-browser-launch-failed"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-launch-failed: stage=setup cause=browser-temp-unavailable"
|
||||
),
|
||||
"web-preflight-browser-environment-failed"
|
||||
);
|
||||
|
||||
let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true";
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(recovery_diagnostic),
|
||||
"web-preflight-browser-recovery-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic(recovery_diagnostic),
|
||||
Some(recovery_diagnostic)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic("启动浏览器失败:原始错误"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
|
||||
"web-preflight-cancelled"
|
||||
);
|
||||
let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false";
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
|
||||
),
|
||||
browser_validation_failure_code(cleanup_diagnostic),
|
||||
"web-preflight-browser-cleanup-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic(cleanup_diagnostic),
|
||||
Some(cleanup_diagnostic)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
|
||||
"web-preflight-browser-environment-failed"
|
||||
|
||||
@@ -236,7 +236,42 @@ impl WindowsProcessJob {
|
||||
.ok_or_else(|| "子进程缺少 Windows process handle".to_string())?;
|
||||
Self::assign_handle(handle as windows_sys::Win32::Foundation::HANDLE)
|
||||
}
|
||||
pub(crate) fn contains_pid(&self, pid: u32) -> Result<bool, String> {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if process.is_null() {
|
||||
return Err("无法打开进程核对 Windows Job 归属".into());
|
||||
}
|
||||
let mut in_job = 0;
|
||||
let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) };
|
||||
unsafe { CloseHandle(process) };
|
||||
if okay == 0 {
|
||||
return Err("无法核对进程 Windows Job 归属".into());
|
||||
}
|
||||
Ok(in_job != 0)
|
||||
}
|
||||
|
||||
pub(crate) fn assign_pid(&self, pid: u32) -> Result<(), String> {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
|
||||
};
|
||||
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if process.is_null() {
|
||||
return Err("无法打开进程加入 Windows Job".into());
|
||||
}
|
||||
let okay = unsafe { AssignProcessToJobObject(self.0, process) };
|
||||
unsafe { CloseHandle(process) };
|
||||
if okay == 0 {
|
||||
return Err("无法将进程加入 Windows Job".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn assign_tokio_named(
|
||||
child: &tokio::process::Child,
|
||||
name: &str,
|
||||
|
||||
Reference in New Issue
Block a user