Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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 Objectbased 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 watchdog2026-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 Objectbased 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, &current, 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, &current, 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), &[], &current, 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, &[], &current, 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), &[], &current, 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());
}