diff --git a/crates/brush-core-vendored/src/commands.rs b/crates/brush-core-vendored/src/commands.rs index eef2c3244..1c5e1ce70 100644 --- a/crates/brush-core-vendored/src/commands.rs +++ b/crates/brush-core-vendored/src/commands.rs @@ -632,7 +632,13 @@ pub(crate) fn execute_external_command( match session_action { ChildSessionAction::DetachSession => { // setsid() creates the fresh session + process group; no process_group(). - cmd.detach_session(); + // A reparenting operand (`nohup cmd &`) additionally double-forks so it + // leaves the host's descendant tree and survives the teardown walk. + if context.params.detach_reparent { + cmd.detach_session_reparent(); + } else { + cmd.detach_session(); + } } ChildSessionAction::TakeForeground if command_leads_session => { // Don't set process_group(0) - setsid() in pre_exec will handle it. diff --git a/crates/brush-core-vendored/src/interp.rs b/crates/brush-core-vendored/src/interp.rs index e3aed2309..cd1d98f66 100644 --- a/crates/brush-core-vendored/src/interp.rs +++ b/crates/brush-core-vendored/src/interp.rs @@ -73,6 +73,11 @@ pub struct ExecutionParameters { open_files: openfiles::OpenFiles, /// Policy for how to manage spawned external processes. pub process_group_policy: ProcessGroupPolicy, + /// Whether external commands spawned in this context should reparent out of + /// the shell's descendant tree (double-fork on Unix) so they survive the + /// host's descendant-walk teardown. Set for the operand of a transparent + /// background wrapper such as `nohup cmd &`. + pub detach_reparent: bool, /// Optional cancellation token shared with callers. cancel_token: Option, /// Optional command-output marker hook. @@ -382,7 +387,12 @@ async fn spawn_async_ao_list_as_job<'a, SE: extensions::ShellExtensions>( let direct_pipeline = background_process_pipeline_for_async_job(ao_list, shell, &async_params).await?; - let job = if let Some(pipeline) = direct_pipeline { + let job = if let Some((pipeline, detach_reparent)) = direct_pipeline { + // A transparent background wrapper (e.g. `nohup cmd &`) was unwrapped to its + // operand. Reparent that operand out of the shell's descendant tree so it + // survives the host's descendant-walk teardown — the persistence agents + // reach for `nohup` expecting. + async_params.detach_reparent = detach_reparent; match try_spawn_pipeline_as_job(&pipeline, ao_list.to_string(), shell, &async_params).await? { Some(job) => job, None => spawn_async_ao_list_in_task(ao_list, shell, &async_params), @@ -404,16 +414,22 @@ async fn background_process_pipeline_for_async_job, params: &ExecutionParameters, -) -> Result, error::Error> { +) -> Result, error::Error> { if !ao_list.additional.is_empty() { return Ok(None); } let mut pipeline = ao_list.first.clone(); + let mut detach_reparent = false; for _ in 0..8 { match classify_background_process_pipeline(&pipeline, shell, params).await? { - BackgroundProcessPipeline::Direct => return Ok(Some(pipeline)), - BackgroundProcessPipeline::Wrapper(unwrapped) => pipeline = unwrapped, + BackgroundProcessPipeline::Direct => return Ok(Some((pipeline, detach_reparent))), + BackgroundProcessPipeline::Wrapper(unwrapped) => { + // Unwrapping a transparent background wrapper (`nohup`) means the + // operand should reparent away from the shell when finally spawned. + detach_reparent = true; + pipeline = unwrapped; + }, BackgroundProcessPipeline::Internal => return Ok(None), } } diff --git a/crates/brush-core-vendored/src/sys/stubs/commands.rs b/crates/brush-core-vendored/src/sys/stubs/commands.rs index 50689ee78..527c82659 100644 --- a/crates/brush-core-vendored/src/sys/stubs/commands.rs +++ b/crates/brush-core-vendored/src/sys/stubs/commands.rs @@ -98,10 +98,17 @@ impl CommandFgControlExt for std::process::Command { pub trait CommandSessionExt { /// Arranges for the command to run in a new session with no controlling terminal. fn detach_session(&mut self); + /// Like [`CommandSessionExt::detach_session`]. No-op on platforms without + /// `setsid`/`fork` reparenting. + fn detach_session_reparent(&mut self); } impl CommandSessionExt for std::process::Command { fn detach_session(&mut self) { // NOTE: This is a no-op on platforms without setsid support. } + + fn detach_session_reparent(&mut self) { + // NOTE: This is a no-op on platforms without setsid/fork support. + } } diff --git a/crates/brush-core-vendored/src/sys/unix/commands.rs b/crates/brush-core-vendored/src/sys/unix/commands.rs index 91f4cce4a..46759a3d6 100644 --- a/crates/brush-core-vendored/src/sys/unix/commands.rs +++ b/crates/brush-core-vendored/src/sys/unix/commands.rs @@ -73,6 +73,10 @@ impl CommandFgControlExt for std::process::Command { pub trait CommandSessionExt { /// Arranges for the command to run in a new POSIX session with no controlling terminal. fn detach_session(&mut self); + /// Like [`CommandSessionExt::detach_session`], but additionally double-forks + /// so the spawned process reparents to init (PID 1) and leaves the caller's + /// descendant tree. + fn detach_session_reparent(&mut self); } impl CommandSessionExt for std::process::Command { @@ -84,6 +88,15 @@ impl CommandSessionExt for std::process::Command { self.pre_exec(pre_exec_detach_session); } } + + fn detach_session_reparent(&mut self) { + // SAFETY: + // This arranges for a provided function to run in the forked child before + // exec. Only async-signal-safe calls (`setsid`, `fork`, `_exit`) are used. + unsafe { + self.pre_exec(pre_exec_detach_session_reparent); + } + } } fn pre_exec_take_foreground() -> Result<(), std::io::Error> { @@ -119,3 +132,31 @@ fn pre_exec_detach_session() -> Result<(), std::io::Error> { Err(errno) => Err(std::io::Error::from_raw_os_error(errno as i32)), } } + +fn pre_exec_detach_session_reparent() -> Result<(), std::io::Error> { + // New session first: drop any controlling terminal. Ignore EPERM, which means + // the child is already a session leader from an outer policy. + match nix::unistd::setsid() { + Ok(_) | Err(nix::errno::Errno::EPERM) => {}, + Err(errno) => return Err(std::io::Error::from_raw_os_error(errno as i32)), + } + + // Double-fork: the intermediate child — the pid the parent's spawn machinery + // tracks — exits immediately, so the grandchild that goes on to `exec` the + // operand reparents to init (PID 1) and is no longer a descendant of the + // shell. This is what lets `nohup cmd &` survive the host's descendant-walk + // teardown without relying on an external `setsid(1)` binary. + // + // SAFETY: the post-`fork` child here is single-threaded, and only + // async-signal-safe primitives (`fork`, `_exit`) run before `exec`. + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(std::io::Error::last_os_error()); + } + if pid > 0 { + // Intermediate parent: exit now to orphan the grandchild. `_exit` avoids + // running atexit handlers or flushing inherited buffers in the fork. + unsafe { libc::_exit(0) }; + } + Ok(()) +} diff --git a/crates/brush-core-vendored/src/sys/windows/commands.rs b/crates/brush-core-vendored/src/sys/windows/commands.rs index c22d0a3a3..54ec635c9 100644 --- a/crates/brush-core-vendored/src/sys/windows/commands.rs +++ b/crates/brush-core-vendored/src/sys/windows/commands.rs @@ -114,10 +114,18 @@ pub trait CommandSessionExt { /// terminal. On Windows this is a no-op; process-group and console behavior /// are handled uniformly by `sys::process::spawn`. fn detach_session(&mut self); + /// Like [`CommandSessionExt::detach_session`]. On Windows there is no session + /// or `fork`-based reparenting, so this is a no-op: the operand stays a child + /// of the shell. + fn detach_session_reparent(&mut self); } impl CommandSessionExt for std::process::Command { fn detach_session(&mut self) { // NOTE: Windows has no setsid; intentionally a no-op. } + + fn detach_session_reparent(&mut self) { + // NOTE: no reparenting primitive on Windows; intentionally a no-op. + } } diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index ad3ffcf70..2e0be99b6 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -496,39 +496,6 @@ fn normalize_path_segment(segment: &str) -> String { normalized.to_string_lossy().to_ascii_lowercase() } -/// Check if a command is resolvable in the given PATH string. -/// Returns true if the command exists and is executable in one of the PATH -/// directories. -fn command_is_resolvable(command: &str, path: &str) -> bool { - for dir in std::env::split_paths(path) { - let full_path = dir.join(command); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if full_path.exists() { - if let Ok(metadata) = full_path.metadata() { - let permissions = metadata.permissions(); - if permissions.mode() & 0o111 != 0 { - return true; - } - } - } - } - #[cfg(windows)] - { - if full_path.exists() { - // On Windows, .exe/.bat/.cmd extensions are automatically tried - for ext in ["", ".exe", ".bat", ".cmd"] { - let with_ext = dir.join(format!("{}{}", command, ext)); - if with_ext.exists() { - return true; - } - } - } - } - } - false -} #[cfg(not(windows))] fn merge_path_values(_existing: &str, incoming: &str) -> String { incoming.to_string() @@ -1966,13 +1933,16 @@ impl builtins::Command for NohupCommand { return Ok(ExecutionResult::new(125)); } - // Detach the operand into a new session / process group (like `setsid`) - // so a backgrounded server survives this embedded shell's kill-on-drop - // teardown, which SIGKILLs the shell's own process group when the host - // process exits. Agents reach for `nohup &` expecting exactly - // this persistence; a real coreutils `nohup` would NOT help, since it - // stays in the shell's process group and dies with it. The new session - // is applied below via ProcessGroupPolicy::NewProcessGroup. + // `nohup ` (foreground) runs the operand directly and surfaces its + // exit status — the contract pinned by + // `nohup_builtin_propagates_command_exit_code`. Persistence across the + // host's teardown is a *background* concern that never reaches this + // builtin: the agent writes `nohup &`, and brush's + // `transparent_background_wrapper` unwraps that to spawn the operand + // directly with `detach_reparent`, double-forking it out of the shell's + // descendant tree (see `execute_external_command` / `detach_session_reparent`). + // Like coreutils, we run the operand here; we only differ by not masking + // SIGHUP (see `nohup_builtin_does_not_mask_sighup`). let mut command_line = String::new(); for (idx, arg) in command.iter().enumerate() { if idx > 0 { diff --git a/packages/coding-agent/scripts/build-binary.ts b/packages/coding-agent/scripts/build-binary.ts index a4cfabeb1..b90498214 100644 --- a/packages/coding-agent/scripts/build-binary.ts +++ b/packages/coding-agent/scripts/build-binary.ts @@ -53,7 +53,9 @@ async function main(): Promise { await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]); await runCommand( ["bun", "--cwd=../natives", "run", "embed:native"], - crossTarget ? { ...Bun.env, TARGET_PLATFORM: crossPlatform as string, TARGET_ARCH: crossArch as string } : Bun.env, + crossTarget + ? { ...Bun.env, TARGET_PLATFORM: crossPlatform as string, TARGET_ARCH: crossArch as string } + : Bun.env, ); await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]); try {