feat: implemented reparenting for backgrounded wrappers
- Introduce `detach_reparent` parameter to command execution to support process reparenting. - Add `detach_session_reparent` to Unix command extensions using a double-fork technique to orphan processes from the shell descendant tree. - Update background pipeline logic to automatically apply reparenting when unwrapping transparent wrappers like `nohup`. - Remove unused `command_is_resolvable` helper.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<CancellationToken>,
|
||||
/// 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<SE: extensions::ShellExtensio
|
||||
ao_list: &ast::AndOrList,
|
||||
shell: &mut Shell<SE>,
|
||||
params: &ExecutionParameters,
|
||||
) -> Result<Option<ast::Pipeline>, error::Error> {
|
||||
) -> Result<Option<(ast::Pipeline, bool)>, 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <server> &` 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 <cmd>` (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 <server> &`, 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 {
|
||||
|
||||
@@ -53,7 +53,9 @@ async function main(): Promise<void> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user