diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml index 80086c8..59ec44b 100644 --- a/.github/workflows/linux-ci.yml +++ b/.github/workflows/linux-ci.yml @@ -11,6 +11,7 @@ permissions: jobs: rust: + name: Linux uses: ./.github/workflows/rust-ci-reusable.yml with: runner: ubuntu-latest diff --git a/.github/workflows/macos-ci.yml b/.github/workflows/macos-ci.yml index 4442461..e68e5da 100644 --- a/.github/workflows/macos-ci.yml +++ b/.github/workflows/macos-ci.yml @@ -11,6 +11,7 @@ permissions: jobs: rust: + name: macOS uses: ./.github/workflows/rust-ci-reusable.yml with: runner: macos-latest diff --git a/.github/workflows/release-reusable.yml b/.github/workflows/release-reusable.yml index 6344786..7f0e61c 100644 --- a/.github/workflows/release-reusable.yml +++ b/.github/workflows/release-reusable.yml @@ -45,7 +45,7 @@ jobs: - name: Cache cargo artifacts uses: Swatinem/rust-cache@v2 - - name: Build release binary + - name: Build release binaries run: cargo build --release --target ${{ inputs.target }} - name: Package release archive @@ -54,7 +54,8 @@ jobs: set -euo pipefail mkdir -p dist cp "target/${{ inputs.target }}/release/devloop" dist/devloop - tar -C dist -czf "${{ inputs.archive_name }}" devloop + cp "target/${{ inputs.target }}/release/devloop-process-guardian" dist/devloop-process-guardian + tar -C dist -czf "${{ inputs.archive_name }}" devloop devloop-process-guardian - name: Extract release notes from changelog if: inputs.publish_release_notes diff --git a/CHANGELOG.md b/CHANGELOG.md index a42493f..209a5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.10.1] - 2026-08-26 + +### Changed + +- Gave Linux and macOS CI distinct required-check names so `main` + protection can require both platforms without an ambiguous status. + +### Fixed + +- Guard every managed process and hook with a pinned Rust companion and + parent-death channel, so an abrupt `devloop` exit kills children, + grandchildren, and deeper descendants that remain in the command's + process group. The companion has a distinct process identity and + stays consistent across in-place installation updates. + ## [0.10.0] - 2026-07-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index a837fbc..d2cf5aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,12 +235,13 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.10.0" +version = "0.10.1" dependencies = [ "anyhow", "axum", "clap", "globset", + "libc", "notify", "pulldown-cmark", "rand", diff --git a/Cargo.toml b/Cargo.toml index af1edfc..ea93b67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.10.0" +version = "0.10.1" edition = "2024" [dependencies] @@ -8,6 +8,7 @@ anyhow = "1.0.98" axum = { version = "0.8.6", features = ["json", "tokio", "http1"] } clap = { version = "4.5.39", features = ["derive"] } globset = "0.4.16" +libc = "0.2.177" notify = "8.0.0" pulldown-cmark = "0.13.0" rand = "0.9.2" @@ -23,5 +24,8 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] } unicode-width = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +tempfile = "3.20.0" + [dev-dependencies] tempfile = "3.20.0" diff --git a/README.md b/README.md index 27defb3..b71ffdb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Each supported platform publishes its release asset independently, so a failure on one platform does not block the other asset from being attached to the GitHub release. +Each archive contains `devloop` and `devloop-process-guardian`. Install +both executables in the same directory. `cargo install` installs the +pair together. + Supported prebuilt release targets: - `x86_64-unknown-linux-gnu` diff --git a/docs/behavior.md b/docs/behavior.md index 8b192c3..b0e2794 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -99,9 +99,23 @@ Managed processes are long-running child commands. - `start_process` is a no-op if the named process is already running. - `restart_process` stops the child, then starts it again. -- Managed processes are started in their own Unix process group, and - stop/restart/shutdown terminates that group so descendant processes do - not survive the supervisor. +- Every external command, including managed processes and hooks, is + launched in its own Unix process group through an internal Rust + companion process. At run startup, `devloop` opens and retains the + exact companion image, so an in-place installation update cannot + change the guardian protocol for later hooks or restarts. The guardian + remains outside the target group, ignores terminal-oriented signals, + and watches a private lifetime channel owned by `devloop`. Managed + targets restore ordinary signal handling before they start. Normal + stop/restart/shutdown terminates the target group, and abrupt + `devloop` disappearance closes the channel so the guardian kills the + group and reaps its direct target. + Children, grandchildren, and deeper descendants are covered while + they remain in the inherited process group. +- A descendant that deliberately creates a new session or process group + escapes portable Unix process-group containment. Such commands must + provide their own shutdown integration instead of daemonizing beneath + `devloop`. - `wait_for_process` waits on the configured readiness probe, not just on successful spawning. - `restart = "always"` restarts a child after any exit unless @@ -135,6 +149,8 @@ the process is restarted. Hooks are one-shot commands executed inside workflows. - Hooks run to completion before the workflow continues. +- Hooks use the same guarded process-group lifecycle as managed + processes, including cleanup after abrupt `devloop` termination. - Hook stdout and stderr are captured fully, then rendered with a source label if `hook..output.inherit` is enabled. - Hook output defaults to `body_style = "dim"` so helper-command output diff --git a/scripts/ci-smoke.sh b/scripts/ci-smoke.sh index 3f4de43..e858890 100755 --- a/scripts/ci-smoke.sh +++ b/scripts/ci-smoke.sh @@ -69,9 +69,7 @@ chmod +x "${tmp_dir}/scripts/emit-ready.sh" state_path="${tmp_dir}/.devloop/state.json" devloop_bin="${repo_root}/target/debug/devloop" -if [[ ! -x "${devloop_bin}" ]]; then - (cd "${repo_root}" && cargo build >/dev/null) -fi +(cd "${repo_root}" && cargo build --bins >/dev/null) "${devloop_bin}" run --config "${tmp_dir}/devloop.toml" >"${log_path}" 2>&1 & devloop_pid=$! diff --git a/src/bin/devloop-process-guardian.rs b/src/bin/devloop-process-guardian.rs new file mode 100644 index 0000000..a58f461 --- /dev/null +++ b/src/bin/devloop-process-guardian.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +fn main() -> Result<()> { + devloop::process_guardian::run_and_exit(std::env::args_os().skip(1).collect()) +} diff --git a/src/engine.rs b/src/engine.rs index 9df3380..762eff6 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -23,10 +23,12 @@ use crate::external_events::{ExternalEventMessage, ExternalEventServer}; use crate::processes::ProcessManager; use crate::session_log::SessionLog; use crate::state::SessionState; +use devloop::process_guardian::GuardianExecutable; pub struct Engine { config: Config, session_log: SessionLog, + guardian_executable: GuardianExecutable, } trait WorkflowEffectAdapter { @@ -81,10 +83,15 @@ struct LiveRuntimeAdapter<'a, 'b> { } impl Engine { - pub fn new(config: Config, session_log: SessionLog) -> Self { + pub fn new( + config: Config, + session_log: SessionLog, + guardian_executable: GuardianExecutable, + ) -> Self { Self { config, session_log, + guardian_executable, } } @@ -95,8 +102,8 @@ impl Engine { .clone() .ok_or_else(|| anyhow!("state file missing after config load"))?, )?; - let mut processes = - ProcessManager::new(&self.config).with_session_log(self.session_log.clone()); + let mut processes = ProcessManager::new(&self.config, self.guardian_executable) + .with_session_log(self.session_log.clone()); let watch_groups = self.config.compiled_watchers()?; let watched_targets = self.config.compiled_watch_targets(); let ignored_watch_paths = vec![self.session_log.path().to_path_buf()]; @@ -1028,7 +1035,10 @@ mod tests { }, ); - let mut processes = ProcessManager::new(&config); + let mut processes = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); run_workflow(&config, &mut processes, &state, None, "compose", &[]) .await .expect("run workflow"); @@ -1093,7 +1103,10 @@ mod tests { }, ); - let mut processes = ProcessManager::new(&config); + let mut processes = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); run_workflow(&config, &mut processes, &state, None, "content", &[]) .await .expect("run workflow"); @@ -1153,7 +1166,10 @@ mod tests { }, ); - let mut processes = ProcessManager::new(&config); + let mut processes = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); run_workflow(&config, &mut processes, &state, None, "announce", &[]) .await .expect("run workflow"); @@ -1941,7 +1957,10 @@ mod tests { }; let state_path = unique_state_path(); let state = SessionState::load(state_path.clone()).expect("load state"); - let mut processes = ProcessManager::new(&config); + let mut processes = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); let error = run_workflow(&config, &mut processes, &state, None, "missing", &[]) .await diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fd6417e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod process_guardian; diff --git a/src/main.rs b/src/main.rs index 9d8dbcf..518509d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,7 @@ async fn main() -> Result<()> { config.validate()?; } Command::Run { config } => { + let guardian_executable = devloop::process_guardian::GuardianExecutable::open()?; let config = resolve_config_path(config)?; let config = Config::load(&config)?; config.validate()?; @@ -97,7 +98,10 @@ async fn main() -> Result<()> { let session_log = SessionLog::create(state_file)?; init_logging(Some(session_log.clone())); announce_session_log_path(&session_log).await?; - if let Err(error) = Engine::new(config, session_log.clone()).run().await { + if let Err(error) = Engine::new(config, session_log.clone(), guardian_executable) + .run() + .await + { error!(error = %format!("{error:#}"), "devloop run failed"); flush_session_log_before_exit(&session_log).await; return Err(error); diff --git a/src/process_guardian.rs b/src/process_guardian.rs new file mode 100644 index 0000000..97dc827 --- /dev/null +++ b/src/process_guardian.rs @@ -0,0 +1,440 @@ +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +#[cfg(target_os = "macos")] +use std::os::unix::fs::{FileExt, PermissionsExt}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use std::sync::Arc; +use std::thread; + +use anyhow::{Context, Result, anyhow}; +use rustix::io::Errno; +use rustix::process::{Pid, Signal, kill_process_group}; + +pub const CONTROL_FD: i32 = 3; +pub const EXECUTABLE_FD_MINIMUM: i32 = 10; +const STARTED_MESSAGE: u8 = 1; +const START_FAILED_MESSAGE: u8 = 2; +const MAX_START_ERROR_BYTES: usize = 16 * 1024; + +/// Holds the exact companion image used for every guardian in one devloop run. +/// +/// Keeping the file open pins its inode across path replacement. Linux executes +/// a duplicated descriptor directly. macOS materializes the pinned bytes in a +/// private temporary executable kept only until spawn completes. Later hooks +/// and restarts therefore cannot switch guardian protocols mid-run. +#[derive(Clone)] +pub struct GuardianExecutable { + image: Arc, +} + +/// Keeps a prepared guardian image alive until `Command::spawn` completes. +pub struct GuardianInvocation { + path: PathBuf, + inherited_image: Option, + #[cfg(target_os = "macos")] + _temporary_image: Option, +} + +impl GuardianInvocation { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn inherited_image_fd(&self) -> Option { + self.inherited_image.as_ref().map(AsRawFd::as_raw_fd) + } +} + +impl GuardianExecutable { + pub fn open() -> Result { + let devloop = std::env::current_exe().context("failed to resolve devloop executable")?; + let directory = executable_directory(&devloop)?; + let path = directory.join(format!( + "devloop-process-guardian{}", + std::env::consts::EXE_SUFFIX + )); + Self::open_path(&path) + } + + fn open_path(path: &Path) -> Result { + let image = File::open(path) + .with_context(|| format!("failed to open process guardian at {}", path.display()))?; + Ok(Self { + image: Arc::new(image), + }) + } + + pub fn prepare_invocation(&self) -> Result { + #[cfg(target_os = "macos")] + { + let temporary_image = self.copy_to_temporary_executable()?; + Ok(GuardianInvocation { + path: temporary_image.to_path_buf(), + inherited_image: None, + _temporary_image: Some(temporary_image), + }) + } + + #[cfg(not(target_os = "macos"))] + self.prepare_descriptor_invocation() + } + + #[cfg(not(target_os = "macos"))] + fn prepare_descriptor_invocation(&self) -> Result { + // SAFETY: fcntl duplicates the live companion descriptor without + // borrowing memory. The returned descriptor is immediately owned. + let raw_fd = unsafe { + libc::fcntl( + self.image.as_raw_fd(), + libc::F_DUPFD_CLOEXEC, + EXECUTABLE_FD_MINIMUM, + ) + }; + if raw_fd == -1 { + return Err(io::Error::last_os_error()) + .context("failed to duplicate process guardian executable"); + } + // SAFETY: F_DUPFD_CLOEXEC returned a new descriptor owned by this call. + let descriptor = unsafe { OwnedFd::from_raw_fd(raw_fd) }; + #[cfg(target_os = "linux")] + let path = PathBuf::from(format!("/proc/self/fd/{raw_fd}")); + #[cfg(not(target_os = "linux"))] + let path = PathBuf::from(format!("/dev/fd/{raw_fd}")); + Ok(GuardianInvocation { + path, + inherited_image: Some(descriptor), + }) + } + + #[cfg(target_os = "macos")] + fn copy_to_temporary_executable(&self) -> Result { + let mut temporary = tempfile::Builder::new() + .prefix("devloop-process-guardian-") + .tempfile() + .context("failed to create temporary process guardian executable")?; + let mut offset = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = self + .image + .read_at(&mut buffer, offset) + .context("failed to read pinned process guardian executable")?; + if count == 0 { + break; + } + temporary + .write_all(&buffer[..count]) + .context("failed to copy pinned process guardian executable")?; + offset += count as u64; + } + temporary + .flush() + .context("failed to flush temporary process guardian executable")?; + temporary + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .context("failed to make temporary process guardian executable")?; + Ok(temporary.into_temp_path()) + } +} + +fn executable_directory(executable: &Path) -> Result<&Path> { + let parent = executable + .parent() + .ok_or_else(|| anyhow!("devloop executable has no parent directory"))?; + if parent.file_name().is_some_and(|name| name == "deps") { + return parent + .parent() + .ok_or_else(|| anyhow!("Cargo test executable has no target profile directory")); + } + Ok(parent) +} + +pub fn append_invocation(command: &mut tokio::process::Command, target: &std::process::Command) { + command.arg(target.get_program()).args(target.get_args()); +} + +pub fn receive_process_group(control: &mut UnixStream) -> Result { + let mut message = [0_u8; 1]; + control.read_exact(&mut message)?; + if message[0] == START_FAILED_MESSAGE { + return Err(anyhow!(receive_start_error(control)?)); + } + if message[0] != STARTED_MESSAGE { + return Err(anyhow!( + "guardian announced unknown startup message {}", + message[0] + )); + } + let mut encoded = [0_u8; size_of::()]; + control.read_exact(&mut encoded)?; + let raw_pid = u32::from_ne_bytes(encoded); + Ok(Pid::from_raw(raw_pid as i32).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("guardian announced invalid process group id {raw_pid}"), + ) + })?) +} + +/// Runs one target process group until the target exits or devloop disappears. +pub fn run_and_exit(command: Vec) -> Result<()> { + ignore_guardian_signals()?; + let status = run(command)?; + std::process::exit(exit_code(status)); +} + +fn run(command: Vec) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| anyhow!("internal process guardian requires a target command"))?; + let mut control = take_control_stream()?; + set_close_on_exec(control.as_raw_fd())?; + + let mut target = Command::new(program); + target.args(args); + target.process_group(0); + // SAFETY: the guardian ignores terminal-oriented signals so bulk shutdown + // cannot remove the reaper. The target must restore ordinary dispositions + // before exec rather than inheriting those guardian-only semantics. + unsafe { + target.pre_exec(restore_target_signals); + } + let mut child = match target.spawn() { + Ok(child) => child, + Err(source) => { + let error = + anyhow!(source).context(format!("failed to start guarded command {program:?}")); + announce_start_error(&mut control, &format!("{error:#}"))?; + return Err(error); + } + }; + let process_group = Pid::from_raw(child.id() as i32) + .ok_or_else(|| anyhow!("guarded command has invalid process group id"))?; + let monitor_control = match control.try_clone() { + Ok(control) => control, + Err(error) => { + terminate_after_guardian_failure(&mut child, process_group); + return Err(error).context("failed to clone guardian control socket"); + } + }; + let monitor = match thread::Builder::new() + .name("devloop-guardian".into()) + .spawn(move || monitor_control_channel(monitor_control, process_group)) + { + Ok(monitor) => monitor, + Err(error) => { + terminate_after_guardian_failure(&mut child, process_group); + return Err(error).context("failed to start guardian control monitor"); + } + }; + + let announcement = control + .write_all(&[STARTED_MESSAGE]) + .and_then(|()| control.write_all(&child.id().to_ne_bytes())) + .context("failed to announce guarded process group"); + if announcement.is_err() { + let _ = control.shutdown(std::net::Shutdown::Both); + } + let status = child.wait().context("failed to wait for guarded command"); + let _ = control.shutdown(std::net::Shutdown::Both); + let monitor_result = monitor + .join() + .map_err(|_| anyhow!("guardian control monitor panicked"))?; + announcement?; + monitor_result?; + status +} + +fn ignore_guardian_signals() -> Result<()> { + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGQUIT] { + set_signal_disposition(signal, libc::SIG_IGN)?; + } + Ok(()) +} + +fn restore_target_signals() -> io::Result<()> { + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGQUIT] { + // SAFETY: pre-exec signal restoration uses only libc::signal and a + // constant disposition; it does not allocate or retain Rust memory. + if unsafe { libc::signal(signal, libc::SIG_DFL) } == libc::SIG_ERR { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +fn set_signal_disposition(signal: i32, disposition: libc::sighandler_t) -> Result<()> { + // SAFETY: signal installs a constant disposition for one supported signal; + // no Rust callback or borrowed memory crosses the FFI boundary. + if unsafe { libc::signal(signal, disposition) } == libc::SIG_ERR { + return Err(io::Error::last_os_error()) + .with_context(|| format!("failed to set guardian signal disposition for {signal}")); + } + Ok(()) +} + +fn announce_start_error(control: &mut UnixStream, message: &str) -> Result<()> { + let bytes = message.as_bytes(); + let bytes = &bytes[..bytes.len().min(MAX_START_ERROR_BYTES)]; + control + .write_all(&[START_FAILED_MESSAGE]) + .and_then(|()| control.write_all(&(bytes.len() as u32).to_ne_bytes())) + .and_then(|()| control.write_all(bytes)) + .context("failed to announce guarded command startup failure") +} + +fn receive_start_error(control: &mut UnixStream) -> io::Result { + let mut encoded_length = [0_u8; size_of::()]; + control.read_exact(&mut encoded_length)?; + let length = u32::from_ne_bytes(encoded_length) as usize; + if length > MAX_START_ERROR_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("guardian startup error exceeds {MAX_START_ERROR_BYTES} bytes"), + )); + } + let mut message = vec![0_u8; length]; + control.read_exact(&mut message)?; + Ok(String::from_utf8_lossy(&message).into_owned()) +} + +fn take_control_stream() -> Result { + let mut socket_type = 0; + let mut length = size_of::() as libc::socklen_t; + // SAFETY: getsockopt only inspects CONTROL_FD and writes within the provided + // c_int buffer. It validates the descriptor before ownership is assumed. + let result = unsafe { + libc::getsockopt( + CONTROL_FD, + libc::SOL_SOCKET, + libc::SO_TYPE, + std::ptr::addr_of_mut!(socket_type).cast(), + &mut length, + ) + }; + if result == -1 { + return Err(io::Error::last_os_error()).context("guardian control descriptor is invalid"); + } + if socket_type != libc::SOCK_STREAM { + return Err(anyhow!( + "guardian control descriptor is not a stream socket" + )); + } + // SAFETY: devloop maps one owned Unix stream socket to CONTROL_FD before + // exec. The successful SO_TYPE check above establishes descriptor validity. + Ok(unsafe { UnixStream::from_raw_fd(CONTROL_FD) }) +} + +fn terminate_after_guardian_failure(child: &mut std::process::Child, process_group: Pid) { + let _ = signal_group(process_group, Signal::KILL); + let _ = child.wait(); +} + +fn monitor_control_channel(mut control: UnixStream, process_group: Pid) -> Result<()> { + let mut unexpected = [0_u8; 1]; + loop { + match control.read(&mut unexpected) { + Ok(0) => { + signal_group(process_group, Signal::KILL)?; + return Ok(()); + } + Ok(_) => { + signal_group(process_group, Signal::KILL)?; + return Err(anyhow!("guardian control channel received unexpected data")); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => { + signal_group(process_group, Signal::KILL)?; + return Ok(()); + } + } + } +} + +fn signal_group(process_group: Pid, signal: Signal) -> Result<()> { + match kill_process_group(process_group, signal) { + Ok(()) | Err(Errno::SRCH) => Ok(()), + Err(error) => Err(anyhow!( + "failed to send {signal:?} to guarded process group: {error}" + )), + } +} + +fn set_close_on_exec(fd: i32) -> Result<()> { + // SAFETY: fd is the live control socket owned by this process. fcntl does + // not retain the pointer-free arguments and is safe before spawning target. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags == -1 || libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) == -1 { + return Err(io::Error::last_os_error()) + .context("failed to protect guardian control socket from target inheritance"); + } + } + Ok(()) +} + +fn exit_code(status: ExitStatus) -> i32 { + status + .code() + .or_else(|| status.signal().map(|signal| 128 + signal)) + .unwrap_or(1) + .clamp(0, 255) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::CommandExt; + + #[test] + fn exit_code_preserves_success() { + let status = Command::new("/bin/sh") + .args(["-c", "exit 0"]) + .status() + .expect("run status fixture"); + assert_eq!(exit_code(status), 0); + } + + #[test] + fn pinned_executable_keeps_original_image_after_path_replacement() { + let directory = tempfile::tempdir().expect("create executable fixture directory"); + let path = directory.path().join("guardian"); + let replacement = directory.path().join("replacement"); + std::fs::copy("/usr/bin/true", &path).expect("copy original executable"); + std::fs::copy("/usr/bin/false", &replacement).expect("copy replacement executable"); + let executable = GuardianExecutable::open_path(&path).expect("pin original executable"); + std::fs::rename(&replacement, &path).expect("replace executable path"); + let invocation = executable + .prepare_invocation() + .expect("prepare pinned executable"); + let inherited_image_fd = invocation.inherited_image_fd(); + let mut command = Command::new(invocation.path()); + // SAFETY: the closure changes only the close-on-exec flag of the owned + // fixture descriptor before exec. + unsafe { + command.pre_exec(move || { + if let Some(descriptor_fd) = inherited_image_fd { + let flags = libc::fcntl(descriptor_fd, libc::F_GETFD); + if flags == -1 + || libc::fcntl(descriptor_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) + == -1 + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + }); + } + + let status = command.status().expect("execute pinned original inode"); + drop(invocation); + + assert!(status.success()); + } +} diff --git a/src/processes.rs b/src/processes.rs index 1028f8f..179e108 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -1,4 +1,6 @@ use std::collections::{BTreeMap, VecDeque}; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, Mutex as StdMutex}; @@ -28,6 +30,7 @@ use crate::output::{ }; use crate::session_log::SessionLog; use crate::state::SessionState; +use devloop::process_guardian::GuardianExecutable; pub struct ProcessManager<'a> { config: &'a Config, @@ -43,12 +46,23 @@ pub struct ProcessManager<'a> { external_event_env: Option, browser_reload_env: Option, session_log: Option, + guardian_executable: GuardianExecutable, } struct ManagedProcess { + guarded: GuardedProcess, + output_tasks: Vec, +} + +/// Owns every external command behind one process-containment boundary. +/// +/// The control socket remains open for exactly as long as devloop owns the +/// command. If devloop disappears, socket EOF makes the guardian kill the +/// complete process group without relying on supervisor shutdown code. +struct GuardedProcess { child: Child, process_group: Pid, - output_tasks: Vec, + _lifetime: UnixStream, } struct OutputTask { @@ -65,6 +79,8 @@ const OUTPUT_DRAIN_TOTAL_TIMEOUT: Duration = Duration::from_secs(30); const OUTPUT_DRAIN_ABORT_TIMEOUT: Duration = Duration::from_secs(1); const TERMINAL_OUTPUT_QUEUE_CAPACITY: usize = 256; const SESSION_LOG_FLUSH_TIMEOUT: Duration = Duration::from_secs(5); +const PROCESS_STOP_TIMEOUT: Duration = Duration::from_secs(2); +const GUARDIAN_REAP_TIMEOUT: Duration = Duration::from_secs(2); struct CommandContext<'a> { env: &'a BTreeMap, @@ -77,7 +93,7 @@ struct CommandContext<'a> { } impl<'a> ProcessManager<'a> { - pub fn new(config: &'a Config) -> Self { + pub fn new(config: &'a Config, guardian_executable: GuardianExecutable) -> Self { Self { config, children: BTreeMap::new(), @@ -92,6 +108,7 @@ impl<'a> ProcessManager<'a> { external_event_env: None, browser_reload_env: None, session_log: None, + guardian_executable, } } @@ -131,7 +148,7 @@ impl<'a> ProcessManager<'a> { let Some(mut child) = self.children.remove(name) else { return Ok(()); }; - terminate_child(name, &mut child.child, child.process_group).await?; + terminate_child(name, &mut child.guarded.child, child.guarded.process_group).await?; self.supervisor.on_process_stopped(name); if let Err(error) = wait_for_output_tasks(name, child.output_tasks, self.session_log.clone()).await @@ -173,7 +190,7 @@ impl<'a> ProcessManager<'a> { .hook .get(name) .ok_or_else(|| anyhow!("unknown hook '{name}'"))?; - let mut command = configure_command( + let command = configure_command( &spec.command, resolve_cwd(&self.config.root, spec.cwd.as_deref()), CommandContext { @@ -187,17 +204,15 @@ impl<'a> ProcessManager<'a> { }, )?; let source_label = process_output_source_label(name, &spec.command); - command.stdin(Stdio::null()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - let mut child = command - .spawn() - .with_context(|| format!("failed to run hook '{name}'"))?; - let stdout = child + let mut guarded = + spawn_guarded_process(name, command, Stdio::null(), &self.guardian_executable).await?; + let stdout = guarded + .child .stdout .take() .ok_or_else(|| anyhow!("failed to capture stdout for hook '{name}'"))?; - let stderr = child + let stderr = guarded + .child .stderr .take() .ok_or_else(|| anyhow!("failed to capture stderr for hook '{name}'"))?; @@ -213,7 +228,11 @@ impl<'a> ProcessManager<'a> { source_label.clone(), name.to_owned(), ); - let child_status = async { child.wait().await.map_err(anyhow::Error::from) }; + let child_status = async move { + let status = guarded.child.wait().await.map_err(anyhow::Error::from)?; + signal_process_group(name, guarded.process_group, Signal::KILL)?; + Ok::<_, anyhow::Error>(status) + }; let (status, stdout, stderr) = tokio::try_join!(child_status, stdout_task, stderr_task) .with_context(|| format!("failed to run hook '{name}'"))?; if let Some(session_log) = &self.session_log @@ -284,7 +303,7 @@ impl<'a> ProcessManager<'a> { .children .get_mut(&name) .ok_or_else(|| anyhow!("missing managed process '{name}'"))?; - managed.child.try_wait()? + managed.guarded.child.try_wait()? }; if let Some(status) = exited { @@ -293,7 +312,7 @@ impl<'a> ProcessManager<'a> { .children .remove(&name) .ok_or_else(|| anyhow!("missing exited process '{name}'"))?; - signal_process_group(&name, managed.process_group, Signal::KILL)?; + signal_process_group(&name, managed.guarded.process_group, Signal::KILL)?; self.spawn_output_cleanup(name.clone(), managed.output_tasks); exits.push((name, status.success())); } @@ -363,7 +382,7 @@ impl<'a> ProcessManager<'a> { if self.children.contains_key(name) { return Ok(()); } - let mut command = configure_command( + let command = configure_command( &spec.command, resolve_cwd(&self.config.root, spec.cwd.as_deref()), CommandContext { @@ -376,13 +395,9 @@ impl<'a> ProcessManager<'a> { workflow: "startup", }, )?; - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - command.process_group(0); - let mut child = command - .spawn() - .with_context(|| format!("failed to start process '{name}'"))?; - let process_group = child_process_group(name, &child)?; + let mut guarded = + spawn_guarded_process(name, command, Stdio::inherit(), &self.guardian_executable) + .await?; let output_generation = self.next_output_state_generation(name, &spec.output.rules, state)?; let process_name = name.to_owned(); @@ -393,7 +408,7 @@ impl<'a> ProcessManager<'a> { let stdout_sink = OutputSink::Stdout(self.stdout.clone()); let stderr_sink = OutputSink::Stderr(self.stderr.clone()); let mut output_tasks = Vec::new(); - if let Some(stdout) = child.stdout.take() { + if let Some(stdout) = guarded.child.stdout.take() { output_tasks.push(OutputTask { handle: tokio::spawn(forward_output_lines( stdout, @@ -411,7 +426,7 @@ impl<'a> ProcessManager<'a> { )), }); } - if let Some(stderr) = child.stderr.take() { + if let Some(stderr) = guarded.child.stderr.take() { output_tasks.push(OutputTask { handle: tokio::spawn(forward_output_lines( stderr, @@ -432,8 +447,7 @@ impl<'a> ProcessManager<'a> { self.children.insert( name.to_owned(), ManagedProcess { - child, - process_group, + guarded, output_tasks, }, ); @@ -531,6 +545,105 @@ impl<'a> ProcessManager<'a> { } } +/// Spawns a managed command behind devloop's internal Rust guardian. +/// +/// The guardian stays outside the target process group, so ordinary TERM/KILL +/// signals reach only the target tree. It watches a private lifetime socket; +/// EOF after any devloop exit makes it kill the group and reap its direct target. +async fn spawn_guarded_process( + name: &str, + command: Command, + stdin: Stdio, + guardian_executable: &GuardianExecutable, +) -> Result { + let (mut lifetime, guardian_control) = + UnixStream::pair().context("failed to create parent-death control socket")?; + let guardian_control_fd = guardian_control.as_raw_fd(); + let target = command.as_std(); + let guardian_image = guardian_executable.prepare_invocation()?; + let guardian_image_fd = guardian_image.inherited_image_fd(); + let mut guardian = Command::new(guardian_image.path()); + devloop::process_guardian::append_invocation(&mut guardian, target); + if let Some(cwd) = target.get_current_dir() { + guardian.current_dir(cwd); + } + for (key, value) in target.get_envs() { + match value { + Some(value) => { + guardian.env(key, value); + } + None => { + guardian.env_remove(key); + } + } + } + guardian.stdout(Stdio::piped()); + guardian.stderr(Stdio::piped()); + guardian.stdin(stdin); + guardian.process_group(0); + // SAFETY: this closure runs after fork and before exec. It only uses + // async-signal-safe libc calls to expose the already-open control socket + // at one fixed descriptor in the guardian process. + unsafe { + guardian.pre_exec(move || { + if let Some(guardian_image_fd) = guardian_image_fd { + let guardian_image_flags = libc::fcntl(guardian_image_fd, libc::F_GETFD); + if guardian_image_flags == -1 + || libc::fcntl( + guardian_image_fd, + libc::F_SETFD, + guardian_image_flags & !libc::FD_CLOEXEC, + ) == -1 + { + return Err(std::io::Error::last_os_error()); + } + } + if guardian_control_fd == devloop::process_guardian::CONTROL_FD { + let flags = libc::fcntl(devloop::process_guardian::CONTROL_FD, libc::F_GETFD); + if flags == -1 + || libc::fcntl( + devloop::process_guardian::CONTROL_FD, + libc::F_SETFD, + flags & !libc::FD_CLOEXEC, + ) == -1 + { + return Err(std::io::Error::last_os_error()); + } + } else { + if libc::dup2(guardian_control_fd, devloop::process_guardian::CONTROL_FD) == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::close(guardian_control_fd) == -1 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } + let mut child = guardian + .spawn() + .with_context(|| format!("failed to start guarded process '{name}'"))?; + drop(guardian_image); + drop(guardian_control); + let process_group = match devloop::process_guardian::receive_process_group(&mut lifetime) { + Ok(process_group) => process_group, + Err(error) => { + drop(lifetime); + child + .wait() + .await + .with_context(|| format!("failed to reap guardian for process '{name}'"))?; + return Err(error) + .with_context(|| format!("guardian failed to start process '{name}'")); + } + }; + Ok(GuardedProcess { + child, + process_group, + _lifetime: lifetime, + }) +} + async fn wait_for_output_tasks( name: &str, output_tasks: Vec, @@ -1419,15 +1532,24 @@ fn clear_output_state_keys(rules: &[OutputRule], state: &SessionState) -> Result Ok(()) } -fn child_process_group(name: &str, child: &Child) -> Result { - let id = child - .id() - .ok_or_else(|| anyhow!("process '{name}' exited before its process group was recorded"))?; - Pid::from_raw(id as i32) - .ok_or_else(|| anyhow!("process '{name}' has invalid process group id {id}")) +async fn terminate_child(name: &str, child: &mut Child, process_group: Pid) -> Result<()> { + terminate_child_with_timeouts( + name, + child, + process_group, + PROCESS_STOP_TIMEOUT, + GUARDIAN_REAP_TIMEOUT, + ) + .await } -async fn terminate_child(name: &str, child: &mut Child, process_group: Pid) -> Result<()> { +async fn terminate_child_with_timeouts( + name: &str, + child: &mut Child, + process_group: Pid, + stop_timeout: Duration, + guardian_reap_timeout: Duration, +) -> Result<()> { if child.try_wait()?.is_some() { signal_process_group(name, process_group, Signal::KILL)?; info!("process {} already exited; cleaned up process group", name); @@ -1435,17 +1557,32 @@ async fn terminate_child(name: &str, child: &mut Child, process_group: Pid) -> R } signal_process_group(name, process_group, Signal::TERM)?; - match timeout(Duration::from_secs(2), child.wait()).await { + match timeout(stop_timeout, child.wait()).await { Ok(result) => { result.with_context(|| format!("failed to wait for process '{name}' after SIGTERM"))?; signal_process_group(name, process_group, Signal::KILL)?; } Err(_) => { signal_process_group(name, process_group, Signal::KILL)?; - child - .wait() - .await - .with_context(|| format!("failed to stop process '{name}' after SIGKILL"))?; + match timeout(guardian_reap_timeout, child.wait()).await { + Ok(result) => { + result.with_context(|| { + format!("failed to wait for process '{name}' after SIGKILL") + })?; + } + Err(_) => { + warn!( + "process {name} did not exit after target group SIGKILL; killing its guardian" + ); + child + .start_kill() + .with_context(|| format!("failed to kill guardian for process '{name}'"))?; + child + .wait() + .await + .with_context(|| format!("failed to reap guardian for process '{name}'"))?; + } + } } } info!("stopped process {}", name); @@ -1630,6 +1767,89 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn guarded_process_preserves_configured_stdin() { + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("IFS= read -r line; printf '%s' \"$line\""); + let guardian = GuardianExecutable::open().expect("open test guardian"); + let mut guarded = spawn_guarded_process("stdin-reader", command, Stdio::piped(), &guardian) + .await + .expect("spawn guard"); + let mut stdin = guarded.child.stdin.take().expect("take stdin"); + let mut stdout = guarded.child.stdout.take().expect("take stdout"); + + stdin + .write_all(b"from-devloop\n") + .await + .expect("write stdin"); + drop(stdin); + let status = guarded.child.wait().await.expect("wait for guard"); + let mut output = String::new(); + stdout + .read_to_string(&mut output) + .await + .expect("read stdout"); + + assert!(status.success()); + assert_eq!(output, "from-devloop"); + } + + #[cfg(unix)] + #[tokio::test] + async fn guarded_process_surfaces_target_spawn_error() { + let command = Command::new("devloop-test-command-that-does-not-exist"); + let guardian = GuardianExecutable::open().expect("open test guardian"); + let error = match spawn_guarded_process("missing", command, Stdio::null(), &guardian).await + { + Ok(_) => panic!("missing target unexpectedly started"), + Err(error) => error, + }; + let message = format!("{error:#}"); + + assert!( + message.contains("failed to start guarded command"), + "{message}" + ); + assert!(!message.contains("unexpected end of file"), "{message}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn terminate_child_bounds_wait_for_an_unresponsive_guardian() { + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("trap '' TERM; printf ready; IFS= read -r _") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + let mut child = command.spawn().expect("spawn guardian fixture"); + let mut ready = [0_u8; 5]; + child + .stdout + .as_mut() + .expect("guardian fixture stdout") + .read_exact(&mut ready) + .await + .expect("guardian fixture readiness"); + assert_eq!(&ready, b"ready"); + let nonexistent_group = Pid::from_raw(2_000_000_000).expect("nonexistent process group"); + + terminate_child_with_timeouts( + "unresponsive-guardian", + &mut child, + nonexistent_group, + Duration::from_millis(20), + Duration::from_millis(20), + ) + .await + .expect("bound guardian wait"); + + assert!(child.try_wait().expect("read guardian status").is_some()); + } + #[cfg(unix)] async fn assert_process_gone(raw_pid: i32) { let pid = Pid::from_raw(raw_pid).expect("pid"); @@ -1680,7 +1900,10 @@ wait }, ); let state = SessionState::load(unique_state_path()).expect("state"); - let mut manager = ProcessManager::new(&config); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); manager .start_named("server", &state) @@ -1737,7 +1960,11 @@ while :; do sleep 1; done let state_file = dir.path().join(".devloop/state.json"); let state = SessionState::load(state_file.clone()).expect("state"); let log = SessionLog::create(&state_file).expect("create log"); - let mut manager = ProcessManager::new(&config).with_session_log(log.clone()); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ) + .with_session_log(log.clone()); manager .start_named("server", &state) @@ -1797,7 +2024,11 @@ exec sleep 600 let state_file = dir.path().join(".devloop/state.json"); let state = SessionState::load(state_file.clone()).expect("state"); let log = SessionLog::create(&state_file).expect("create log"); - let mut manager = ProcessManager::new(&config).with_session_log(log.clone()); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ) + .with_session_log(log.clone()); manager .start_named("server", &state) @@ -1858,7 +2089,10 @@ exit 0 ); let state_file = dir.path().join(".devloop/state.json"); let state = SessionState::load(state_file).expect("state"); - let mut manager = ProcessManager::new(&config); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); manager .start_named("server", &state) @@ -1907,7 +2141,11 @@ exit 0 let state_file = dir.path().join(".devloop/state.json"); let state = SessionState::load(state_file.clone()).expect("state"); let log = SessionLog::create(&state_file).expect("create log"); - let mut manager = ProcessManager::new(&config).with_session_log(log.clone()); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ) + .with_session_log(log.clone()); manager .start_named("server", &state) @@ -1978,7 +2216,11 @@ exec sleep 600 let state_file = dir.path().join(".devloop/state.json"); let state = SessionState::load(state_file.clone()).expect("state"); let log = SessionLog::create(&state_file).expect("create log"); - let mut manager = ProcessManager::new(&config).with_session_log(log.clone()); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ) + .with_session_log(log.clone()); manager .start_named("first", &state) @@ -2007,7 +2249,10 @@ exec sleep 600 async fn finish_output_cleanup_tasks_drains_all_failures() { let dir = tempdir().expect("tempdir"); let config = test_config(dir.path()); - let mut manager = ProcessManager::new(&config); + let mut manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ); manager .output_cleanup_tasks .spawn(async { Err(anyhow!("first cleanup failure")) }); @@ -2162,7 +2407,11 @@ exec sleep 600 let state_file = dir.path().join(".devloop/state.json"); let state = SessionState::load(state_file.clone()).expect("state"); let log = SessionLog::create(&state_file).expect("create log"); - let manager = ProcessManager::new(&config).with_session_log(log.clone()); + let manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ) + .with_session_log(log.clone()); manager .run_hook("capture", &state, &[], "test") @@ -2201,7 +2450,11 @@ exec sleep 600 std::io::ErrorKind::BrokenPipe, "simulated session log failure", ); - let manager = ProcessManager::new(&config).with_session_log(log); + let manager = ProcessManager::new( + &config, + GuardianExecutable::open().expect("open test guardian"), + ) + .with_session_log(log); manager .run_hook("capture", &state, &[], "test") diff --git a/tests/process_cleanup.rs b/tests/process_cleanup.rs new file mode 100644 index 0000000..26e8cea --- /dev/null +++ b/tests/process_cleanup.rs @@ -0,0 +1,253 @@ +#![cfg(unix)] + +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use rustix::io::Errno; +use rustix::process::{Pid, Signal, getpgid, kill_process, test_kill_process}; +use tempfile::TempDir; + +#[test] +fn abrupt_supervisor_death_kills_the_complete_managed_process_tree() { + assert_abrupt_supervisor_death_cleans_tree(TreeLaunch::ManagedProcess); +} + +#[test] +fn abrupt_supervisor_death_kills_the_complete_hook_process_tree() { + assert_abrupt_supervisor_death_cleans_tree(TreeLaunch::Hook); +} + +fn assert_abrupt_supervisor_death_cleans_tree(launch: TreeLaunch) { + let fixture = ProcessTreeFixture::new(launch); + let mut devloop = DevloopChild::spawn(&fixture); + let parent = fixture.wait_for_pid("parent.pid", Duration::from_secs(10)); + let child = fixture.wait_for_pid("child.pid", Duration::from_secs(10)); + let grandchild = fixture.wait_for_pid("grandchild.pid", Duration::from_secs(10)); + let guardian = assert_target_group(&devloop, parent, child, grandchild); + assert_guardian_identity_and_signal_resilience(guardian); + + devloop.kill_supervisor(); + + assert_process_gone(guardian); + assert_process_gone(parent); + assert_process_gone(child); + assert_process_gone(grandchild); +} + +fn assert_target_group(devloop: &DevloopChild, parent: i32, child: i32, grandchild: i32) -> i32 { + let parent = Pid::from_raw(parent).expect("parent pid"); + let child = Pid::from_raw(child).expect("child pid"); + let grandchild = Pid::from_raw(grandchild).expect("grandchild pid"); + let devloop = Pid::from_raw(devloop.child.id() as i32).expect("devloop pid"); + + assert_eq!(getpgid(Some(parent)).expect("parent process group"), parent); + assert_eq!(getpgid(Some(child)).expect("child process group"), parent); + assert_eq!( + getpgid(Some(grandchild)).expect("grandchild process group"), + parent + ); + assert_ne!( + getpgid(Some(devloop)).expect("devloop process group"), + parent + ); + process_parent(parent.as_raw_nonzero().get()) +} + +fn assert_guardian_identity_and_signal_resilience(raw_pid: i32) { + let guardian = Pid::from_raw(raw_pid).expect("guardian pid"); + let process_name = process_field(raw_pid, "comm"); + let executable_name = std::path::Path::new(process_name.trim()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(process_name.trim()); + assert_ne!( + executable_name, "devloop", + "guardian must have a distinct name" + ); + + kill_process(guardian, Signal::TERM).expect("send SIGTERM to guardian"); + assert!( + test_kill_process(guardian).is_ok(), + "guardian must survive name-oriented termination signals" + ); +} + +fn process_parent(raw_pid: i32) -> i32 { + process_field(raw_pid, "ppid") + .trim() + .parse() + .expect("parse guardian pid") +} + +fn process_field(raw_pid: i32, field: &str) -> String { + let output = Command::new("ps") + .args(["-o", &format!("{field}="), "-p", &raw_pid.to_string()]) + .output() + .expect("inspect process"); + assert!(output.status.success(), "ps failed for process {raw_pid}"); + String::from_utf8(output.stdout).expect("ps output is UTF-8") +} + +#[derive(Clone, Copy)] +enum TreeLaunch { + ManagedProcess, + Hook, +} + +struct ProcessTreeFixture { + dir: TempDir, +} + +impl ProcessTreeFixture { + fn new(launch: TreeLaunch) -> Self { + let dir = tempfile::tempdir().expect("create fixture directory"); + let fixture = Self { dir }; + let config = match launch { + TreeLaunch::ManagedProcess => { + r#"root = "." +state_file = "./.devloop/state.json" + +[watch.config] +paths = ["devloop.toml"] +workflow = "noop" + +[process.tree] +command = ["sh", "parent.sh"] +autostart = true +restart = "never" + +[workflow.noop] +steps = [{ action = "log", message = "configuration changed" }] +"# + } + TreeLaunch::Hook => { + r#"root = "." +state_file = "./.devloop/state.json" +startup_workflows = ["start"] + +[watch.config] +paths = ["devloop.toml"] +workflow = "noop" + +[hook.tree] +command = ["sh", "parent.sh"] + +[workflow.start] +steps = [{ action = "run_hook", hook = "tree" }] + +[workflow.noop] +steps = [{ action = "log", message = "configuration changed" }] +"# + } + }; + fixture.write("devloop.toml", config); + fixture.write( + "parent.sh", + r#"#!/bin/sh +set -eu + +printf '%s\n' "$$" > parent.pid +sh child.sh & +wait "$!" +"#, + ); + fixture.write( + "child.sh", + r#"#!/bin/sh +set -eu + +printf '%s\n' "$$" > child.pid +sh grandchild.sh & +wait "$!" +"#, + ); + fixture.write( + "grandchild.sh", + r#"#!/bin/sh +set -eu + +trap '' TERM +printf '%s\n' "$$" > grandchild.pid +while :; do + sleep 60 +done +"#, + ); + fixture + } + + fn path(&self) -> &std::path::Path { + self.dir.path() + } + + fn write(&self, name: &str, content: &str) { + std::fs::write(self.path().join(name), content).expect("write fixture file"); + } + + fn wait_for_pid(&self, name: &str, timeout: Duration) -> i32 { + let path = self.path().join(name); + let deadline = Instant::now() + timeout; + loop { + match std::fs::read_to_string(&path) { + Ok(raw_pid) => { + if let Ok(pid) = raw_pid.trim().parse() { + return pid; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("failed to read {}: {error}", path.display()), + } + assert!(Instant::now() < deadline, "timed out waiting for {name}"); + thread::yield_now(); + } + } +} + +struct DevloopChild { + child: Child, +} + +impl DevloopChild { + fn spawn(fixture: &ProcessTreeFixture) -> Self { + let child = Command::new(env!("CARGO_BIN_EXE_devloop")) + .arg("run") + .arg("--config") + .arg(fixture.path().join("devloop.toml")) + .current_dir(fixture.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn devloop"); + Self { child } + } + + fn kill_supervisor(&mut self) { + let pid = Pid::from_raw(self.child.id() as i32).expect("devloop pid"); + kill_process(pid, Signal::KILL).expect("kill devloop supervisor"); + let status = self.child.wait().expect("wait for killed devloop"); + assert!(!status.success(), "SIGKILL should not report success"); + } +} + +impl Drop for DevloopChild { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn assert_process_gone(raw_pid: i32) { + let pid = Pid::from_raw(raw_pid).expect("managed process pid"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if matches!(test_kill_process(pid), Err(Errno::SRCH)) { + return; + } + assert!( + Instant::now() < deadline, + "managed descendant {raw_pid} survived devloop" + ); + thread::yield_now(); + } +}