Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "nomifun-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[package.metadata.nomifun-runtime]
|
||||
# 1.3.x required: 1.1.38 has a stdin-buffering bug in `bun x --bun` that
|
||||
# prevents Nomi's ACP adapter (long-lived stdio protocol) from ever
|
||||
# receiving the initialize request — see nomicore fix notes for
|
||||
# Nomifun#2828. If bumping, keep >= 1.3.13.
|
||||
bun_version = "1.3.13"
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
dirs.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
which.workspace = true
|
||||
zstd.workspace = true
|
||||
fs2.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { workspace = true, features = ["process", "io-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
zstd.workspace = true
|
||||
zip = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
toml = { workspace = true }
|
||||
dirs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
zstd.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc.workspace = true
|
||||
wait-timeout = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Job Object–based child-tree cleanup (see src/job.rs). Win32_Security gates
|
||||
# CreateJobObjectW (its SECURITY_ATTRIBUTES param); Win32_System_Threading
|
||||
# gates JOBOBJECT_EXTENDED_LIMIT_INFORMATION (its IO_COUNTERS field). Do not
|
||||
# trim these — they currently also arrive via tokio's feature unification,
|
||||
# which would mask the breakage until tokio bumps its windows-sys major.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Build-time bundling of the bun runtime.
|
||||
//!
|
||||
//! - Reads `BUN_VARIANT` ("default" | "baseline"; default = "default") env
|
||||
//! and `[package.metadata.nomifun-runtime] bun_version` from Cargo.toml.
|
||||
//! - For win-arm64: emits HAS_EMBEDDED_BUN=false and exits early.
|
||||
//! - Otherwise: downloads `bun-<platform>-<arch>[-baseline].zip`,
|
||||
//! extracts the `bun` binary, zstd-compresses it, emits constants
|
||||
//! into `$OUT_DIR/bun_meta.rs`.
|
||||
//! - Caches decompressed+compressed artifacts in `$CARGO_HOME/nomifun-bun-cache/`.
|
||||
|
||||
#[path = "build_support.rs"]
|
||||
mod build_support;
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=BUN_VARIANT");
|
||||
println!("cargo:rerun-if-env-changed=BUN_VERSION_OVERRIDE");
|
||||
println!("cargo:rerun-if-env-changed=NOMIFUN_EMBED_BUN");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=build_support.rs");
|
||||
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||
|
||||
let target = std::env::var("TARGET").expect("TARGET env set by cargo");
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
|
||||
let variant = std::env::var("BUN_VARIANT").unwrap_or_else(|_| "default".into());
|
||||
let version = read_bun_version();
|
||||
|
||||
let meta_path = out_dir.join("bun_meta.rs");
|
||||
|
||||
// Default: dev builds skip bun download (no network). CI release
|
||||
// sets NOMIFUN_EMBED_BUN=1 to actually embed.
|
||||
if std::env::var("NOMIFUN_EMBED_BUN").as_deref() != Ok("1") {
|
||||
write_meta_stub(&meta_path, &out_dir);
|
||||
return;
|
||||
}
|
||||
|
||||
if !build_support::has_embedded_bun(&target) {
|
||||
write_meta_stub(&meta_path, &out_dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(asset) = build_support::asset_name_for(&target, &variant) else {
|
||||
println!("cargo:warning=No bun asset mapped for target {target}; emitting stub");
|
||||
write_meta_stub(&meta_path, &out_dir);
|
||||
return;
|
||||
};
|
||||
|
||||
let cache_root = cargo_cache_root().join(&version).join(format!("{target}-{variant}"));
|
||||
fs::create_dir_all(&cache_root).expect("create cache dir");
|
||||
|
||||
let bun_exe_path = cache_root.join(build_support::bun_exe_name(&target));
|
||||
let compressed_path = cache_root.join("bun.blob.zst");
|
||||
let sha_path = cache_root.join("bun.sha256");
|
||||
|
||||
// Step 1: ensure decompressed bun is cached.
|
||||
if !bun_exe_path.is_file() {
|
||||
let url = build_support::download_url(&version, &asset);
|
||||
println!("cargo:info=Downloading {url}");
|
||||
let zip_path = cache_root.join(&asset);
|
||||
download(&url, &zip_path);
|
||||
unzip_bun(&zip_path, &cache_root, build_support::bun_exe_name(&target));
|
||||
}
|
||||
|
||||
// Step 2: compute sha256 if missing.
|
||||
let sha_hex = if sha_path.is_file() {
|
||||
fs::read_to_string(&sha_path).expect("read sha").trim().to_string()
|
||||
} else {
|
||||
let hex = sha256_of(&bun_exe_path);
|
||||
fs::write(&sha_path, &hex).expect("write sha");
|
||||
hex
|
||||
};
|
||||
|
||||
// Step 3: compress if missing.
|
||||
if !compressed_path.is_file() {
|
||||
zstd_compress_file(&bun_exe_path, &compressed_path);
|
||||
}
|
||||
|
||||
// Step 4: copy compressed blob into OUT_DIR so include_bytes! can find it.
|
||||
let out_blob = out_dir.join("bun.blob.zst");
|
||||
fs::copy(&compressed_path, &out_blob).expect("copy blob to OUT_DIR");
|
||||
|
||||
// Step 5: emit bun_meta.rs.
|
||||
let meta = format!(
|
||||
"// @generated by build.rs — do not edit\n\
|
||||
pub const BUN_BLOB: &[u8] = include_bytes!(\"bun.blob.zst\");\n\
|
||||
pub const BUN_SHA256: &str = \"{sha_hex}\";\n\
|
||||
pub const BUN_VERSION: &str = \"{version}\";\n\
|
||||
pub const HAS_EMBEDDED_BUN: bool = true;\n"
|
||||
);
|
||||
fs::write(&meta_path, meta).expect("write bun_meta.rs");
|
||||
}
|
||||
|
||||
fn read_bun_version() -> String {
|
||||
if let Ok(v) = std::env::var("BUN_VERSION_OVERRIDE")
|
||||
&& !v.trim().is_empty()
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let toml_src = fs::read_to_string("Cargo.toml").expect("read Cargo.toml");
|
||||
let parsed: toml::Value = toml::from_str(&toml_src).expect("parse Cargo.toml");
|
||||
parsed
|
||||
.get("package")
|
||||
.and_then(|p| p.get("metadata"))
|
||||
.and_then(|m| m.get("nomifun-runtime"))
|
||||
.and_then(|r| r.get("bun_version"))
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("[package.metadata.nomifun-runtime] bun_version missing")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn cargo_cache_root() -> PathBuf {
|
||||
let home = std::env::var("CARGO_HOME").map(PathBuf::from).unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".cargo"))
|
||||
.unwrap_or_else(|| PathBuf::from(".cargo"))
|
||||
});
|
||||
home.join("nomifun-bun-cache")
|
||||
}
|
||||
|
||||
fn write_meta_stub(meta_path: &Path, out_dir: &Path) {
|
||||
let stub_blob = out_dir.join("bun.blob.zst");
|
||||
if !stub_blob.is_file() {
|
||||
fs::write(&stub_blob, []).expect("write stub blob");
|
||||
}
|
||||
let body = "// @generated by build.rs — do not edit\n\
|
||||
pub const BUN_BLOB: &[u8] = include_bytes!(\"bun.blob.zst\");\n\
|
||||
pub const BUN_SHA256: &str = \"\";\n\
|
||||
pub const BUN_VERSION: &str = \"\";\n\
|
||||
pub const HAS_EMBEDDED_BUN: bool = false;\n";
|
||||
fs::write(meta_path, body).expect("write stub bun_meta.rs");
|
||||
}
|
||||
|
||||
fn download(url: &str, out: &Path) {
|
||||
let bytes = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(600))
|
||||
.build()
|
||||
.expect("build http client")
|
||||
.get(url)
|
||||
.send()
|
||||
.unwrap_or_else(|e| panic!("GET {url} failed: {e}"))
|
||||
.error_for_status()
|
||||
.unwrap_or_else(|e| panic!("non-2xx from {url}: {e}"))
|
||||
.bytes()
|
||||
.unwrap_or_else(|e| panic!("read body: {e}"));
|
||||
fs::write(out, &bytes).unwrap_or_else(|e| panic!("write {}: {e}", out.display()));
|
||||
}
|
||||
|
||||
fn unzip_bun(zip_path: &Path, out_dir: &Path, exe_name: &str) {
|
||||
let f = fs::File::open(zip_path).expect("open zip");
|
||||
let mut archive = zip::ZipArchive::new(f).expect("read zip");
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i).expect("zip entry");
|
||||
let name = entry.name().to_string();
|
||||
if name.ends_with(exe_name) && !entry.is_dir() {
|
||||
let out = out_dir.join(exe_name);
|
||||
let mut w = fs::File::create(&out).expect("create exe");
|
||||
std::io::copy(&mut entry, &mut w).expect("extract exe");
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("bun executable {exe_name} not found in zip {}", zip_path.display());
|
||||
}
|
||||
|
||||
fn sha256_of(path: &Path) -> String {
|
||||
let mut f = fs::File::open(path).expect("open for sha");
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = f.read(&mut buf).expect("read chunk");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn zstd_compress_file(input: &Path, output: &Path) {
|
||||
let mut src = fs::File::open(input).expect("open src");
|
||||
let dst = fs::File::create(output).expect("create dst");
|
||||
let mut enc = zstd::stream::write::Encoder::new(dst, 19).expect("zstd encoder");
|
||||
std::io::copy(&mut src, &mut enc).expect("compress");
|
||||
let mut out = enc.finish().expect("finish encoder");
|
||||
out.write_all(&[]).ok();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Pure helpers for `build.rs`. Kept separate so they can be unit-tested
|
||||
//! without running the build script itself.
|
||||
//!
|
||||
//! Exposed to `build.rs` via `#[path = "build_support.rs"] mod build_support;`.
|
||||
|
||||
/// Whether this target has an embedded bun asset.
|
||||
pub fn has_embedded_bun(target: &str) -> bool {
|
||||
target != "aarch64-pc-windows-msvc"
|
||||
}
|
||||
|
||||
/// Map a Rust target triple + variant to the bun release asset filename.
|
||||
/// Returns None for targets without a bun prebuild.
|
||||
pub fn asset_name_for(target: &str, variant: &str) -> Option<String> {
|
||||
let (platform, arch) = match target {
|
||||
"x86_64-apple-darwin" => ("darwin", "x64"),
|
||||
"aarch64-apple-darwin" => ("darwin", "aarch64"),
|
||||
"x86_64-unknown-linux-gnu" | "x86_64-unknown-linux-musl" => ("linux", "x64"),
|
||||
"aarch64-unknown-linux-gnu" | "aarch64-unknown-linux-musl" => ("linux", "aarch64"),
|
||||
"x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => ("windows", "x64"),
|
||||
_ => return None,
|
||||
};
|
||||
let suffix = if variant == "baseline" { "-baseline" } else { "" };
|
||||
Some(format!("bun-{platform}-{arch}{suffix}.zip"))
|
||||
}
|
||||
|
||||
/// Compose the GitHub download URL for a given bun version + asset.
|
||||
pub fn download_url(version: &str, asset: &str) -> String {
|
||||
format!("https://github.com/oven-sh/bun/releases/download/bun-v{version}/{asset}")
|
||||
}
|
||||
|
||||
/// Expected filename of the bun executable extracted from the zip.
|
||||
pub fn bun_exe_name(target: &str) -> &'static str {
|
||||
if target.contains("windows") { "bun.exe" } else { "bun" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn has_embedded_bun_false_only_for_win_arm64() {
|
||||
assert!(has_embedded_bun("x86_64-apple-darwin"));
|
||||
assert!(has_embedded_bun("aarch64-apple-darwin"));
|
||||
assert!(has_embedded_bun("x86_64-unknown-linux-gnu"));
|
||||
assert!(has_embedded_bun("aarch64-unknown-linux-gnu"));
|
||||
assert!(has_embedded_bun("x86_64-pc-windows-msvc"));
|
||||
assert!(!has_embedded_bun("aarch64-pc-windows-msvc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_name_default_variants() {
|
||||
assert_eq!(
|
||||
asset_name_for("x86_64-apple-darwin", "default").as_deref(),
|
||||
Some("bun-darwin-x64.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
asset_name_for("aarch64-apple-darwin", "default").as_deref(),
|
||||
Some("bun-darwin-aarch64.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
asset_name_for("x86_64-unknown-linux-gnu", "default").as_deref(),
|
||||
Some("bun-linux-x64.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
asset_name_for("aarch64-unknown-linux-gnu", "default").as_deref(),
|
||||
Some("bun-linux-aarch64.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
asset_name_for("x86_64-pc-windows-msvc", "default").as_deref(),
|
||||
Some("bun-windows-x64.zip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_name_baseline_variant() {
|
||||
assert_eq!(
|
||||
asset_name_for("x86_64-unknown-linux-gnu", "baseline").as_deref(),
|
||||
Some("bun-linux-x64-baseline.zip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_name_none_for_win_arm64() {
|
||||
assert!(asset_name_for("aarch64-pc-windows-msvc", "default").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_url_format() {
|
||||
assert_eq!(
|
||||
download_url("1.1.38", "bun-darwin-x64.zip"),
|
||||
"https://github.com/oven-sh/bun/releases/download/bun-v1.1.38/bun-darwin-x64.zip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bun_exe_name_platform_specific() {
|
||||
assert_eq!(bun_exe_name("x86_64-apple-darwin"), "bun");
|
||||
assert_eq!(bun_exe_name("x86_64-unknown-linux-gnu"), "bun");
|
||||
assert_eq!(bun_exe_name("x86_64-pc-windows-msvc"), "bun.exe");
|
||||
assert_eq!(bun_exe_name("aarch64-pc-windows-msvc"), "bun.exe");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! 手动校验 macOS 父死安全网(fork-based watchdog,2026-06-19 重写)。
|
||||
//!
|
||||
//! 经真实 `Builder::spawn` 起一个长命子进程(`process_group(0)` 自成进程组),打印其 pid,然后父进程
|
||||
//! 挂起。外部脚本 `kill -9` 本进程(父),随后检查子进程是否被 **fork 出来的独立 watchdog 进程** 收掉
|
||||
//! (`kill -0 <child>` → ESRCH = 已收)。这是 PLATFORM-VERIFICATION.md Task 4 ③「kill -9 父 → 子被回收」
|
||||
//! 的可执行复现器(进程内单测杀不了测试自身,故用 example + 脚本编排)。
|
||||
use std::io::Write;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut b = nomifun_runtime::Builder::new("sleep");
|
||||
b.arg("600");
|
||||
let child = b.spawn().expect("spawn child");
|
||||
println!("CHILD_PID={}", child.id().expect("child pid"));
|
||||
std::io::stdout().flush().ok();
|
||||
// 保持父进程存活;外部 kill -9 本进程 → watchdog(独立进程,存活于父 SIGKILL)应收掉子。
|
||||
std::thread::sleep(std::time::Duration::from_secs(600));
|
||||
drop(child);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Cross-platform cache directory resolution for the bundled bun runtime.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Override for [`runtime_root`], set by [`init`] from the backend
|
||||
/// startup path so cached bun binaries land under `AppConfig.data_dir`
|
||||
/// instead of the OS-default cache location.
|
||||
///
|
||||
/// Lifecycle: written once by `nomifun-app`'s `main()` before
|
||||
/// [`crate::enhance_process_path`] / [`crate::resolve_bun`] run, read
|
||||
/// every time [`runtime_root`] is queried thereafter. Callers that miss
|
||||
/// the init window (e.g. the `mcp-*` subcommands, unit tests,
|
||||
/// `build.rs`) transparently fall back to `dirs::cache_dir()`.
|
||||
static RUNTIME_ROOT_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Anchor the runtime root to a caller-supplied data directory — typically
|
||||
/// the backend's `AppConfig.data_dir`. Idempotent on repeat calls (only
|
||||
/// the first value wins); a warning is logged if a second path is
|
||||
/// attempted so unexpected double-inits are visible.
|
||||
pub fn init(data_dir: impl AsRef<Path>) {
|
||||
let path = data_dir.as_ref().join("runtime");
|
||||
if let Err(existing) = RUNTIME_ROOT_OVERRIDE.set(path.clone())
|
||||
&& existing != path
|
||||
{
|
||||
tracing::warn!(
|
||||
attempted = %path.display(),
|
||||
existing = %existing.display(),
|
||||
"nomifun_runtime::init called twice with different paths; keeping first"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the root cache directory used for all nomifun runtime artifacts.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Path supplied via [`init`] (`{data_dir}/runtime`) when the backend
|
||||
/// started with `--data-dir`.
|
||||
/// 2. Platform cache dir (via `dirs::cache_dir()`):
|
||||
/// - macOS: `~/Library/Caches/nomifun/runtime`
|
||||
/// - Linux: `$XDG_CACHE_HOME/nomifun/runtime` (fallback `~/.cache/nomifun/runtime`)
|
||||
/// - Windows: `%LOCALAPPDATA%\nomifun\runtime`
|
||||
///
|
||||
/// Returns `None` only when neither [`init`] has run nor a platform cache
|
||||
/// dir is determinable (exotic envs).
|
||||
pub fn runtime_root() -> Option<PathBuf> {
|
||||
if let Some(p) = RUNTIME_ROOT_OVERRIDE.get() {
|
||||
return Some(p.clone());
|
||||
}
|
||||
dirs::cache_dir().map(|d| d.join("nomifun").join("runtime"))
|
||||
}
|
||||
|
||||
/// Per-version cache directory name: `bun-<version>-<sha12>`.
|
||||
///
|
||||
/// `sha12` is the first 12 hex chars of the bun binary sha256 — embedding
|
||||
/// it means version bumps and content-level bumps both produce a new dir
|
||||
/// so stale bytes never shadow a new build.
|
||||
pub fn bun_dir_name(version: &str, sha256: &str) -> String {
|
||||
let sha12 = &sha256[..12.min(sha256.len())];
|
||||
format!("bun-{version}-{sha12}")
|
||||
}
|
||||
|
||||
/// Full path for a specific (version, sha) cache directory.
|
||||
pub fn bun_dir(version: &str, sha256: &str) -> Option<PathBuf> {
|
||||
runtime_root().map(|root| root.join(bun_dir_name(version, sha256)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bun_dir_name_format() {
|
||||
assert_eq!(bun_dir_name("1.1.38", "abc1234567890def"), "bun-1.1.38-abc123456789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bun_dir_name_short_sha_does_not_panic() {
|
||||
// Defensive: if upstream ever passes <12 chars, don't panic.
|
||||
assert_eq!(bun_dir_name("1.0", "abc"), "bun-1.0-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_root_ends_with_expected_suffix() {
|
||||
let root = runtime_root().expect("cache dir available in test env");
|
||||
let tail: Vec<_> = root
|
||||
.components()
|
||||
.rev()
|
||||
.take(2)
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(tail, vec!["runtime".to_string(), "nomifun".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bun_dir_embeds_version_and_sha() {
|
||||
let dir = bun_dir("1.1.38", "deadbeefcafebabe").expect("cache available");
|
||||
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert_eq!(name, "bun-1.1.38-deadbeefcafe");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Access to the bun bytes embedded at build time.
|
||||
//!
|
||||
//! Production reads constants emitted by `build.rs` into
|
||||
//! `$OUT_DIR/bun_meta.rs`. When `NOMIFUN_EMBED_BUN` is unset at build time,
|
||||
//! `build.rs` emits a stub with `HAS_EMBEDDED_BUN = false`, which makes
|
||||
//! `resolve_bun()` fall back to `which()` — exactly the pre-embed behavior.
|
||||
|
||||
/// Raw compile-time constants generated by `build.rs` into `$OUT_DIR/bun_meta.rs`.
|
||||
pub(crate) mod consts {
|
||||
include!(concat!(env!("OUT_DIR"), "/bun_meta.rs"));
|
||||
}
|
||||
|
||||
/// Indirection trait so tests can inject a fake embedded payload instead
|
||||
/// of linking the real (potentially 30MB+) compressed blob.
|
||||
pub trait EmbeddedBun: Send + Sync {
|
||||
fn has(&self) -> bool;
|
||||
fn blob(&self) -> &'static [u8];
|
||||
fn sha256(&self) -> &'static str;
|
||||
fn version(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Production implementation backed by constants from `bun_meta.rs`.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ProductionEmbed;
|
||||
|
||||
impl EmbeddedBun for ProductionEmbed {
|
||||
fn has(&self) -> bool {
|
||||
consts::HAS_EMBEDDED_BUN
|
||||
}
|
||||
fn blob(&self) -> &'static [u8] {
|
||||
consts::BUN_BLOB
|
||||
}
|
||||
fn sha256(&self) -> &'static str {
|
||||
consts::BUN_SHA256
|
||||
}
|
||||
fn version(&self) -> &'static str {
|
||||
consts::BUN_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct FakeEmbed {
|
||||
pub has: bool,
|
||||
pub blob: &'static [u8],
|
||||
pub sha256: &'static str,
|
||||
pub version: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl EmbeddedBun for FakeEmbed {
|
||||
fn has(&self) -> bool {
|
||||
self.has
|
||||
}
|
||||
fn blob(&self) -> &'static [u8] {
|
||||
self.blob
|
||||
}
|
||||
fn sha256(&self) -> &'static str {
|
||||
self.sha256
|
||||
}
|
||||
fn version(&self) -> &'static str {
|
||||
self.version
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production_embed_respects_env_gate() {
|
||||
// Unless NOMIFUN_EMBED_BUN=1 at build time, ProductionEmbed reports "no embed".
|
||||
assert!(!ProductionEmbed.has());
|
||||
assert_eq!(ProductionEmbed.blob(), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_embed_returns_injected_values() {
|
||||
let fake = FakeEmbed {
|
||||
has: true,
|
||||
blob: b"hello",
|
||||
sha256: "deadbeef",
|
||||
version: "9.9.9",
|
||||
};
|
||||
assert!(fake.has());
|
||||
assert_eq!(fake.blob(), b"hello");
|
||||
assert_eq!(fake.sha256(), "deadbeef");
|
||||
assert_eq!(fake.version(), "9.9.9");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Atomic extraction of the compressed embedded bun blob to the cache dir.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Acquire inter-process advisory file lock (so parallel starts don't race).
|
||||
//! 2. Re-check stamp: another process may have finished while we waited.
|
||||
//! 3. zstd-decode blob -> `<dir>/bun.tmp`.
|
||||
//! 4. Verify sha256 of `bun.tmp` == expected.
|
||||
//! 5. chmod 0o755 (Unix only).
|
||||
//! 6. Atomic rename `bun.tmp` -> `bun[.exe]`.
|
||||
//! 7. Create `bunx[.exe]` — symlink on Unix, copy on Windows.
|
||||
//! 8. Create `node[.exe]` — symlink on Unix, copy on Windows — so
|
||||
//! `#!/usr/bin/env node` shebangs in npm packages resolve to bun.
|
||||
//! 9. Write `bun.stamp` JSON.
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fs2::FileExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Stamp {
|
||||
pub sha256: String,
|
||||
pub version: String,
|
||||
pub extracted_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExtractError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("checksum mismatch: expected {expected}, got {actual}")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("serde_json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub fn bun_filename() -> &'static str {
|
||||
if cfg!(windows) { "bun.exe" } else { "bun" }
|
||||
}
|
||||
|
||||
pub fn bunx_filename() -> &'static str {
|
||||
if cfg!(windows) { "bunx.exe" } else { "bunx" }
|
||||
}
|
||||
|
||||
pub fn node_filename() -> &'static str {
|
||||
if cfg!(windows) { "node.exe" } else { "node" }
|
||||
}
|
||||
|
||||
/// Returns true when `<dir>/bun[.exe]` exists and `<dir>/bun.stamp`
|
||||
/// records the expected sha256 + version.
|
||||
pub fn is_fresh(dir: &Path, expected_sha: &str, expected_version: &str) -> bool {
|
||||
let bun = dir.join(bun_filename());
|
||||
if !bun.is_file() {
|
||||
return false;
|
||||
}
|
||||
let stamp_path = dir.join("bun.stamp");
|
||||
let Ok(bytes) = fs::read(&stamp_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(stamp): Result<Stamp, _> = serde_json::from_slice(&bytes) else {
|
||||
return false;
|
||||
};
|
||||
stamp.sha256 == expected_sha && stamp.version == expected_version
|
||||
}
|
||||
|
||||
/// Extract `blob` (zstd-compressed bun) into `dir`. Idempotent and
|
||||
/// cross-process safe via advisory file lock on `<dir>/../runtime.lock`.
|
||||
pub fn extract_into(dir: &Path, blob: &[u8], expected_sha: &str, version: &str) -> Result<PathBuf, ExtractError> {
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
// Lock file lives in the parent (runtime root) so it survives across
|
||||
// per-version dir churn.
|
||||
let lock_parent = dir.parent().unwrap_or(dir);
|
||||
fs::create_dir_all(lock_parent)?;
|
||||
let lock_path = lock_parent.join("runtime.lock");
|
||||
let lock_file = File::create(&lock_path)?;
|
||||
lock_file.lock_exclusive()?;
|
||||
|
||||
// Re-check after taking the lock: maybe another process finished.
|
||||
if is_fresh(dir, expected_sha, version) {
|
||||
let _ = FileExt::unlock(&lock_file);
|
||||
return Ok(dir.join(bun_filename()));
|
||||
}
|
||||
|
||||
let result = (|| -> Result<PathBuf, ExtractError> {
|
||||
let tmp_path = dir.join("bun.tmp");
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
|
||||
// Decompress zstd -> tmp file.
|
||||
{
|
||||
let mut out = File::create(&tmp_path)?;
|
||||
let reader = BufReader::new(std::io::Cursor::new(blob));
|
||||
let mut decoder = zstd::stream::read::Decoder::new(reader)?;
|
||||
std::io::copy(&mut decoder, &mut out)?;
|
||||
out.sync_all()?;
|
||||
}
|
||||
|
||||
// Verify sha256.
|
||||
let actual_sha = sha256_file(&tmp_path)?;
|
||||
if actual_sha != expected_sha {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
return Err(ExtractError::ChecksumMismatch {
|
||||
expected: expected_sha.into(),
|
||||
actual: actual_sha,
|
||||
});
|
||||
}
|
||||
|
||||
// chmod +x on Unix.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&tmp_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&tmp_path, perms)?;
|
||||
}
|
||||
|
||||
// Atomic rename into place.
|
||||
let bun_path = dir.join(bun_filename());
|
||||
let _ = fs::remove_file(&bun_path);
|
||||
fs::rename(&tmp_path, &bun_path)?;
|
||||
|
||||
// bunx: symlink (Unix) or copy (Windows).
|
||||
let bunx_path = dir.join(bunx_filename());
|
||||
let _ = fs::remove_file(&bunx_path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&bun_path, &bunx_path)?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
fs::copy(&bun_path, &bunx_path)?;
|
||||
}
|
||||
|
||||
// node: symlink (Unix) or copy (Windows).
|
||||
// Many npm packages use `#!/usr/bin/env node` shebangs; placing a
|
||||
// `node` alias in the bundled bun directory ensures they resolve
|
||||
// to bun (which is Node-compatible) even when no standalone Node
|
||||
// installation exists on the host.
|
||||
let node_path = dir.join(node_filename());
|
||||
let _ = fs::remove_file(&node_path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&bun_path, &node_path)?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
fs::copy(&bun_path, &node_path)?;
|
||||
}
|
||||
|
||||
// Stamp.
|
||||
let stamp = Stamp {
|
||||
sha256: expected_sha.into(),
|
||||
version: version.into(),
|
||||
extracted_at: chrono_utc_now(),
|
||||
};
|
||||
let stamp_bytes = serde_json::to_vec_pretty(&stamp)?;
|
||||
let stamp_tmp = dir.join("bun.stamp.tmp");
|
||||
{
|
||||
let mut f = File::create(&stamp_tmp)?;
|
||||
f.write_all(&stamp_bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&stamp_tmp, dir.join("bun.stamp"))?;
|
||||
|
||||
Ok(bun_path)
|
||||
})();
|
||||
|
||||
let _ = FileExt::unlock(&lock_file);
|
||||
result
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, std::io::Error> {
|
||||
let mut f = BufReader::new(File::open(path)?);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
/// A cheap RFC3339-ish timestamp that avoids pulling chrono into this crate.
|
||||
fn chrono_utc_now() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("epoch-{secs}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_blob(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = zstd::stream::write::Encoder::new(&mut out, 0).unwrap();
|
||||
enc.write_all(payload).unwrap();
|
||||
enc.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn sha_hex(payload: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(payload);
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_happy_path_creates_bun_and_bunx_and_node() {
|
||||
let payload = b"#!/bin/sh\necho fake-bun\n";
|
||||
let blob = make_blob(payload);
|
||||
let expected_sha = sha_hex(payload);
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("bun-9.9.9-aaaa");
|
||||
let bun_path = extract_into(&dir, &blob, &expected_sha, "9.9.9").unwrap();
|
||||
|
||||
assert!(bun_path.is_file(), "bun file must exist");
|
||||
assert!(dir.join(bunx_filename()).exists(), "bunx must exist");
|
||||
assert!(dir.join(node_filename()).exists(), "node must exist");
|
||||
assert!(dir.join("bun.stamp").is_file(), "stamp must exist");
|
||||
|
||||
let contents = std::fs::read(&bun_path).unwrap();
|
||||
assert_eq!(contents, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_is_idempotent_via_stamp_fast_path() {
|
||||
let payload = b"#!/bin/sh\necho fake\n";
|
||||
let blob = make_blob(payload);
|
||||
let sha = sha_hex(payload);
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("bun-1.0-aaaa");
|
||||
|
||||
extract_into(&dir, &blob, &sha, "1.0").unwrap();
|
||||
// Remove bun temp to prove re-extraction isn't happening.
|
||||
assert!(is_fresh(&dir, &sha, "1.0"));
|
||||
|
||||
// Second call should early-return via is_fresh after lock reacquire.
|
||||
extract_into(&dir, &blob, &sha, "1.0").unwrap();
|
||||
assert!(is_fresh(&dir, &sha, "1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_corrupt_checksum() {
|
||||
let payload = b"real contents";
|
||||
let blob = make_blob(payload);
|
||||
let wrong_sha = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("bun-corrupt");
|
||||
let err = extract_into(&dir, &blob, wrong_sha, "1.0").unwrap_err();
|
||||
match err {
|
||||
ExtractError::ChecksumMismatch { .. } => {}
|
||||
e => panic!("expected ChecksumMismatch, got {e:?}"),
|
||||
}
|
||||
assert!(!dir.join(bun_filename()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fresh_returns_false_when_missing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
assert!(!is_fresh(tmp.path(), "abc", "1.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Windows Job Object–based child-process-tree cleanup.
|
||||
//!
|
||||
//! `kill_on_drop(true)` only terminates the *direct* child, and the explicit
|
||||
//! `taskkill /T` teardown in [`crate::spawn`] only runs when our code gets a
|
||||
//! chance to run. Neither helps when this process is force-killed — which is
|
||||
//! exactly what `tauri dev` does on rebuild/Ctrl+C — so descendant trees like
|
||||
//! `bunx → codex-acp → MCP stdio bridges` survive as orphans. The bridges are
|
||||
//! this very executable (`nomifun-desktop.exe mcp-*-stdio`), so a leftover
|
||||
//! tree keeps the binary locked and the next build dies with os error 5.
|
||||
//!
|
||||
//! A Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` is the OS-level fix:
|
||||
//! every process assigned to the job — and all descendants, which inherit
|
||||
//! membership automatically — is terminated by the kernel when the last job
|
||||
//! handle closes. The process-global job handle lives for the lifetime of
|
||||
//! this process and is closed by the OS on process death *of any kind*,
|
||||
//! including TerminateProcess.
|
||||
//!
|
||||
//! Residual window: membership is granted by `AssignProcessToJobObject`
|
||||
//! *after* CreateProcess returns, so descendants the child manages to create
|
||||
//! in those few microseconds — or anything it spawned if it exits before the
|
||||
//! assignment — land outside the job. Closing it would need
|
||||
//! CREATE_SUSPENDED → assign → ResumeThread, which `tokio::process` does not
|
||||
//! expose; real CLI children spend far longer in loader/runtime init than
|
||||
//! the window lasts, so this is accepted.
|
||||
|
||||
use std::io;
|
||||
use std::os::windows::io::RawHandle;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tokio::process::Child;
|
||||
use tracing::warn;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject,
|
||||
};
|
||||
|
||||
/// An owned kill-on-close Job Object. Dropping it (closing the last handle)
|
||||
/// makes the kernel terminate every process still assigned to it.
|
||||
pub struct CleanupJob {
|
||||
handle: *mut core::ffi::c_void,
|
||||
}
|
||||
|
||||
// SAFETY: the wrapped value is an opaque kernel handle; the Win32 job APIs
|
||||
// called on it are documented thread-safe.
|
||||
unsafe impl Send for CleanupJob {}
|
||||
unsafe impl Sync for CleanupJob {}
|
||||
|
||||
impl CleanupJob {
|
||||
/// Create an anonymous job object configured with KILL_ON_JOB_CLOSE.
|
||||
pub fn new() -> io::Result<Self> {
|
||||
// SAFETY: plain Win32 calls. The handle is checked before use and
|
||||
// closed on every early-exit path.
|
||||
unsafe {
|
||||
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let ok = SetInformationJobObject(
|
||||
handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
);
|
||||
if ok == 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
CloseHandle(handle);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(Self { handle })
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign a live process to this job. Descendants spawned by the process
|
||||
/// afterwards inherit membership automatically.
|
||||
pub fn assign_raw(&self, process: RawHandle) -> io::Result<()> {
|
||||
// SAFETY: `self.handle` is live for `'self`; `process` is a live
|
||||
// process handle owned by the caller for the duration of the call.
|
||||
let ok = unsafe { AssignProcessToJobObject(self.handle, process.cast()) };
|
||||
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn raw(&self) -> *mut core::ffi::c_void {
|
||||
self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CleanupJob {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `handle` is owned by `self` and closed exactly once. With
|
||||
// KILL_ON_JOB_CLOSE this terminates all processes still in the job.
|
||||
unsafe { CloseHandle(self.handle) };
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-global cleanup job. Created lazily on first spawn; lives until
|
||||
/// this process dies, at which point the OS closes the handle and reaps every
|
||||
/// assigned child tree. `None` if creation failed (we degrade to the existing
|
||||
/// kill_on_drop / taskkill behaviour rather than refusing to spawn).
|
||||
pub(crate) fn global_cleanup_job() -> Option<&'static CleanupJob> {
|
||||
static JOB: OnceLock<Option<CleanupJob>> = OnceLock::new();
|
||||
JOB.get_or_init(|| match CleanupJob::new() {
|
||||
Ok(job) => Some(job),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
error = %e,
|
||||
"Failed to create cleanup job object; child process trees will leak if this process is force-killed"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Best-effort: put `child` into the global cleanup job. Failure is logged,
|
||||
/// never fatal — the child still runs, we just lose the force-kill safety net.
|
||||
pub(crate) fn assign_to_cleanup_job(child: &Child) {
|
||||
let Some(job) = global_cleanup_job() else { return };
|
||||
// `raw_handle` is `None` once the child has already been reaped — nothing
|
||||
// left to clean up in that case.
|
||||
let Some(raw) = child.raw_handle() else { return };
|
||||
if let Err(e) = job.assign_raw(raw) {
|
||||
warn!(pid = ?child.id(), error = %e, "Failed to assign child process to cleanup job");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `true` while the OS still has a live (not yet terminated) process with
|
||||
/// this pid. `tasklist /FI` prints an INFO line and nothing else when no
|
||||
/// task matches.
|
||||
fn pid_alive(pid: u32) -> bool {
|
||||
let out = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
||||
.output()
|
||||
.expect("tasklist should run");
|
||||
String::from_utf8_lossy(&out.stdout).contains(&pid.to_string())
|
||||
}
|
||||
|
||||
fn wait_until(timeout: Duration, mut check: impl FnMut() -> bool) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if check() {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_job_kills_child_and_grandchild() {
|
||||
// A plain path inside a temp dir — NOT NamedTempFile, whose open
|
||||
// handle would block PowerShell's Set-Content under Windows share
|
||||
// semantics.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join("grandchild-pid.txt");
|
||||
|
||||
// Leader powershell spawns a grandchild powershell, records its pid
|
||||
// into the marker file, then blocks. Mirrors the real-world shape:
|
||||
// Builder child (ACP CLI) spawning its own descendants. The marker
|
||||
// path travels via env var, not string interpolation — temp paths
|
||||
// contain the user profile, where an apostrophe would break a PS
|
||||
// single-quoted literal.
|
||||
let script = "$p = Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 120' \
|
||||
-PassThru -WindowStyle Hidden; \
|
||||
Set-Content -Path $env:NOMI_JOB_TEST_MARKER -Value $p.Id; Start-Sleep -Seconds 120";
|
||||
let mut leader = tokio::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", script])
|
||||
.env("NOMI_JOB_TEST_MARKER", &marker)
|
||||
.spawn()
|
||||
.expect("spawn leader powershell");
|
||||
|
||||
let job = CleanupJob::new().expect("create job");
|
||||
job.assign_raw(leader.raw_handle().expect("leader handle"))
|
||||
.expect("assign leader");
|
||||
|
||||
// Wait for the grandchild pid to land in the marker file.
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(20), || {
|
||||
std::fs::read_to_string(&marker)
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
"grandchild pid marker should appear"
|
||||
);
|
||||
let grandchild_pid: u32 = std::fs::read_to_string(&marker)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("marker should contain a pid");
|
||||
assert!(pid_alive(grandchild_pid), "grandchild should be running");
|
||||
|
||||
// Closing the last job handle must reap the whole tree — this is the
|
||||
// force-kill safety net (the OS does this even when no userland
|
||||
// cleanup code runs).
|
||||
drop(job);
|
||||
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(5), || leader.try_wait().ok().flatten().is_some()),
|
||||
"leader should be terminated by job close"
|
||||
);
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(5), || !pid_alive(grandchild_pid)),
|
||||
"grandchild pid={grandchild_pid} should be terminated by job close"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assigned_child_runs_to_completion_normally() {
|
||||
// The brief sleep keeps the child alive across the assign call — a
|
||||
// bare `exit 0` could finish first on a loaded machine, and
|
||||
// AssignProcessToJobObject fails with ACCESS_DENIED on a terminated
|
||||
// process.
|
||||
let mut child = tokio::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", "Start-Sleep -Milliseconds 500; exit 0"])
|
||||
.spawn()
|
||||
.expect("spawn powershell");
|
||||
|
||||
let job = CleanupJob::new().expect("create job");
|
||||
job.assign_raw(child.raw_handle().expect("child handle"))
|
||||
.expect("assign child");
|
||||
|
||||
let status = child.wait().await.expect("wait child");
|
||||
assert!(status.success(), "job membership must not disturb a normal run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_job_is_created_once_and_assign_helper_is_silent() {
|
||||
let job1 = global_cleanup_job().expect("global job should create on a normal system") as *const _;
|
||||
let job2 = global_cleanup_job().expect("second call returns the same job") as *const _;
|
||||
assert_eq!(job1, job2, "global job must be a singleton");
|
||||
|
||||
let child = tokio::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", "exit 0"])
|
||||
.spawn()
|
||||
.expect("spawn powershell");
|
||||
// Must not panic / error loudly.
|
||||
assign_to_cleanup_job(&child);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Bundled runtime (bun) resolver for nomicore.
|
||||
//!
|
||||
//! Embeds the bun runtime at build time (zstd-compressed) and extracts it
|
||||
//! to the user's OS cache directory on first call. Callers use
|
||||
//! [`resolve_bun`] to obtain a usable executable path and [`bun_bin_dir`]
|
||||
//! to prepend the runtime directory to child-process `PATH`.
|
||||
|
||||
mod cache;
|
||||
mod embed;
|
||||
mod extract;
|
||||
#[cfg(windows)]
|
||||
mod job;
|
||||
mod resolver;
|
||||
mod shell_env;
|
||||
|
||||
pub use cache::{init, runtime_root};
|
||||
pub use resolver::{ResolveError, bun_bin_dir, resolve_bun, resolve_command_in, resolve_command_path};
|
||||
pub use shell_env::enhance_process_path;
|
||||
mod spawn;
|
||||
pub use spawn::{Builder, kill_process_tree};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../build_support.rs"]
|
||||
mod build_support_tests;
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Public API for the bundled bun runtime.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::cache;
|
||||
use crate::embed::{EmbeddedBun, ProductionEmbed};
|
||||
use crate::extract::{self, ExtractError};
|
||||
|
||||
/// Max time to wait for a freshly-extracted `bun` binary to become
|
||||
/// observable via `Path::is_file()` after `extract_into()` returns.
|
||||
const BUN_OBSERVABLE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const BUN_OBSERVABLE_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("bun not found")]
|
||||
NotFound,
|
||||
#[error("failed to extract embedded bun: {0}")]
|
||||
Extract(#[from] std::io::Error),
|
||||
#[error("embedded bun checksum mismatch")]
|
||||
ChecksumMismatch,
|
||||
#[error("serde_json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl From<ExtractError> for ResolveError {
|
||||
fn from(err: ExtractError) -> Self {
|
||||
match err {
|
||||
ExtractError::Io(e) => ResolveError::Extract(e),
|
||||
ExtractError::ChecksumMismatch { .. } => ResolveError::ChecksumMismatch,
|
||||
ExtractError::Json(e) => ResolveError::Json(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static RESOLVED_BUN: OnceLock<PathBuf> = OnceLock::new();
|
||||
static BUN_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
/// Returns the path to a usable `bun` executable.
|
||||
///
|
||||
/// Priority: `NOMIFUN_BUN_PATH` env override > embedded + extract >
|
||||
/// `which("bun")`.
|
||||
pub fn resolve_bun() -> Result<PathBuf, ResolveError> {
|
||||
if let Some(path) = RESOLVED_BUN.get() {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
let resolved = resolve_with(&ProductionEmbed)?;
|
||||
let _ = RESOLVED_BUN.set(resolved.clone());
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Returns the directory that holds `bun` and `bunx`, if a bundled
|
||||
/// runtime was extracted. `None` when no embed + no override was used.
|
||||
pub fn bun_bin_dir() -> Option<PathBuf> {
|
||||
BUN_DIR
|
||||
.get_or_init(|| {
|
||||
resolve_with(&ProductionEmbed)
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(PathBuf::from))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn resolve_with<E: EmbeddedBun>(embed: &E) -> Result<PathBuf, ResolveError> {
|
||||
if let Some(p) = env_override() {
|
||||
return Ok(p);
|
||||
}
|
||||
if !embed.has() {
|
||||
return which::which("bun").map_err(|_| ResolveError::NotFound);
|
||||
}
|
||||
let dir = cache::bun_dir(embed.version(), embed.sha256()).ok_or(ResolveError::NotFound)?;
|
||||
let bun_path = dir.join(extract::bun_filename());
|
||||
|
||||
// Stamp says fresh AND the executable is actually on disk: fast path.
|
||||
if extract::is_fresh(&dir, embed.sha256(), embed.version()) && bun_path.is_file() {
|
||||
return Ok(bun_path);
|
||||
}
|
||||
|
||||
// One retry on checksum mismatch: wipe dir and re-extract.
|
||||
let extracted = match extract::extract_into(&dir, embed.blob(), embed.sha256(), embed.version()) {
|
||||
Ok(p) => p,
|
||||
Err(ExtractError::ChecksumMismatch { .. }) => {
|
||||
tracing::warn!("bun cache checksum mismatch; wiping and retrying");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
extract::extract_into(&dir, embed.blob(), embed.sha256(), embed.version())?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Guard against returning a phantom path: wait until the executable
|
||||
// is observable on disk. Without this, a caller that immediately
|
||||
// spawns the returned path can race with the OS file-cache flush and
|
||||
// see ENOENT, as seen on cold start right after first extract.
|
||||
wait_until_observable(&extracted)?;
|
||||
Ok(extracted)
|
||||
}
|
||||
|
||||
fn wait_until_observable(path: &Path) -> Result<(), ResolveError> {
|
||||
let deadline = Instant::now() + BUN_OBSERVABLE_TIMEOUT;
|
||||
loop {
|
||||
if path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"extracted bun path not observable after timeout"
|
||||
);
|
||||
return Err(ResolveError::NotFound);
|
||||
}
|
||||
std::thread::sleep(BUN_OBSERVABLE_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
fn env_override() -> Option<PathBuf> {
|
||||
let raw = std::env::var("NOMIFUN_BUN_PATH").ok()?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let p = PathBuf::from(trimmed);
|
||||
if p.is_file() {
|
||||
Some(p)
|
||||
} else {
|
||||
tracing::warn!(path = %p.display(), "NOMIFUN_BUN_PATH does not point to a file; ignoring");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a command name to an absolute path.
|
||||
///
|
||||
/// For `bun` / `bunx` we go through `nomifun_runtime` so the bundled
|
||||
/// runtime is used when present; everything else falls back to the
|
||||
/// user's `$PATH` via `which::which`.
|
||||
///
|
||||
/// On Windows, if a bare name lookup fails we retry with the common
|
||||
/// shim suffixes (`.cmd`, `.ps1`, `.bat`). Tools installed via npm
|
||||
/// global / pnpm / yarn typically ship as `name.cmd`, and a user with a
|
||||
/// trimmed `PATHEXT` would otherwise see them as missing.
|
||||
pub fn resolve_command_path(cmd: &str) -> Option<PathBuf> {
|
||||
match cmd {
|
||||
"bun" => resolve_bun().ok().or_else(|| which::which("bun").ok()),
|
||||
"bunx" => {
|
||||
let bunx_name = if cfg!(windows) { "bunx.exe" } else { "bunx" };
|
||||
if let Some(dir) = bun_bin_dir() {
|
||||
let p = dir.join(bunx_name);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
which::which("bunx").ok()
|
||||
}
|
||||
other => which::which(other).ok().or_else(|| windows_shim_fallback(other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_shim_fallback(cmd: &str) -> Option<PathBuf> {
|
||||
// If the caller already passed an extension, no point retrying.
|
||||
if Path::new(cmd).extension().is_some() {
|
||||
return None;
|
||||
}
|
||||
for ext in ["cmd", "ps1", "bat"] {
|
||||
if let Ok(p) = which::which(format!("{cmd}.{ext}")) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn windows_shim_fallback(_cmd: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve `cmd` to an absolute path **within `dir` only** — does not walk
|
||||
/// `PATH`. Honours `PATHEXT` (so `widget.exe` is found on Windows), and on
|
||||
/// Windows additionally tries `.cmd`, `.ps1`, `.bat` shim suffixes for
|
||||
/// npm-/pnpm-installed CLIs whose extension `PATHEXT` may not list.
|
||||
///
|
||||
/// `dir` is wrapped via `std::env::join_paths` before being handed to
|
||||
/// `which::which_in`, so a `dir` that itself contains the OS PATH
|
||||
/// separator (`:` on Unix, `;` on Windows) cannot be misinterpreted as
|
||||
/// two directories. If `dir` cannot be expressed as a single PATH
|
||||
/// entry, we return `None` rather than searching a phantom location.
|
||||
///
|
||||
/// Returns `None` if the command cannot be resolved inside the directory.
|
||||
pub fn resolve_command_in(cmd: &str, dir: &Path) -> Option<PathBuf> {
|
||||
let paths = std::env::join_paths([dir]).ok()?;
|
||||
if let Ok(p) = which::which_in(cmd, Some(&paths), dir) {
|
||||
return Some(p);
|
||||
}
|
||||
windows_shim_fallback_in(cmd, dir)
|
||||
}
|
||||
|
||||
/// Try `cmd` plus the common Windows shim suffixes (`.cmd`, `.ps1`, `.bat`)
|
||||
/// inside a single directory. Used by `resolve_command_in` for callers that
|
||||
/// want a directory-scoped lookup (the global `windows_shim_fallback` below
|
||||
/// goes through `which::which`, which walks the entire `PATH`).
|
||||
#[cfg(windows)]
|
||||
fn windows_shim_fallback_in(cmd: &str, dir: &Path) -> Option<PathBuf> {
|
||||
if Path::new(cmd).extension().is_some() {
|
||||
return None;
|
||||
}
|
||||
for ext in ["cmd", "ps1", "bat"] {
|
||||
let candidate = dir.join(format!("{cmd}.{ext}"));
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn windows_shim_fallback_in(_cmd: &str, _dir: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::embed::FakeEmbed;
|
||||
use std::io::Write as _;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn make_blob(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = zstd::stream::write::Encoder::new(&mut out, 0).unwrap();
|
||||
enc.write_all(payload).unwrap();
|
||||
enc.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn sha(payload: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(payload);
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_embed_falls_back_to_which() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
// Safety: unset to avoid env override winning.
|
||||
// SAFETY: ENV_LOCK serializes tests that mutate NOMIFUN_BUN_PATH.
|
||||
unsafe {
|
||||
std::env::remove_var("NOMIFUN_BUN_PATH");
|
||||
}
|
||||
|
||||
let fake = FakeEmbed {
|
||||
has: false,
|
||||
blob: b"",
|
||||
sha256: "",
|
||||
version: "",
|
||||
};
|
||||
let res = resolve_with(&fake);
|
||||
// If bun is on the test host's PATH -> Ok; otherwise NotFound.
|
||||
// Both are correct behaviors for this branch.
|
||||
match res {
|
||||
Ok(_) | Err(ResolveError::NotFound) => {}
|
||||
Err(e) => panic!("unexpected error: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_wins_over_embed() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let path = tmp.path().to_path_buf();
|
||||
// SAFETY: ENV_LOCK serializes tests that mutate NOMIFUN_BUN_PATH.
|
||||
unsafe {
|
||||
std::env::set_var("NOMIFUN_BUN_PATH", &path);
|
||||
}
|
||||
|
||||
let payload = b"anything";
|
||||
let fake_blob: &'static [u8] = Box::leak(make_blob(payload).into_boxed_slice());
|
||||
let fake_sha: &'static str = Box::leak(sha(payload).into_boxed_str());
|
||||
let fake = FakeEmbed {
|
||||
has: true,
|
||||
blob: fake_blob,
|
||||
sha256: fake_sha,
|
||||
version: "1.0",
|
||||
};
|
||||
|
||||
let result = resolve_with(&fake).unwrap();
|
||||
assert_eq!(result, path);
|
||||
|
||||
// SAFETY: ENV_LOCK serializes tests that mutate NOMIFUN_BUN_PATH.
|
||||
unsafe {
|
||||
std::env::remove_var("NOMIFUN_BUN_PATH");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_until_observable_returns_immediately_when_present() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
// File exists, so this must be cheap.
|
||||
let start = Instant::now();
|
||||
wait_until_observable(tmp.path()).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_until_observable_errors_when_path_never_appears() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let phantom = tmp.path().join("does-not-exist");
|
||||
let res = wait_until_observable(&phantom);
|
||||
match res {
|
||||
Err(ResolveError::NotFound) => {}
|
||||
other => panic!("expected NotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_env_override_falls_through_to_embed() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
// SAFETY: ENV_LOCK serializes tests that mutate NOMIFUN_BUN_PATH.
|
||||
unsafe {
|
||||
std::env::set_var("NOMIFUN_BUN_PATH", "/definitely/does/not/exist");
|
||||
}
|
||||
|
||||
let fake = FakeEmbed {
|
||||
has: false,
|
||||
blob: b"",
|
||||
sha256: "",
|
||||
version: "",
|
||||
};
|
||||
let res = resolve_with(&fake);
|
||||
// Must not error out as `Extract(...)` from env override branch;
|
||||
// must fall through to which() (Ok or NotFound — both fine).
|
||||
match res {
|
||||
Ok(_) | Err(ResolveError::NotFound) => {}
|
||||
Err(e) => panic!("unexpected error: {e:?}"),
|
||||
}
|
||||
|
||||
// SAFETY: ENV_LOCK serializes tests that mutate NOMIFUN_BUN_PATH.
|
||||
unsafe {
|
||||
std::env::remove_var("NOMIFUN_BUN_PATH");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolve_command_in_finds_executable_in_dir() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let bin = tmp.path().join("widget");
|
||||
std::fs::write(&bin, b"#!/bin/sh\necho hi\n").unwrap();
|
||||
let mut perms = std::fs::metadata(&bin).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&bin, perms).unwrap();
|
||||
|
||||
let found = resolve_command_in("widget", tmp.path()).expect("must find");
|
||||
assert_eq!(found, bin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_in_returns_none_for_missing_command() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let found = resolve_command_in("definitely-not-here", tmp.path());
|
||||
assert!(found.is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolve_command_in_handles_dir_with_colon_safely() {
|
||||
// A path containing `:` is a separator-collision hazard for the
|
||||
// PATH string `which_in` consumes. We must NOT internally split
|
||||
// and search a wrong second segment — return None instead.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let weird = tmp.path().join("with:colon");
|
||||
std::fs::create_dir(&weird).unwrap();
|
||||
// No `widget` file is created anywhere — the only way this could
|
||||
// return Some is if the function wrongly split `with:colon` and
|
||||
// found something in another segment.
|
||||
let found = resolve_command_in("widget", &weird);
|
||||
assert!(found.is_none(), "must not split on `:` inside dir; got {:?}", found);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolve_command_in_falls_back_to_cmd_shim_on_windows() {
|
||||
// Simulate an npm-installed CLI: only `widget.cmd` exists, not `widget.exe`.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let shim = tmp.path().join("widget.cmd");
|
||||
std::fs::write(&shim, b"@echo off\r\necho hi\r\n").unwrap();
|
||||
|
||||
let found = resolve_command_in("widget", tmp.path()).expect("must find shim");
|
||||
assert!(
|
||||
found.to_string_lossy().to_lowercase().ends_with("widget.cmd"),
|
||||
"expected the .cmd shim; got {}",
|
||||
found.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
//! Startup-time PATH enhancement.
|
||||
//!
|
||||
//! Call [`enhance_process_path`] from `main()` **before any worker thread
|
||||
//! is spawned** (including the tokio runtime). It rewrites
|
||||
//! `std::env::var("PATH")` to include:
|
||||
//!
|
||||
//! 1. The bundled bun directory (highest priority).
|
||||
//! 2. Platform extra bins (`~/.bun/bin`, `~/.cargo/bin`, homebrew,
|
||||
//! asdf/mise/fnm, env-var-driven roots like `PNPM_HOME`, …).
|
||||
//! 3. The current `PATH` (inherited from the launching process).
|
||||
//! 4. The **interactive** login-shell `PATH` (Unix only, 5s timeout) —
|
||||
//! sources `~/.zshrc` / `~/.bashrc` in addition to the login files, so
|
||||
//! toolchain dirs added there (nvm/fnm/pnpm/asdf/mise, custom npm
|
||||
//! prefixes) are visible. Fixes launchd / Finder / systemd-service
|
||||
//! starts where the inherited PATH is minimal.
|
||||
//!
|
||||
//! After this runs, all downstream `which::which(...)` and
|
||||
//! `Command::new(...)` calls see the enhanced PATH with zero further
|
||||
//! wiring.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
/// Enhance the current process's `PATH`. Returns the merged PATH string
|
||||
/// for logging/debugging.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Must be called **before** any other thread exists (including the
|
||||
/// tokio runtime). Internally calls `std::env::set_var` which is
|
||||
/// `unsafe` on Rust 2024.
|
||||
pub unsafe fn enhance_process_path() -> String {
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
let login = login_shell_path();
|
||||
let extras = platform_extra_bins();
|
||||
let bun_dir = crate::bun_bin_dir();
|
||||
|
||||
let merged = merge_paths(bun_dir.as_deref(), &extras, ¤t, login.as_deref());
|
||||
|
||||
if merged == current {
|
||||
tracing::warn!("PATH enhancement produced no changes; continuing with inherited PATH");
|
||||
} else {
|
||||
tracing::info!(
|
||||
login = login.is_some(),
|
||||
extra_bin_count = extras.len(),
|
||||
bun_bundled = bun_dir.is_some(),
|
||||
original_len = current.len(),
|
||||
merged_len = merged.len(),
|
||||
"PATH enhanced at startup"
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: caller guarantees single-threaded precondition.
|
||||
unsafe {
|
||||
std::env::set_var("PATH", &merged);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
// Placeholder helpers — filled in by later tasks.
|
||||
|
||||
fn merge_paths(bun_dir: Option<&Path>, extras: &[PathBuf], current: &str, login: Option<&str>) -> String {
|
||||
// Order: bun_dir, extras, current, login. First-occurrence wins.
|
||||
// `env::split_paths` and `env::join_paths` honour the OS-specific
|
||||
// separator (':' on Unix, ';' on Windows) and handle quoting.
|
||||
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
|
||||
let mut parts: Vec<PathBuf> = Vec::new();
|
||||
|
||||
let mut push = |p: PathBuf| {
|
||||
if p.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
if seen.insert(p.clone()) {
|
||||
parts.push(p);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(p) = bun_dir {
|
||||
push(p.to_path_buf());
|
||||
}
|
||||
for p in extras {
|
||||
push(p.clone());
|
||||
}
|
||||
for p in std::env::split_paths(current) {
|
||||
push(p);
|
||||
}
|
||||
if let Some(l) = login {
|
||||
for p in std::env::split_paths(l) {
|
||||
push(p);
|
||||
}
|
||||
}
|
||||
|
||||
std::env::join_paths(&parts)
|
||||
.map(|os| os.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn platform_extra_bins() -> Vec<PathBuf> {
|
||||
let mut out = platform_extra_bins_at(dirs::home_dir().as_deref());
|
||||
// Env-var-driven install locations. Kept out of `platform_extra_bins_at`
|
||||
// so that function stays a pure function of `home` for unit tests; the
|
||||
// real env is only read here.
|
||||
out.extend(env_driven_bins(|k| std::env::var(k).ok()));
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve toolchain bin dirs from explicit env vars a user may have set
|
||||
/// to relocate a package manager's install root (e.g. `PNPM_HOME`,
|
||||
/// `NPM_CONFIG_PREFIX`). `get` returns the raw value of an env var, or
|
||||
/// `None` if unset — injectable so the resolution logic is unit-testable
|
||||
/// without mutating the process environment. Only directories that exist
|
||||
/// on disk are returned.
|
||||
fn env_driven_bins<F>(get: F) -> Vec<PathBuf>
|
||||
where
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
// (env var, subdir appended to its value). Empty subdir => the value
|
||||
// is already the bin dir.
|
||||
const SPECS: &[(&str, &str)] = &[
|
||||
("PNPM_HOME", ""), // pnpm global bin (its own dir)
|
||||
("NPM_CONFIG_PREFIX", "bin"), // custom `npm config set prefix`
|
||||
("BUN_INSTALL", "bin"), // bun install root
|
||||
("VOLTA_HOME", "bin"), // volta
|
||||
("DENO_INSTALL", "bin"), // deno
|
||||
("N_PREFIX", "bin"), // `n` node version manager
|
||||
];
|
||||
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for (var, sub) in SPECS {
|
||||
let Some(raw) = get(var) else { continue };
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dir = if sub.is_empty() {
|
||||
PathBuf::from(raw)
|
||||
} else {
|
||||
PathBuf::from(raw).join(sub)
|
||||
};
|
||||
if dir.is_dir() {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn platform_extra_bins_at(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let mut push_if_dir = |p: PathBuf| {
|
||||
if p.is_dir() {
|
||||
out.push(p);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(h) = home {
|
||||
push_if_dir(h.join(".bun").join("bin"));
|
||||
push_if_dir(h.join(".cargo").join("bin"));
|
||||
push_if_dir(h.join("go").join("bin"));
|
||||
push_if_dir(h.join(".deno").join("bin"));
|
||||
push_if_dir(h.join(".local").join("bin"));
|
||||
push_if_dir(h.join(".volta").join("bin"));
|
||||
// Custom npm global prefixes (`npm config set prefix …`) and other
|
||||
// common per-user node install roots.
|
||||
push_if_dir(h.join(".npm-global").join("bin"));
|
||||
push_if_dir(h.join(".npm-packages").join("bin"));
|
||||
push_if_dir(h.join(".node").join("bin"));
|
||||
// Version-manager shim dirs (asdf, mise/rtx).
|
||||
push_if_dir(h.join(".asdf").join("shims"));
|
||||
push_if_dir(h.join(".local").join("share").join("mise").join("shims"));
|
||||
// pnpm global bin default locations (Linux XDG vs macOS).
|
||||
push_if_dir(h.join(".local").join("share").join("pnpm"));
|
||||
push_if_dir(h.join("Library").join("pnpm"));
|
||||
for nvm_bin in nvm_version_bins(h) {
|
||||
push_if_dir(nvm_bin);
|
||||
}
|
||||
for fnm_bin in fnm_version_bins(h) {
|
||||
push_if_dir(fnm_bin);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Homebrew on Apple Silicon (`/opt/homebrew/bin`) is NOT on the
|
||||
// minimal PATH a GUI launch inherits, and `/usr/local` is where
|
||||
// many CLIs (claude/codex via npm/brew or official installers)
|
||||
// land. Cheap to probe; `push_if_dir` drops the ones absent.
|
||||
push_if_dir(PathBuf::from("/opt/homebrew/bin"));
|
||||
push_if_dir(PathBuf::from("/opt/homebrew/sbin"));
|
||||
push_if_dir(PathBuf::from("/usr/local/bin"));
|
||||
push_if_dir(PathBuf::from("/usr/local/sbin"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
push_if_dir(PathBuf::from(&appdata).join("npm"));
|
||||
}
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
push_if_dir(PathBuf::from(&local).join("pnpm"));
|
||||
push_if_dir(PathBuf::from(&local).join("fnm_multishells"));
|
||||
// winget package shims (stable since App Installer 1.4).
|
||||
push_if_dir(PathBuf::from(&local).join("Microsoft").join("WinGet").join("Links"));
|
||||
// Yarn classic global bin.
|
||||
push_if_dir(PathBuf::from(&local).join("Yarn").join("bin"));
|
||||
}
|
||||
if let Ok(pf) = std::env::var("ProgramFiles") {
|
||||
push_if_dir(PathBuf::from(&pf).join("Git").join("cmd"));
|
||||
push_if_dir(PathBuf::from(&pf).join("Git").join("bin"));
|
||||
push_if_dir(PathBuf::from(&pf).join("nodejs"));
|
||||
}
|
||||
if let Ok(pf86) = std::env::var("ProgramFiles(x86)") {
|
||||
push_if_dir(PathBuf::from(&pf86).join("nodejs"));
|
||||
}
|
||||
if let Ok(scoop) = std::env::var("SCOOP") {
|
||||
push_if_dir(PathBuf::from(&scoop).join("shims"));
|
||||
} else if let Some(h) = home {
|
||||
push_if_dir(h.join("scoop").join("shims"));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn nvm_version_bins(home: &Path) -> Vec<PathBuf> {
|
||||
let versions_dir = home.join(".nvm").join("versions").join("node");
|
||||
let Ok(entries) = std::fs::read_dir(&versions_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut bins: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path().join("bin"))
|
||||
.filter(|bin| bin.is_dir())
|
||||
.collect();
|
||||
|
||||
// Prefer newer-looking versions first, matching the user's active
|
||||
// Node installation ahead of older fallbacks when multiple bins exist.
|
||||
bins.sort_by(|a, b| b.cmp(a));
|
||||
bins
|
||||
}
|
||||
|
||||
/// fnm installs each Node version under
|
||||
/// `<data>/fnm/node-versions/<ver>/installation/bin`. The per-shell
|
||||
/// `fnm_multishells` symlinks are ephemeral, so we walk the stable
|
||||
/// version dirs instead. We probe the common data roots (Linux XDG,
|
||||
/// macOS Application Support, and `~/.fnm`).
|
||||
fn fnm_version_bins(home: &Path) -> Vec<PathBuf> {
|
||||
let roots = [
|
||||
home.join(".local").join("share").join("fnm").join("node-versions"),
|
||||
home.join("Library")
|
||||
.join("Application Support")
|
||||
.join("fnm")
|
||||
.join("node-versions"),
|
||||
home.join(".fnm").join("node-versions"),
|
||||
];
|
||||
|
||||
let mut bins: Vec<PathBuf> = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(entries) = std::fs::read_dir(&root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let bin = entry.path().join("installation").join("bin");
|
||||
if bin.is_dir() {
|
||||
bins.push(bin);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Newer-looking versions first, mirroring nvm handling above.
|
||||
bins.sort_by(|a, b| b.cmp(a));
|
||||
bins
|
||||
}
|
||||
|
||||
/// Markers wrapped around the probed `$PATH` so we can extract it
|
||||
/// cleanly even when an interactive shell's startup files print banners,
|
||||
/// version notices, or prompt escapes to stdout. Without the markers,
|
||||
/// any such noise would be mistaken for PATH segments.
|
||||
#[cfg(unix)]
|
||||
const PATH_PROBE_BEGIN: &str = "__NOMIFUN_PATH_BEGIN__";
|
||||
#[cfg(unix)]
|
||||
const PATH_PROBE_END: &str = "__NOMIFUN_PATH_END__";
|
||||
|
||||
/// Shell snippet that prints the live `$PATH` wrapped in our markers.
|
||||
#[cfg(unix)]
|
||||
const PATH_PROBE_SNIPPET: &str = "printf '__NOMIFUN_PATH_BEGIN__%s__NOMIFUN_PATH_END__' \"$PATH\"";
|
||||
|
||||
/// How long to wait for the login-shell probe before giving up. An
|
||||
/// interactive shell sources the user's full startup files (oh-my-zsh,
|
||||
/// nvm, conda init, …), which can take a second or two on a heavily
|
||||
/// customized setup, so we allow more headroom than a bare command needs.
|
||||
#[cfg(unix)]
|
||||
const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Pull the `$PATH` value out of the probe's stdout, ignoring any
|
||||
/// surrounding noise emitted by the shell's startup files. Returns
|
||||
/// `None` when the markers are absent or the captured value is empty.
|
||||
#[cfg(unix)]
|
||||
fn extract_probe_path(raw: &str) -> Option<String> {
|
||||
let start = raw.find(PATH_PROBE_BEGIN)? + PATH_PROBE_BEGIN.len();
|
||||
let end_rel = raw[start..].find(PATH_PROBE_END)?;
|
||||
let path = raw[start..start + end_rel].trim();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn login_shell_path() -> Option<String> {
|
||||
let shell = std::env::var("SHELL").ok()?;
|
||||
if !Path::new(&shell).is_absolute() {
|
||||
tracing::debug!(%shell, "SHELL is not absolute, skipping login shell probe");
|
||||
return None;
|
||||
}
|
||||
run_login_shell_path(&shell, None)
|
||||
}
|
||||
|
||||
/// Spawn `shell` as an **interactive login** shell and capture the
|
||||
/// `$PATH` it exports.
|
||||
///
|
||||
/// `-i` (interactive) is essential: most users add their toolchain dirs
|
||||
/// (nvm / fnm / pnpm / asdf / mise, custom npm prefixes, manual
|
||||
/// `export PATH=…`) in `~/.zshrc` / `~/.bashrc`, which a *non*-interactive
|
||||
/// login shell (`-l` only) does NOT source — that gap is why some
|
||||
/// machines detect no CLI agents at all. `-l` (login) additionally pulls
|
||||
/// in `~/.zprofile` / `~/.bash_profile`, so `-i -l` is a strict superset
|
||||
/// of the previous `-l`-only probe.
|
||||
///
|
||||
/// `home_override` lets tests point the child at a scratch `$HOME` (and
|
||||
/// drops `ZDOTDIR` so zsh resolves its rc files under that `$HOME`); in
|
||||
/// production it is `None` and the real environment is inherited.
|
||||
#[cfg(unix)]
|
||||
fn run_login_shell_path(shell: &str, home_override: Option<&Path>) -> Option<String> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(["-i", "-l", "-c", PATH_PROBE_SNIPPET])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
if let Some(home) = home_override {
|
||||
cmd.env("HOME", home);
|
||||
cmd.env_remove("ZDOTDIR");
|
||||
}
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(%shell, error = %e, "login shell spawn failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Drain stdout on a dedicated thread. An interactive shell may emit a
|
||||
// PATH long enough to fill the pipe buffer (deadlocks a read-after-wait)
|
||||
// AND — unlike the old non-interactive probe — may never exit if a
|
||||
// startup file blocks. Reading on a separate thread lets the timeout
|
||||
// below fire and `kill()` the child, which closes the pipe and unblocks
|
||||
// this reader. The thread is always joined before we return, so no
|
||||
// worker thread outlives `enhance_process_path`'s `set_var`.
|
||||
let mut stdout_handle = child.stdout.take()?;
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = stdout_handle.read_to_string(&mut buf);
|
||||
buf
|
||||
});
|
||||
|
||||
let status = match child.wait_timeout(LOGIN_SHELL_TIMEOUT) {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = reader.join();
|
||||
tracing::warn!("login shell PATH probe timed out");
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = reader.join();
|
||||
tracing::debug!(error = %e, "login shell wait_timeout errored");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = reader.join().ok()?;
|
||||
|
||||
if !status.success() {
|
||||
tracing::debug!(?status, "login shell exited non-zero");
|
||||
return None;
|
||||
}
|
||||
|
||||
extract_probe_path(&stdout)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn login_shell_path() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Serializes tests that mutate the process-global `SHELL` env var.
|
||||
/// `cargo test` runs test fns on parallel threads; without this lock
|
||||
/// one test's `set_var`/`remove_var` races another's read.
|
||||
#[cfg(unix)]
|
||||
static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn sep() -> &'static str {
|
||||
if cfg!(windows) { ";" } else { ":" }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_paths_dedupes_preserve_order() {
|
||||
let s = sep();
|
||||
let current = format!("/a{s}/b{s}/c");
|
||||
let login = format!("/b{s}/d");
|
||||
let extras: Vec<PathBuf> = vec![PathBuf::from("/e")];
|
||||
|
||||
let result = merge_paths(None, &extras, ¤t, Some(&login));
|
||||
let parts: Vec<&str> = result.split(s).collect();
|
||||
|
||||
assert_eq!(parts, vec!["/e", "/a", "/b", "/c", "/d"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_paths_with_bun_dir_at_front() {
|
||||
let s = sep();
|
||||
let current = format!("/a{s}/b");
|
||||
let bun = PathBuf::from("/bun");
|
||||
|
||||
let result = merge_paths(Some(&bun), &[], ¤t, None);
|
||||
let parts: Vec<&str> = result.split(s).collect();
|
||||
|
||||
assert_eq!(parts, vec!["/bun", "/a", "/b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_paths_drops_empty_segments() {
|
||||
let s = sep();
|
||||
let current = format!("{s}/a{s}{s}/b{s}");
|
||||
|
||||
let result = merge_paths(None, &[], ¤t, None);
|
||||
let parts: Vec<&str> = result.split(s).collect();
|
||||
|
||||
assert_eq!(parts, vec!["/a", "/b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_paths_all_optional_none() {
|
||||
let result = merge_paths(None, &[], "", None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_paths_bun_dir_deduplicates_if_already_in_current() {
|
||||
let s = sep();
|
||||
let current = format!("/bun{s}/a");
|
||||
let bun = PathBuf::from("/bun");
|
||||
|
||||
let result = merge_paths(Some(&bun), &[], ¤t, None);
|
||||
let parts: Vec<&str> = result.split(s).collect();
|
||||
|
||||
// /bun appears first (from bun_dir), then /a from current.
|
||||
// Second /bun (inside current) is dedup'd.
|
||||
assert_eq!(parts, vec!["/bun", "/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_extra_bins_at_filters_nonexistent() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
// 构造少量"存在"的 bin 目录,其他 candidate 仍会被 platform_extra_bins_at
|
||||
// 检查但应被过滤掉。
|
||||
std::fs::create_dir_all(home.join(".bun/bin")).unwrap();
|
||||
std::fs::create_dir_all(home.join(".cargo/bin")).unwrap();
|
||||
std::fs::create_dir_all(home.join(".nvm/versions/node/v22.22.0/bin")).unwrap();
|
||||
std::fs::create_dir_all(home.join(".nvm/versions/node/v25.1.0/bin")).unwrap();
|
||||
|
||||
let bins = platform_extra_bins_at(Some(home));
|
||||
|
||||
// The product builds these tails with Path::join, so the separator is
|
||||
// platform-native (backslash on Windows). Match component-wise — a
|
||||
// multi-component &str like "a/b" is one component to Path::ends_with and
|
||||
// never matches the two-component a\b tail on Windows. Build tails as
|
||||
// PathBufs (and normalize separators for the substring check) instead.
|
||||
let tail = |segs: &[&str]| segs.iter().collect::<PathBuf>();
|
||||
|
||||
// 至少这两个应出现
|
||||
assert!(
|
||||
bins.iter().any(|p| p.ends_with(tail(&[".bun", "bin"]))),
|
||||
"expected ~/.bun/bin in result"
|
||||
);
|
||||
assert!(
|
||||
bins.iter().any(|p| p.ends_with(tail(&[".cargo", "bin"]))),
|
||||
"expected ~/.cargo/bin in result"
|
||||
);
|
||||
assert!(
|
||||
bins.iter()
|
||||
.any(|p| p.ends_with(tail(&[".nvm", "versions", "node", "v22.22.0", "bin"]))),
|
||||
"expected ~/.nvm/versions/node/v22.22.0/bin in result"
|
||||
);
|
||||
assert!(
|
||||
bins.iter()
|
||||
.any(|p| p.ends_with(tail(&[".nvm", "versions", "node", "v25.1.0", "bin"]))),
|
||||
"expected ~/.nvm/versions/node/v25.1.0/bin in result"
|
||||
);
|
||||
let nvm_bins: Vec<_> = bins
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.contains(".nvm/versions/node/")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(nvm_bins.len(), 2);
|
||||
assert!(
|
||||
nvm_bins[0].ends_with(tail(&[".nvm", "versions", "node", "v25.1.0", "bin"])),
|
||||
"expected newer NVM bin first"
|
||||
);
|
||||
assert!(
|
||||
nvm_bins[1].ends_with(tail(&[".nvm", "versions", "node", "v22.22.0", "bin"])),
|
||||
"expected older NVM bin second"
|
||||
);
|
||||
|
||||
// 没创建的目录不应出现
|
||||
assert!(!bins.iter().any(|p| p.ends_with(tail(&["go", "bin"]))));
|
||||
assert!(!bins.iter().any(|p| p.ends_with(tail(&[".deno", "bin"]))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_extra_bins_at_handles_no_home() {
|
||||
let bins = platform_extra_bins_at(None);
|
||||
// 没 home 时,Unix 返回空;Windows 可能仍从 env 读到 APPDATA 等——两种都可接受。
|
||||
// 只验证不 panic。
|
||||
let _ = bins;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_extra_bins_at_includes_common_node_tool_dirs() {
|
||||
// Defense-in-depth: tools that install CLIs (claude/codex) into dirs
|
||||
// these managers own. If the interactive-shell probe somehow fails,
|
||||
// these still let detection succeed.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".npm-global/bin")).unwrap();
|
||||
std::fs::create_dir_all(home.join(".asdf/shims")).unwrap();
|
||||
std::fs::create_dir_all(home.join(".local/share/mise/shims")).unwrap();
|
||||
// fnm installs node under <root>/node-versions/<ver>/installation/bin.
|
||||
std::fs::create_dir_all(home.join(".local/share/fnm/node-versions/v20.11.0/installation/bin")).unwrap();
|
||||
|
||||
let bins = platform_extra_bins_at(Some(home));
|
||||
|
||||
assert!(
|
||||
bins.iter().any(|p| p.ends_with(".npm-global/bin")),
|
||||
"expected ~/.npm-global/bin in {bins:?}"
|
||||
);
|
||||
assert!(
|
||||
bins.iter().any(|p| p.ends_with(".asdf/shims")),
|
||||
"expected ~/.asdf/shims in {bins:?}"
|
||||
);
|
||||
assert!(
|
||||
bins.iter().any(|p| p.ends_with("mise/shims")),
|
||||
"expected mise shims in {bins:?}"
|
||||
);
|
||||
assert!(
|
||||
bins.iter().any(|p| p.ends_with("node-versions/v20.11.0/installation/bin")),
|
||||
"expected fnm node bin in {bins:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_driven_bins_resolves_subdir_specs_and_filters_missing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pnpm = tmp.path().join("pnpm-home");
|
||||
std::fs::create_dir_all(&pnpm).unwrap();
|
||||
let bun_root = tmp.path().join("bun");
|
||||
std::fs::create_dir_all(bun_root.join("bin")).unwrap();
|
||||
|
||||
let pnpm_s = pnpm.to_string_lossy().into_owned();
|
||||
let bun_s = bun_root.to_string_lossy().into_owned();
|
||||
let bins = env_driven_bins(|k| match k {
|
||||
// PNPM_HOME is itself the bin dir (no subdir appended).
|
||||
"PNPM_HOME" => Some(pnpm_s.clone()),
|
||||
// BUN_INSTALL points at a root; the bin dir is <root>/bin.
|
||||
"BUN_INSTALL" => Some(bun_s.clone()),
|
||||
// Points at a non-existent dir: must be filtered out.
|
||||
"VOLTA_HOME" => Some(tmp.path().join("nope").to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert!(bins.contains(&pnpm), "PNPM_HOME (no subdir) should be included: {bins:?}");
|
||||
assert!(
|
||||
bins.contains(&bun_root.join("bin")),
|
||||
"BUN_INSTALL/bin should be included: {bins:?}"
|
||||
);
|
||||
assert!(
|
||||
!bins.iter().any(|p| p.ends_with("nope/bin")),
|
||||
"non-existent VOLTA_HOME/bin must be filtered: {bins:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn login_shell_path_returns_none_without_shell_var() {
|
||||
let _guard = SHELL_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// SAFETY: SHELL_ENV_LOCK serializes SHELL mutations across tests.
|
||||
unsafe {
|
||||
std::env::remove_var("SHELL");
|
||||
}
|
||||
let result = login_shell_path();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn login_shell_path_rejects_relative_shell() {
|
||||
let _guard = SHELL_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// SAFETY: SHELL_ENV_LOCK serializes SHELL mutations across tests.
|
||||
unsafe {
|
||||
std::env::set_var("SHELL", "sh");
|
||||
}
|
||||
let result = login_shell_path();
|
||||
assert!(result.is_none());
|
||||
unsafe {
|
||||
std::env::remove_var("SHELL");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn login_shell_path_roundtrip_with_sh() {
|
||||
let _guard = SHELL_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// SAFETY: SHELL_ENV_LOCK serializes SHELL mutations across tests.
|
||||
unsafe {
|
||||
std::env::set_var("SHELL", "/bin/sh");
|
||||
}
|
||||
let result = login_shell_path();
|
||||
assert!(result.is_some(), "login shell probe should return Some");
|
||||
let path = result.unwrap();
|
||||
assert!(!path.is_empty(), "login shell PATH should not be empty");
|
||||
unsafe {
|
||||
std::env::remove_var("SHELL");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn extract_probe_path_pulls_value_between_markers() {
|
||||
let raw = format!("{PATH_PROBE_BEGIN}/usr/bin:/bin{PATH_PROBE_END}");
|
||||
assert_eq!(extract_probe_path(&raw).as_deref(), Some("/usr/bin:/bin"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn extract_probe_path_ignores_surrounding_shell_noise() {
|
||||
// Interactive startup files (oh-my-zsh banner, nvm notice, p10k
|
||||
// preamble) can print before/after our markers — must be stripped.
|
||||
let raw = format!(
|
||||
"Welcome banner\noh-my-zsh updated\n{PATH_PROBE_BEGIN}/opt/homebrew/bin:/usr/bin{PATH_PROBE_END}\n% "
|
||||
);
|
||||
assert_eq!(
|
||||
extract_probe_path(&raw).as_deref(),
|
||||
Some("/opt/homebrew/bin:/usr/bin")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn extract_probe_path_none_without_markers() {
|
||||
assert_eq!(extract_probe_path("/usr/bin:/bin"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn extract_probe_path_none_when_value_empty() {
|
||||
let raw = format!("{PATH_PROBE_BEGIN}{PATH_PROBE_END}");
|
||||
assert_eq!(extract_probe_path(&raw), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_login_shell_path_sources_interactive_rc() {
|
||||
// Regression test for the "only nomi shows up" bug: a *non*-interactive
|
||||
// login shell (`-l`) does NOT source ~/.zshrc, where most users add
|
||||
// their CLI dirs (nvm/fnm/pnpm/asdf/mise/custom npm prefixes). The
|
||||
// probe must use an *interactive* login shell (`-i -l`) so PATH
|
||||
// entries from ~/.zshrc are visible — otherwise claude/codex go
|
||||
// undetected and only the internal `nomi` agent shows up.
|
||||
let zsh = Path::new("/bin/zsh");
|
||||
if !zsh.exists() {
|
||||
eprintln!("skipping run_login_shell_path_sources_interactive_rc: /bin/zsh absent");
|
||||
return;
|
||||
}
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
let marker = home.path().join("nomimarker-bin");
|
||||
std::fs::create_dir_all(&marker).unwrap();
|
||||
// ~/.zshrc is sourced for INTERACTIVE shells only.
|
||||
std::fs::write(
|
||||
home.path().join(".zshrc"),
|
||||
format!("export PATH=\"{}:$PATH\"\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = run_login_shell_path("/bin/zsh", Some(home.path()))
|
||||
.expect("interactive login shell probe should return a PATH");
|
||||
let marker_str = marker.to_string_lossy();
|
||||
assert!(
|
||||
path.split(':').any(|p| p == marker_str),
|
||||
"expected ~/.zshrc PATH entry {marker_str} in probed PATH, got: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
//! Opinionated wrapper around [`tokio::process::Command`] that centralises
|
||||
//! cross-cutting concerns of child-process spawning across the workspace.
|
||||
//!
|
||||
//! Two construction flavours are provided:
|
||||
//!
|
||||
//! * [`Builder::new`] — for long-running agent CLIs whose stdio is owned
|
||||
//! by the caller (e.g. ACP SDK). Defaults to inherited stdio. Callers
|
||||
//! typically override to `piped()` to capture the streams.
|
||||
//!
|
||||
//! * [`Builder::clean_cli`] — for short-lived CLI tools whose output we
|
||||
//! capture and parse. Defaults to piped stdio plus `NO_COLOR=1` and
|
||||
//! `TERM=dumb` so ANSI escape codes do not leak into the captured
|
||||
//! output.
|
||||
//!
|
||||
//! Both flavours:
|
||||
//! * set `kill_on_drop(true)` so a panicking / erroring caller cannot
|
||||
//! leave orphaned children;
|
||||
//! * remove `NODE_OPTIONS`, `NODE_INSPECT`, `NODE_DEBUG`, `CLAUDECODE`
|
||||
//! so the child doesn't inherit debug/agent state that belongs to the
|
||||
//! parent (matches v1 `acpConnectors.ts::getCleanAgentEnv`).
|
||||
//!
|
||||
//! Enhanced `PATH` (including the bundled bun directory) is handled
|
||||
//! once at process startup by [`crate::enhance_process_path`]; Builder
|
||||
//! does not re-inject it.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::resolver::resolve_command_path;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
|
||||
|
||||
/// Construction mode — determines default stdio + env extras.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Mode {
|
||||
Default,
|
||||
CleanCli,
|
||||
}
|
||||
|
||||
pub struct Builder {
|
||||
inner: Command,
|
||||
mode: Mode,
|
||||
/// Hand-off children (terminal windows, editors opened for the user) are
|
||||
/// expected to OUTLIVE this process: skip the Windows cleanup job and the
|
||||
/// Linux parent-death signal. See [`Builder::hand_off`].
|
||||
hand_off: bool,
|
||||
/// (unix) Extra fds to hand the child at specific target fd numbers, e.g.
|
||||
/// Chrome's `--remote-debugging-pipe` fd3/fd4. Each `(target, source)` is
|
||||
/// installed in a clobber-safe `pre_exec` shuffle at [`Builder::spawn`]; the
|
||||
/// source `OwnedFd`s are kept alive here until spawn forks the child.
|
||||
#[cfg(unix)]
|
||||
extra_fds: Vec<(RawFd, OwnedFd)>,
|
||||
}
|
||||
|
||||
/// Force-kill a spawned child and wait for the direct child handle to exit.
|
||||
///
|
||||
/// On Unix, children spawned through [`Builder::new`] are process-group
|
||||
/// leaders, so this targets that group to clean up descendants as well. On
|
||||
/// Windows, this uses `taskkill /T` to terminate the process tree.
|
||||
pub async fn kill_process_tree(child: &mut Child) -> io::Result<()> {
|
||||
let Some(pid) = child.id() else {
|
||||
return child.kill().await;
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
force_kill_process_tree(pid, Some(pid))?;
|
||||
#[cfg(windows)]
|
||||
kill_windows_process_tree(pid).await?;
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
child.kill().await?;
|
||||
child.wait().await.map(|_| ())
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Builder {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Builder")
|
||||
.field("mode", &self.mode)
|
||||
.field("command", self.inner.as_std())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the configured spawn as a shell-style preview (`cd … && env -u
|
||||
/// X K=V <prog> <args>…`) suitable for logs and error messages. Format
|
||||
/// comes for free from `std::process::Command`'s `Debug` impl.
|
||||
impl std::fmt::Display for Builder {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Debug::fmt(self.inner.as_std(), f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Builder for long-running agent subprocesses (ACP SDK, legacy CLI).
|
||||
///
|
||||
/// Defaults:
|
||||
/// - stdio: inherit (callers typically override with `.stdin(piped())`
|
||||
/// etc. when they need to own the streams)
|
||||
/// - `kill_on_drop(true)`
|
||||
/// - removes `NODE_OPTIONS`, `NODE_INSPECT`, `NODE_DEBUG`, `CLAUDECODE`
|
||||
pub fn new<S: AsRef<OsStr>>(program: S) -> Self {
|
||||
let mut inner = Command::new(resolve_program(program.as_ref()));
|
||||
inner.kill_on_drop(true);
|
||||
configure_platform_spawn(&mut inner);
|
||||
strip_pollution(&mut inner);
|
||||
Self {
|
||||
inner,
|
||||
mode: Mode::Default,
|
||||
hand_off: false,
|
||||
#[cfg(unix)]
|
||||
extra_fds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for short-lived CLI tools whose output we capture.
|
||||
///
|
||||
/// Defaults:
|
||||
/// - stdio: all piped
|
||||
/// - `kill_on_drop(true)`
|
||||
/// - removes `NODE_OPTIONS`, `NODE_INSPECT`, `NODE_DEBUG`, `CLAUDECODE`
|
||||
/// - sets `NO_COLOR=1`, `TERM=dumb`
|
||||
pub fn clean_cli<S: AsRef<OsStr>>(program: S) -> Self {
|
||||
let mut inner = Command::new(resolve_program(program.as_ref()));
|
||||
inner
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.env("NO_COLOR", "1")
|
||||
.env("TERM", "dumb");
|
||||
configure_platform_spawn(&mut inner);
|
||||
strip_pollution(&mut inner);
|
||||
Self {
|
||||
inner,
|
||||
mode: Mode::CleanCli,
|
||||
hand_off: false,
|
||||
#[cfg(unix)]
|
||||
extra_fds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this child as a hand-off: a process launched FOR the user that
|
||||
/// must outlive us (an opened terminal window, an editor, an installer).
|
||||
/// It is excluded from the force-kill safety nets — the Windows cleanup
|
||||
/// job and the Linux PDEATHSIG — which would otherwise terminate it (and
|
||||
/// everything it spawned) the moment this process exits.
|
||||
pub fn hand_off(&mut self) -> &mut Self {
|
||||
self.hand_off = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// (unix) Hand owned fds to the child at specific target fd numbers — e.g.
|
||||
/// Chrome's `--remote-debugging-pipe` reads commands on fd 3 and writes
|
||||
/// responses on fd 4. Each `(target_fd, source)` is installed via a
|
||||
/// clobber-safe `pre_exec` shuffle at [`spawn`](Self::spawn): every source is
|
||||
/// first relocated to a high temp fd (so none sits on a target slot), then
|
||||
/// `dup2`'d onto its target (which clears `FD_CLOEXEC` on the target so it
|
||||
/// survives `exec`). The source `OwnedFd`s are kept alive in the Builder
|
||||
/// until `spawn` forks; the parent's copies are dropped when `spawn` returns.
|
||||
///
|
||||
/// The caller should keep its own (parent-side) ends with `FD_CLOEXEC` set so
|
||||
/// they don't leak into this child or any other spawn.
|
||||
#[cfg(unix)]
|
||||
pub fn inherit_fds(&mut self, mappings: Vec<(RawFd, OwnedFd)>) -> &mut Self {
|
||||
self.extra_fds.extend(mappings);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
|
||||
self.inner.arg(arg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn args<I, S>(&mut self, args: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
self.inner.args(args);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn env<K, V>(&mut self, key: K, val: V) -> &mut Self
|
||||
where
|
||||
K: AsRef<OsStr>,
|
||||
V: AsRef<OsStr>,
|
||||
{
|
||||
self.inner.env(key, val);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: AsRef<OsStr>,
|
||||
V: AsRef<OsStr>,
|
||||
{
|
||||
self.inner.envs(vars);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn env_remove<K: AsRef<OsStr>>(&mut self, key: K) -> &mut Self {
|
||||
self.inner.env_remove(key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {
|
||||
self.inner.current_dir(dir);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stdin<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {
|
||||
self.inner.stdin(cfg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stdout<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {
|
||||
self.inner.stdout(cfg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stderr<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {
|
||||
self.inner.stderr(cfg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Spawn the process and return the standard `tokio::process::Child`.
|
||||
///
|
||||
/// Unless [`hand_off`](Self::hand_off) was set, the child is covered by
|
||||
/// the force-kill safety nets: on Windows it is assigned to the
|
||||
/// process-global cleanup job ([`crate::job`]) — descendants inherit
|
||||
/// membership and the kernel kills the whole tree when this process dies,
|
||||
/// even force-killed (`tauri dev` rebuild, Ctrl+C), where `kill_on_drop`
|
||||
/// never runs. (Descendants the child creates in the brief window before
|
||||
/// the assignment land outside the job — see `crate::job` docs.) On Linux
|
||||
/// the equivalent is PDEATHSIG, installed here. On macOS — which has
|
||||
/// neither — the equivalent is a kqueue `NOTE_EXIT` watcher on the parent
|
||||
/// pid that group-kills the child's pgid on parent death (see
|
||||
/// [`install_macos_pdeath_watch`]).
|
||||
pub fn spawn(mut self) -> io::Result<Child> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if !self.hand_off {
|
||||
install_pdeathsig(&mut self.inner);
|
||||
}
|
||||
// (unix) Install the inherited-fd shuffle (e.g. Chrome's --remote-debugging-pipe
|
||||
// fd3/fd4) before forking. The source OwnedFds stay alive in `self` (dropped when
|
||||
// this fn returns, i.e. after the fork), so they're valid in the child.
|
||||
#[cfg(unix)]
|
||||
if !self.extra_fds.is_empty() {
|
||||
install_fd_shuffle(&mut self.inner, &self.extra_fds);
|
||||
}
|
||||
let child = self.inner.spawn()?;
|
||||
#[cfg(windows)]
|
||||
if !self.hand_off {
|
||||
crate::job::assign_to_cleanup_job(&child);
|
||||
}
|
||||
// macOS has no PDEATHSIG and no Job Object; the equivalent safety net
|
||||
// is a kqueue watcher on the parent pid that group-kills the child on
|
||||
// parent death. Installed only when we have the child's pid (a child
|
||||
// that already exited needs nothing cleaned up).
|
||||
#[cfg(target_os = "macos")]
|
||||
if !self.hand_off {
|
||||
if let Some(pid) = child.id() {
|
||||
install_macos_pdeath_watch(pid);
|
||||
}
|
||||
}
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// Run to completion and collect stdout/stderr.
|
||||
///
|
||||
/// Equivalent to `tokio::process::Command::output` (stdout/stderr forced
|
||||
/// to piped), but routed through [`Self::spawn`] so the Windows cleanup
|
||||
/// job covers these children too.
|
||||
pub async fn output(mut self) -> io::Result<std::process::Output> {
|
||||
self.inner.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let child = self.spawn()?;
|
||||
child.wait_with_output().await
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_pollution(cmd: &mut Command) {
|
||||
cmd.env_remove("NODE_OPTIONS")
|
||||
.env_remove("NODE_INSPECT")
|
||||
.env_remove("NODE_DEBUG")
|
||||
.env_remove("CLAUDECODE");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn configure_platform_spawn(cmd: &mut Command) {
|
||||
// Start each child in its own process group so explicit teardown can
|
||||
// kill the whole subtree (CLI + MCP descendants) in one shot.
|
||||
cmd.process_group(0);
|
||||
}
|
||||
|
||||
/// (unix) Install a clobber-safe `pre_exec` shuffle that places each `(target, source)`
|
||||
/// fd at `target` in the child (e.g. Chrome `--remote-debugging-pipe` fd3/fd4).
|
||||
///
|
||||
/// Algorithm (async-signal-safe): relocate every source to a high temp fd first (so no
|
||||
/// source sits on a target slot), then `dup2` each temp onto its target — `dup2` clears
|
||||
/// `FD_CLOEXEC` on the target, so the target survives `exec` even when the caller created
|
||||
/// the source with `FD_CLOEXEC`. Temps are closed afterward. Reading the captured `maps`
|
||||
/// `Vec` post-fork is safe (it was allocated pre-fork; no allocation happens in the child).
|
||||
#[cfg(unix)]
|
||||
fn install_fd_shuffle(cmd: &mut Command, extra_fds: &[(RawFd, OwnedFd)]) {
|
||||
// Capture (target, source_raw) by value. The OwnedFds stay alive in the Builder
|
||||
// until spawn forks, so source_raw is a valid fd in the forked child.
|
||||
let maps: Vec<(RawFd, RawFd)> = extra_fds.iter().map(|(t, fd)| (*t, fd.as_raw_fd())).collect();
|
||||
// SAFETY: the closure runs post-fork/pre-exec in the child; fcntl/dup2/close are all
|
||||
// async-signal-safe. It only reads the pre-fork-allocated `maps` and uses stack locals.
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
const MAX_FDS: usize = 16;
|
||||
if maps.len() > MAX_FDS {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"too many inherited fds",
|
||||
));
|
||||
}
|
||||
// Phase 1: relocate sources to high temp fds (>= 20), away from target slots.
|
||||
let mut temps = [(0 as RawFd, 0 as RawFd); MAX_FDS]; // (target, temp)
|
||||
let mut base: RawFd = 20;
|
||||
for (i, &(target, source)) in maps.iter().enumerate() {
|
||||
let temp = libc::fcntl(source, libc::F_DUPFD, base);
|
||||
if temp < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
base = temp + 1;
|
||||
temps[i] = (target, temp);
|
||||
}
|
||||
// Phase 2: dup2 temp -> target (clears CLOEXEC on target → survives exec), close temp.
|
||||
for &(target, temp) in temps.iter().take(maps.len()) {
|
||||
if libc::dup2(temp, target) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
libc::close(temp);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: have the KERNEL deliver SIGKILL to the child when this process
|
||||
/// dies without running any cleanup — force-killed by a `tauri dev` rebuild,
|
||||
/// OOM-killed, crashed. kill_on_drop and the explicit group-kill only work
|
||||
/// while our code still runs; this is the no-userland-cleanup safety net
|
||||
/// (the Windows counterpart is the Job Object in `crate::job`). The child's
|
||||
/// own MCP descendants then exit on stdin EOF.
|
||||
///
|
||||
/// PDEATHSIG fires when the spawning THREAD dies, not the process. Every
|
||||
/// Builder spawn happens on a long-lived tokio multi-thread runtime worker
|
||||
/// (audited 2026-06: no spawn_blocking / short-lived-thread call sites),
|
||||
/// where thread death == runtime shutdown == exactly when children must die.
|
||||
/// Do NOT call Builder::spawn from inside `spawn_blocking` or other
|
||||
/// short-lived threads — the child would be killed when that thread is
|
||||
/// reclaimed.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn install_pdeathsig(cmd: &mut Command) {
|
||||
let parent_pid = std::process::id();
|
||||
// SAFETY: the pre_exec closure runs post-fork/pre-exec in the child;
|
||||
// prctl, getppid and raise are all async-signal-safe.
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
// The parent may have died between fork and prctl — the death
|
||||
// signal only fires for deaths AFTER it is installed, so
|
||||
// re-check and self-terminate to close the race.
|
||||
if libc::getppid() != parent_pid as libc::pid_t {
|
||||
libc::raise(libc::SIGKILL);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS parent-death safety net — the analogue of Linux PDEATHSIG and the
|
||||
/// Windows Job Object, neither of which exists here.
|
||||
///
|
||||
/// macOS has no `prctl(PR_SET_PDEATHSIG)` and no kill-on-close Job Object. The
|
||||
/// only thing that survives our process being force-killed (`tauri dev` rebuild,
|
||||
/// crash, OOM — where no userland cleanup of ours runs) is a **separate
|
||||
/// process**. A thread cannot help: it dies with the process on SIGKILL and
|
||||
/// never gets to act (which is exactly why the previous thread-based version was
|
||||
/// a silent no-op on force-kill, verified 2026-06-19). So we fork a tiny
|
||||
/// **watchdog process** that `kqueue`-watches `EVFILT_PROC|NOTE_EXIT` on BOTH the
|
||||
/// parent pid and the child pid:
|
||||
/// - **parent exits first** (any cause incl. SIGKILL) → `kill(-child_pgid,
|
||||
/// SIGKILL)` reaps the child plus its whole group (the child leads its own
|
||||
/// group via `process_group(0)`, so `pgid == child pid`), mirroring the
|
||||
/// Linux/Windows nets;
|
||||
/// - **child exits first** (normal) → the watchdog just exits (nothing to kill),
|
||||
/// so it never lingers.
|
||||
///
|
||||
/// Implementation notes (self-contained — no host re-exec / dispatch wiring):
|
||||
/// - **double-fork + `setsid`**: the intermediate exits immediately (reaped by
|
||||
/// us here), reparenting the watchdog to launchd, which reaps it on exit → no
|
||||
/// zombie;
|
||||
/// - the watchdog body ([`macos_pdeath_watchdog`]) uses **only async-signal-safe
|
||||
/// libc** (no allocation, no tracing, no Rust runtime) — mandatory after
|
||||
/// `fork()` in our multi-threaded tokio process;
|
||||
/// - it **closes every inherited fd ≥ 3** first, so it holds no copy of the
|
||||
/// parent's pipes/sockets — notably Chrome's `--remote-debugging-pipe` command
|
||||
/// pipe, where a stray copy would stop Chrome from seeing EOF and self-exiting;
|
||||
/// - **races closed**: re-check the parent is alive after registration (the
|
||||
/// fork→register window), and re-check the child is alive (`kill(child,0)`)
|
||||
/// right before `kill(-pgid)` so a child that already exited (pgid possibly
|
||||
/// reused) is never mis-killed.
|
||||
///
|
||||
/// Best-effort: any failure degrades to `kill_on_drop` + explicit
|
||||
/// [`kill_process_tree`] (which still cover graceful paths); never fatal to spawn.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn install_macos_pdeath_watch(child_pid: u32) {
|
||||
let parent_pid = std::process::id() as libc::pid_t;
|
||||
let child_pid = child_pid as libc::pid_t;
|
||||
|
||||
// SAFETY: between fork() and _exit the (grand)child calls only async-signal-safe
|
||||
// libc (fork/setsid/kqueue/kevent/kill/close/_exit) — it touches no Rust runtime
|
||||
// state, allocator, locks, or tracing. The parent path only forks and best-effort
|
||||
// reaps the immediately-exiting intermediate.
|
||||
unsafe {
|
||||
let pid1 = libc::fork();
|
||||
if pid1 < 0 {
|
||||
tracing::warn!(
|
||||
error = %io::Error::last_os_error(),
|
||||
"macos pdeath watch: fork() failed; child tree relies on kill_on_drop only"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if pid1 == 0 {
|
||||
// Intermediate: own session, then fork the watchdog and exit so the
|
||||
// watchdog reparents to launchd (which reaps it on exit → no zombie).
|
||||
libc::setsid();
|
||||
let pid2 = libc::fork();
|
||||
if pid2 != 0 {
|
||||
// intermediate (pid2 > 0) or fork failure (pid2 < 0): exit now.
|
||||
libc::_exit(0);
|
||||
}
|
||||
// Grandchild = watchdog. Diverges (ends in _exit).
|
||||
macos_pdeath_watchdog(parent_pid, child_pid);
|
||||
}
|
||||
// Original parent: reap the intermediate (it exits immediately). ECHILD if
|
||||
// tokio's SIGCHLD reaper got it first — harmless.
|
||||
let mut status: libc::c_int = 0;
|
||||
libc::waitpid(pid1, &mut status, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The watchdog loop, run in the double-forked grandchild. **async-signal-safe
|
||||
/// only** — raw libc, no allocation / tracing / Rust runtime. Always ends in `_exit`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Must run post-fork in a dedicated process that does nothing else. Every call
|
||||
/// here is async-signal-safe libc.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_op_in_unsafe_fn)] // whole body is async-signal-safe libc FFI (see # Safety)
|
||||
unsafe fn macos_pdeath_watchdog(parent_pid: libc::pid_t, child_pid: libc::pid_t) -> ! {
|
||||
// 1) Drop every inherited fd ≥ 3 so we hold no copy of the parent's
|
||||
// pipes/sockets (notably Chrome's command pipe — a stray copy would block
|
||||
// Chrome's EOF-driven self-exit). Close BEFORE creating our kqueue.
|
||||
let maxfd = {
|
||||
let m = libc::sysconf(libc::_SC_OPEN_MAX);
|
||||
if m <= 3 || m > 4096 { 4096 } else { m as libc::c_int }
|
||||
};
|
||||
let mut fd = 3;
|
||||
while fd < maxfd {
|
||||
libc::close(fd);
|
||||
fd += 1;
|
||||
}
|
||||
|
||||
let kq = libc::kqueue();
|
||||
if kq < 0 {
|
||||
libc::_exit(11);
|
||||
}
|
||||
|
||||
// 2) Register NOTE_EXIT on parent then child (separate calls so each one's
|
||||
// ESRCH — already dead — is detected on its own).
|
||||
if !register_note_exit(kq, parent_pid) {
|
||||
// Parent already gone (or registration failed): kill the child group if
|
||||
// it's still alive, then exit.
|
||||
if libc::kill(child_pid, 0) == 0 {
|
||||
libc::kill(-child_pid, libc::SIGKILL);
|
||||
}
|
||||
libc::_exit(0);
|
||||
}
|
||||
if !register_note_exit(kq, child_pid) {
|
||||
// Child already gone: nothing to clean up.
|
||||
libc::_exit(0);
|
||||
}
|
||||
|
||||
// 3) Race re-check: the parent could have died between our fork and the
|
||||
// registration above (NOTE_EXIT only fires for deaths AFTER EV_ADD).
|
||||
if libc::kill(parent_pid, 0) != 0 && *libc::__error() == libc::ESRCH {
|
||||
if libc::kill(child_pid, 0) == 0 {
|
||||
libc::kill(-child_pid, libc::SIGKILL);
|
||||
}
|
||||
libc::_exit(0);
|
||||
}
|
||||
|
||||
// 4) Block until the kernel reports the parent OR the child exited.
|
||||
let mut ev = zeroed_kevent();
|
||||
loop {
|
||||
let n = libc::kevent(kq, std::ptr::null(), 0, &mut ev, 1, std::ptr::null());
|
||||
if n < 0 {
|
||||
if *libc::__error() == libc::EINTR {
|
||||
continue; // interrupted → retry
|
||||
}
|
||||
libc::_exit(13); // unexpected → degrade to kill_on_drop
|
||||
}
|
||||
if n >= 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// `ev.ident` read by value (kevent is repr(packed) on Apple; fields are Copy).
|
||||
if ev.ident == parent_pid as libc::uintptr_t {
|
||||
// Parent died → group-kill the child, but only if it's still alive (close
|
||||
// the pgid-reuse window: a dead child's pgid could belong to another group).
|
||||
if libc::kill(child_pid, 0) == 0 {
|
||||
libc::kill(-child_pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
// else: child exited first → nothing to kill.
|
||||
libc::_exit(0);
|
||||
}
|
||||
|
||||
/// Register `EVFILT_PROC|NOTE_EXIT` on `pid`. Returns `false` if the kernel
|
||||
/// rejected it (e.g. the pid is already dead → ESRCH).
|
||||
///
|
||||
/// # Safety
|
||||
/// Async-signal-safe libc only; called from the watchdog grandchild.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_op_in_unsafe_fn)] // whole body is libc FFI (see # Safety)
|
||||
unsafe fn register_note_exit(kq: libc::c_int, pid: libc::pid_t) -> bool {
|
||||
let change = libc::kevent {
|
||||
ident: pid as libc::uintptr_t,
|
||||
filter: libc::EVFILT_PROC,
|
||||
flags: libc::EV_ADD | libc::EV_RECEIPT,
|
||||
fflags: libc::NOTE_EXIT,
|
||||
data: 0,
|
||||
udata: std::ptr::null_mut(),
|
||||
};
|
||||
let mut receipt = zeroed_kevent();
|
||||
let n = libc::kevent(kq, &change, 1, &mut receipt, 1, std::ptr::null());
|
||||
if n < 0 {
|
||||
return false;
|
||||
}
|
||||
// EV_RECEIPT places an EV_ERROR receipt with data==errno (0 == success).
|
||||
// `receipt.flags`/`receipt.data` read by value (packed struct; Copy fields).
|
||||
if n >= 1 && (receipt.flags & libc::EV_ERROR) != 0 && receipt.data != 0 {
|
||||
return false; // e.g. ESRCH: pid already dead.
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A zero-initialised `kevent` (stack-local; no allocation). async-signal-safe.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn zeroed_kevent() -> libc::kevent {
|
||||
libc::kevent {
|
||||
ident: 0,
|
||||
filter: 0,
|
||||
flags: 0,
|
||||
fflags: 0,
|
||||
data: 0,
|
||||
udata: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn configure_platform_spawn(cmd: &mut Command) {
|
||||
// GUI host: keep console-subsystem children (bun/node/git/taskkill/…) from
|
||||
// flashing a console window. CREATE_NO_WINDOW = 0x0800_0000.
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn configure_platform_spawn(_cmd: &mut Command) {}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn force_kill_process_tree(pid: u32, process_group_id: Option<u32>) -> io::Result<()> {
|
||||
if let Some(group_id) = process_group_id.filter(|group_id| *group_id > 1) {
|
||||
let result = unsafe { libc::kill(-(group_id as i32), libc::SIGKILL) };
|
||||
if result == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
return kill_unix_target(pid as i32);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
kill_unix_target(pid as i32)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn kill_unix_target(target: i32) -> io::Result<()> {
|
||||
let result = unsafe { libc::kill(target, libc::SIGKILL) };
|
||||
if result == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn kill_windows_process_tree(pid: u32) -> io::Result<()> {
|
||||
let pid_arg = pid.to_string();
|
||||
let mut cmd = Builder::clean_cli("taskkill");
|
||||
cmd.args(["/F", "/T", "/PID", pid_arg.as_str()]);
|
||||
let output = cmd.output().await?;
|
||||
if output.status.success() || output.status.code() == Some(128) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"taskkill failed for pid {pid} (exit {:?}): {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve `program` through `resolve_command_path` so callers don't have
|
||||
/// to. If the input already contains a path separator (relative or
|
||||
/// absolute) we leave it alone — only bare command names go through
|
||||
/// the resolver, where the bundled-bun shim and Windows `.cmd / .ps1 /
|
||||
/// .bat` fallbacks live.
|
||||
fn resolve_program(program: &OsStr) -> OsString {
|
||||
if let Some(s) = program.to_str()
|
||||
&& !s.is_empty()
|
||||
&& !s.contains('/')
|
||||
&& !s.contains('\\')
|
||||
&& let Some(path) = resolve_command_path(s)
|
||||
{
|
||||
return path.into_os_string();
|
||||
}
|
||||
program.to_os_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Only the unix-only tests below need these.
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::test]
|
||||
async fn clean_cli_captures_stdout_and_strips_env_pollution() {
|
||||
// Set pollution on parent — it must not leak into child.
|
||||
// SAFETY: single-threaded test. Rust 2024 requires unsafe.
|
||||
unsafe {
|
||||
std::env::set_var("NODE_OPTIONS", "--inspect=9229");
|
||||
std::env::set_var("CLAUDECODE", "1");
|
||||
}
|
||||
|
||||
// Ask the child to print NODE_OPTIONS + CLAUDECODE; Builder must
|
||||
// have removed them.
|
||||
let mut b = Builder::clean_cli("sh");
|
||||
b.arg("-c")
|
||||
.arg("echo \"NO:${NODE_OPTIONS:-unset} CC:${CLAUDECODE:-unset}\"");
|
||||
let output = b.output().await.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("NO:unset"), "got: {stdout}");
|
||||
assert!(stdout.contains("CC:unset"), "got: {stdout}");
|
||||
assert!(output.status.success());
|
||||
|
||||
// SAFETY: single-threaded test cleanup.
|
||||
unsafe {
|
||||
std::env::remove_var("NODE_OPTIONS");
|
||||
std::env::remove_var("CLAUDECODE");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clean_cli_sets_no_color_and_term_dumb() {
|
||||
let mut b = Builder::clean_cli("sh");
|
||||
b.arg("-c").arg("echo \"NC:${NO_COLOR:-unset} TERM:${TERM:-unset}\"");
|
||||
let output = b.output().await.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("NC:1"), "got: {stdout}");
|
||||
assert!(stdout.contains("TERM:dumb"), "got: {stdout}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_allows_stdio_override() {
|
||||
// agent() defaults to inherit. Override to piped, then verify
|
||||
// we can capture output.
|
||||
let mut b = Builder::new("sh");
|
||||
b.arg("-c").arg("echo hello").stdout(Stdio::piped());
|
||||
let output = b.output().await.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert_eq!(stdout.trim(), "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_strips_env_pollution() {
|
||||
// SAFETY: single-threaded test.
|
||||
unsafe {
|
||||
std::env::set_var("NODE_INSPECT", "9229");
|
||||
std::env::set_var("NODE_DEBUG", "*");
|
||||
}
|
||||
|
||||
let mut b = Builder::new("sh");
|
||||
b.arg("-c")
|
||||
.arg("echo \"NI:${NODE_INSPECT:-unset} ND:${NODE_DEBUG:-unset}\"")
|
||||
.stdout(Stdio::piped());
|
||||
let output = b.output().await.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("NI:unset"), "got: {stdout}");
|
||||
assert!(stdout.contains("ND:unset"), "got: {stdout}");
|
||||
|
||||
// SAFETY: single-threaded cleanup.
|
||||
unsafe {
|
||||
std::env::remove_var("NODE_INSPECT");
|
||||
std::env::remove_var("NODE_DEBUG");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_returns_child_with_pid() {
|
||||
let mut b = Builder::new("sh");
|
||||
b.arg("-c").arg("sleep 0.05");
|
||||
let mut child = b.spawn().unwrap();
|
||||
assert!(child.id().is_some());
|
||||
let status = child.wait().await.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn spawned_child_is_assigned_to_cleanup_job() {
|
||||
use std::os::windows::io::RawHandle;
|
||||
|
||||
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
|
||||
|
||||
let mut b = Builder::new("powershell");
|
||||
b.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]);
|
||||
let mut child = b.spawn().unwrap();
|
||||
|
||||
let job = crate::job::global_cleanup_job().expect("global cleanup job");
|
||||
let child_handle: RawHandle = child.raw_handle().expect("child handle");
|
||||
let mut in_job = 0;
|
||||
// SAFETY: both handles are live; IsProcessInJob only reads them.
|
||||
let ok = unsafe { IsProcessInJob(child_handle.cast(), job.raw(), &mut in_job) };
|
||||
assert_ne!(ok, 0, "IsProcessInJob should succeed");
|
||||
assert_ne!(in_job, 0, "Builder-spawned child must be inside the cleanup job");
|
||||
|
||||
kill_process_tree(&mut child).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn hand_off_child_stays_out_of_cleanup_job() {
|
||||
use std::os::windows::io::RawHandle;
|
||||
|
||||
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
|
||||
|
||||
let mut b = Builder::new("powershell");
|
||||
b.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]).hand_off();
|
||||
let mut child = b.spawn().unwrap();
|
||||
|
||||
let job = crate::job::global_cleanup_job().expect("global cleanup job");
|
||||
let child_handle: RawHandle = child.raw_handle().expect("child handle");
|
||||
let mut in_job = 0;
|
||||
// SAFETY: both handles are live; IsProcessInJob only reads them.
|
||||
let ok = unsafe { IsProcessInJob(child_handle.cast(), job.raw(), &mut in_job) };
|
||||
assert_ne!(ok, 0, "IsProcessInJob should succeed");
|
||||
assert_eq!(in_job, 0, "hand-off child must NOT be in the cleanup job");
|
||||
|
||||
kill_process_tree(&mut child).await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_renders_shell_style_command() {
|
||||
let mut b = Builder::new("/usr/local/bin/bun");
|
||||
b.current_dir("/tmp/work dir")
|
||||
.env("FOO", "bar baz")
|
||||
.args(["x", "--flag", "with space"]);
|
||||
|
||||
let preview = format!("{b}");
|
||||
|
||||
// The shell-style `cd "..." && env -u X K=V "prog" "args"...` rendering is
|
||||
// produced ONLY by std::process::Command's Debug impl on UNIX. On Windows,
|
||||
// std renders just the quoted program + quoted args (no cd / env -u / K=V).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert!(
|
||||
preview.starts_with(r#"cd "/tmp/work dir" &&"#),
|
||||
"missing cwd prefix: {preview}"
|
||||
);
|
||||
assert!(preview.contains("env "), "expected env section: {preview}");
|
||||
assert!(preview.contains(r#"FOO="bar baz""#), "FOO missing: {preview}");
|
||||
// strip_pollution unsets these
|
||||
assert!(
|
||||
preview.contains("-u NODE_OPTIONS"),
|
||||
"missing -u NODE_OPTIONS: {preview}"
|
||||
);
|
||||
assert!(preview.contains("-u CLAUDECODE"), "missing -u CLAUDECODE: {preview}");
|
||||
}
|
||||
|
||||
// On both platforms the program and each quoted arg appear in the preview.
|
||||
// (On Windows the program is rendered without the surrounding-context
|
||||
// prefix, so assert containment of the program rather than a quoted form.)
|
||||
#[cfg(unix)]
|
||||
assert!(
|
||||
preview.contains(r#""/usr/local/bin/bun""#),
|
||||
"program missing: {preview}"
|
||||
);
|
||||
#[cfg(windows)]
|
||||
assert!(preview.contains("bun"), "program missing: {preview}");
|
||||
|
||||
assert!(preview.contains(r#""--flag""#), "arg --flag missing: {preview}");
|
||||
assert!(preview.contains(r#""with space""#), "arg with space missing: {preview}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !is_pid_alive(pid) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
let result = unsafe { libc::kill(pid as i32, 0) };
|
||||
if result == 0 {
|
||||
return true;
|
||||
}
|
||||
!matches!(io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH))
|
||||
}
|
||||
|
||||
/// macOS parent-death safety net: a child spawned through `Builder`
|
||||
/// installs a kqueue `NOTE_EXIT` watcher on the parent (this process).
|
||||
/// We cannot kill the live test process to observe it, so this test
|
||||
/// exercises the same primitive the watcher fires — group-kill of the
|
||||
/// child's pgid (== leader pid, since `process_group(0)` makes the child
|
||||
/// its own group leader) — and asserts the pgid is reaped. This guards
|
||||
/// the contract the macOS watcher relies on: `kill(-pgid, SIGKILL)`
|
||||
/// tears the spawned subtree down, and `kill(pid, 0)` reports `ESRCH`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn macos_group_kill_reaps_spawned_child_pgid() {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
|
||||
let mut b = Builder::new("sh");
|
||||
b.arg("-c")
|
||||
.arg("sleep 30")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = b.spawn().unwrap();
|
||||
let pid = child.id().expect("spawned child should have a pid");
|
||||
|
||||
assert!(is_pid_alive(pid), "child pid={pid} should be running after spawn");
|
||||
|
||||
// The child is its own process-group leader (process_group(0)), so its
|
||||
// pgid equals its pid. This is exactly the target the kqueue watcher
|
||||
// signals when the parent dies.
|
||||
force_kill_process_tree(pid, Some(pid)).expect("group kill of child pgid should succeed");
|
||||
|
||||
// A SIGKILL'd DIRECT child lingers as a zombie (kill(pid,0) keeps
|
||||
// returning 0) until we wait() on it — a raw liveness poll would spin
|
||||
// until timeout. Reaping returns the terminal status, which is the
|
||||
// stronger assertion: the child was terminated by SIGKILL (delivered via
|
||||
// the negative-pgid group kill), not a normal exit.
|
||||
let status = child.wait().await.expect("wait on group-killed child");
|
||||
assert_eq!(
|
||||
status.signal(),
|
||||
Some(libc::SIGKILL),
|
||||
"child pid={pid} must be terminated by SIGKILL via group kill, got {status:?}",
|
||||
);
|
||||
// After reaping, the pid is truly gone (kill(pid,0) → ESRCH).
|
||||
assert!(!is_pid_alive(pid), "child pid={pid} should be gone after group kill + reap");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_process_tree_uses_cached_group_when_leader_has_exited() {
|
||||
let marker = tempfile::NamedTempFile::new().unwrap();
|
||||
let marker_path = marker.path().to_string_lossy().into_owned();
|
||||
|
||||
let mut builder = Builder::new("sh");
|
||||
builder
|
||||
.args([
|
||||
"-c",
|
||||
"sleep 60 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0",
|
||||
"runtime-cached-group-cleanup",
|
||||
marker_path.as_str(),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let mut child = builder.spawn().unwrap();
|
||||
let leader_pid = child.id().expect("leader pid should exist");
|
||||
let status = child.wait().await.unwrap();
|
||||
assert!(status.success(), "leader should exit before cleanup test");
|
||||
|
||||
let child_pid: u32 = std::fs::read_to_string(marker.path())
|
||||
.expect("background child pid marker should exist")
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("background child pid should be numeric");
|
||||
|
||||
assert!(
|
||||
is_pid_alive(child_pid),
|
||||
"background child pid={child_pid} should still be alive"
|
||||
);
|
||||
|
||||
force_kill_process_tree(leader_pid, Some(leader_pid)).expect("cached group kill should succeed");
|
||||
|
||||
assert!(
|
||||
wait_for_pid_exit(child_pid, Duration::from_secs(5)),
|
||||
"background child pid={child_pid} should exit after cached group kill",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Integration tests for `nomifun_runtime` extraction.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_zstd_blob(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = zstd::stream::write::Encoder::new(&mut out, 0).unwrap();
|
||||
enc.write_all(payload).unwrap();
|
||||
enc.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zstd_roundtrip_produces_matching_bytes() {
|
||||
let payload = b"#!/bin/sh\necho fake-bun\n";
|
||||
let blob = make_zstd_blob(payload);
|
||||
|
||||
let mut dec = zstd::stream::read::Decoder::new(&blob[..]).unwrap();
|
||||
let mut out = Vec::new();
|
||||
std::io::copy(&mut dec, &mut out).unwrap();
|
||||
assert_eq!(out, payload);
|
||||
assert_eq!(sha256_hex(&out).len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_dir_fixture_available() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let p = tmp.path().join("probe");
|
||||
fs::write(&p, b"x").unwrap();
|
||||
assert!(p.is_file());
|
||||
}
|
||||
Reference in New Issue
Block a user