diff --git a/.gitignore b/.gitignore index fc7eca3e..219336a2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ engines/colmap/** engines/brush/** !engines/brush/README.md engines/linux/brush/** +!engines/linux/brush/README.md engines/macos/arm64/** +!engines/macos/arm64/README.md diff --git a/engines/linux/brush/README.md b/engines/linux/brush/README.md new file mode 100644 index 00000000..4aa27eea --- /dev/null +++ b/engines/linux/brush/README.md @@ -0,0 +1,7 @@ +Brush v0.3.0 Ubuntu x86_64 runtime. `npm run setup:engines:linux` downloads and +verifies `brush_app` into this directory; the binary itself is never committed. + +This README is tracked so the directory exists in a fresh clone. +`src-tauri/tauri.linux.conf.json` declares the directory as a bundle resource, +and the Tauri build script aborts when a declared resource path is missing -- +which would otherwise make `cargo test` fail before compiling any test. diff --git a/engines/macos/arm64/README.md b/engines/macos/arm64/README.md new file mode 100644 index 00000000..88ade42f --- /dev/null +++ b/engines/macos/arm64/README.md @@ -0,0 +1,8 @@ +Apple Silicon macOS runtime. `npm run setup:engines:macos` downloads and verifies +the arm64 FFmpeg/FFprobe/COLMAP/Brush closure into `bin/` and `lib/`; the +binaries themselves are never committed. + +This README is tracked so the directory exists in a fresh clone. +`src-tauri/tauri.macos.conf.json` declares the directory as a bundle resource, +and the Tauri build script aborts when a declared resource path is missing -- +which would otherwise make `cargo test` fail before compiling any test. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3f0fcf80..7ab4532e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -461,9 +461,9 @@ pub async fn begin_gaussian_video_export( let (root, _, _) = catalog::registered_final_ply_for_project(project_id).await?; let active = state.active.lock().await; - if !active + if active .as_ref() - .is_some_and(|session| session.project_id == project_id) + .is_none_or(|session| session.project_id != project_id) { return Err(SplatError::Process( "该项目当前未在 OOOSplat 预览中打开,无法导出视频。".into(), diff --git a/src-tauri/src/engines/health.rs b/src-tauri/src/engines/health.rs index d5506c3e..0e8d4848 100644 --- a/src-tauri/src/engines/health.rs +++ b/src-tauri/src/engines/health.rs @@ -834,7 +834,15 @@ mod tests { let paths = EnginePaths::from_root("/opt/ooosplat-engines"); assert_eq!(paths.colmap, PathBuf::from("/opt/ooosplat-engines/colmap")); let discovered = EnginePaths::from_candidates(PathBuf::from("/missing/engines")); - assert!(discovered.ffmpeg.is_file()); + // FFmpeg is not installed on every contributor machine or CI runner, so assert + // the resolver contract instead of requiring the binary: an explicit override + // wins, then PATH, and otherwise the managed path is kept so engine health can + // report the exact file it expected. + let expected = std::env::var_os("OOOSPLAT_FFMPEG") + .map(PathBuf::from) + .or_else(|| find_on_path("ffmpeg")) + .unwrap_or_else(|| PathBuf::from("/missing/engines/ffmpeg")); + assert_eq!(discovered.ffmpeg, expected); } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/process/mod.rs b/src-tauri/src/process/mod.rs index 98cf03a4..3aeb51c8 100644 --- a/src-tauri/src/process/mod.rs +++ b/src-tauri/src/process/mod.rs @@ -475,7 +475,44 @@ mod tests { let pid = descendant_pid.lock().unwrap().expect("descendant PID"); manager.cancel(); assert!(matches!(run.await.unwrap(), Err(SplatError::Cancelled))); - tokio::time::sleep(Duration::from_millis(50)).await; - assert!(!PathBuf::from(format!("/proc/{pid}")).exists()); + + let mut terminated = false; + for _ in 0..150 { + if descendant_is_terminated(pid) { + terminated = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + terminated, + "descendant {pid} was still running after cancellation" + ); + } + + /// A killed descendant is reparented to PID 1, and PID 1 does not reap in every + /// container, so the process can linger as a zombie with its `/proc` entry intact. + /// The entry being gone and the entry being a zombie both mean the process stopped + /// running, which is what cancellation has to guarantee. + #[cfg(target_os = "linux")] + fn descendant_is_terminated(pid: u32) -> bool { + let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else { + return true; + }; + status + .lines() + .find_map(|line| line.strip_prefix("State:")) + .map(|state| state.trim_start().starts_with('Z')) + .unwrap_or(true) + } + + /// Unix targets without `/proc` reap orphans through PID 1, so a terminated + /// descendant disappears outright rather than lingering as a visible zombie. + #[cfg(all(unix, not(target_os = "linux")))] + fn descendant_is_terminated(pid: u32) -> bool { + // SAFETY: signal 0 only runs the existence and permission checks for the PID + // and delivers nothing, so no process state is observed or modified. + let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0; + !alive } }