rightkit-child-process 0.4.1

Ownership-safe child lifecycle, restart, and health primitives for Right Suite apps.
Documentation
# rightkit-child-process

Ownership-safe process lifecycle, restart, and health primitives for Right
Suite desktop apps. It owns child spawn with tree ownership, tree termination,
restart scheduling (`RestartTracker`), and health thresholds (`HealthTracker`).
It contains no app protocol, probe implementation, or business message.

**Use it** wherever an app starts a child process that must not outlive the app, or must be restarted by policy.

**Do not use it** to sandbox a process (use `rightkit-sandbox`), to adopt and kill arbitrary PIDs without an identity check, or to define an app's health probe.

## Features

One optional feature, off by default: `tokio` (adds `tokio` with `process` and `time`): `NoWindow` for `tokio::process::Command` and `tokio_support::{terminate_tree, terminate_process_group}`. Platform dependencies: `libc` (Unix) and `windows` 0.62 (Windows). Also without the feature: `NoWindow` for `std::process::Command`, `OutputCapture` / `read_bounded` (byte-exact bounded capture of a stream).

## Platform support

- Unix: children lead a new process group (or a session with `UnixContainment::Session`). Termination signals the group, under the identity rule below. `kill_on_parent_exit` is Linux only.
- Windows: children are created suspended, assigned to a Job Object, then resumed. Termination uses the Job Object.
- The crate has `cfg(unix)`, `cfg(windows)`, `cfg(target_os = "macos")`, and `cfg(target_os = "linux")` branches across `src/owned.rs`, `src/process_handle.rs`, `src/unix_child.rs`, `src/exit_watch.rs`, `src/identity.rs`, `src/process_unix.rs`, `src/process_windows.rs`, and `src/spawn_windows.rs`.

Published version: see `INDEX.md` at the repository root. Release notes: `CHANGELOG.md`.

## Ownership boundary

- `OwnedCommand` is the only way to create an `OwnedChild`. Ordinary spawns own
  their tree; explicitly detached spawns leave lifetime with the caller.
- `AdoptedProcess` can only report an existing PID and whether it is running.
  It deliberately has no terminate method. There is no public `kill_pid` API.
  `AdoptedProcess::identity` yields an `AdoptedIdentity` (pid, start time, executable)
  whose `signal` verifies the pid still names that process before acting and refuses
  otherwise; `sweep` and `sweep_descendants` apply the same rule to descendants.
- `reveal` shows or opens a file in the system file manager, only inside caller-supplied
  roots (`PathScope`); it refuses escapes and runnable files.
- Dropping an `OwnedChild` kills at once and waits for the reap for at most
  `drop_reap_timeout` (default 2 s), then reaps in the background.
- Dropping an ordinary `OwnedChild` terminates and reaps its tree. A failure
  after OS spawn also cleans up the partial child.

Windows children are created suspended, assigned to a Job Object, then
resumed. Children are hidden by default (`CREATE_NO_WINDOW`); `windows_hide()` is a no-op kept for compatibility.
Explicit termination uses the Job Object, so descendants die while unrelated
sibling processes survive. Unix children lead a new process group;
`wait_or_kill` sends `SIGTERM` to the group, waits for the leader for the bounded grace
period, then sends `SIGKILL` to the group and reaps it. If the leader exits inside the grace,
descendants that are still running are killed before the leader is reaped.

Windows resumes the suspended primary thread only after assignment decisions.

## Unix process-group identity

A process-group id can be recycled for an unrelated group once the leader has been reaped and
the group is empty, so a signal sent to it afterwards can hit a program that is not ours.
Version 0.3.3 checked group membership after the reap and then signalled, which left a window
in which that could happen. The rule that replaces it:

- **A group signal is sent only while this process still holds the leader as its own unreaped
  child.** An unreaped child, running or exited, pins its pid and group id; only this process
  can reap it. Every group signal is sent under the same lock that guards reaping, after the
  leader is confirmed unreaped, so no reap can fall between the check and the signal.
- **After the first reap, by any path, group signalling is over** for that `OwnedChild` and all
  its `ProcessHandle` clones: no signal, and no `kill(-pgid, 0)` membership probe. Paths that
  reap: `OwnedChild::wait`, `try_wait`, `wait_timeout`, the same methods on a `ProcessHandle`
  or a clone, the detached reaper, and `wait`/`try_wait` through `OwnedChild::child()`.
- **`wait()` followed by drop signals nothing.** Grandchildren that outlive a plainly waited
  leader are not killed. To kill them, use `terminate_tree`, `terminate_tree_with_options`,
  `terminate_tree_bounded`, or `wait_or_kill` while the leader is still unreaped. These keep
  an exited leader unreaped (it is observed with `waitid(WNOWAIT)`), signal the group while
  that zombie pins the id, and only then reap it.
- A handle clone that waits on another thread reaps as soon as the leader exits, and so
  retires group signalling for the owner too. A program that needs both a monitor thread and
  tree termination must not let the monitor reap: **a monitor calls `ProcessHandle::wait_exited`
  (or `wait_exited_timeout(timeout) -> bool` when it enforces a deadline), which returns once
  the leader has exited and leaves it an unreaped zombie.** The owner then kills the group with
  `wait_or_kill` or `terminate_tree*` and reaps afterwards. A caller that drains the leader's
  pipes (a run loop, `rightkit-sandbox`'s `wait_with_output`) keeps the leader unreaped until
  the drain is over, so a descendant that outlives the leader and holds the pipes is still
  killed with the group at the deadline. A plain `ProcessHandle::wait` on another thread
  reaps and ends that. On Unix hosts other than Linux, Android, macOS and iOS there is no
  non-reaping probe, so `wait_exited` reaps like `wait` there.
- **An unreaped child is not proof the pid is still ours.** Other code in the process can collect
  the leader (`waitpid(-1)`) and a replacement child can take its number. So every signal, every
  `waitpid`/`waitid` and every group-membership probe that names the pid first compares the
  kernel's description of it (parent and start time: `proc_pidinfo` on macOS, `/proc/<pid>/stat`
  on Linux) with the one taken at spawn. A different or missing description marks the leader
  lost: nothing is sent, waited or reaped (`wait` answers `ECHILD`) and the replacement keeps its
  status. A child the host cannot describe at spawn is stopped and the spawn fails.
  `OwnedChild::while_unreaped` runs a caller's pid-based action (a descendant sweep) under the
  same proof and lock. **The proof does not close the race entirely; see "Known limits".**
- **Waits are loops of non-blocking looks, and the exit event is only a hint.** `ProcessHandle::wait`,
  `wait_timeout`, `wait_exited*`, `OwnedChild::wait*`, `wait_or_kill`, `terminate_tree*`, `Drop`, the
  detached reaper thread (`rightkit-detached-reap`) and the failed-spawn cleanup (up to its timeout) take
  the shared lock for one non-blocking look (`waitpid(WNOHANG)`, or the non-reaping
  `waitid(WNOHANG | WNOWAIT)` for `wait_exited*`), release it, sleep, and look again until the leader
  is reaped or the caller's deadline passes. None of them reaps with a blocking call or sleeps while
  holding the lock, so a deadline is honoured whatever the child does, `kill` or `terminate_tree` from
  another thread runs while one of them is in progress, and no wait system call names the pid outside
  the lock. The sleep between looks is a kqueue `NOTE_EXIT` / pidfd wake-up where the kernel offers one
  (at most 1 s per sleep, ending early at the exit), and 5 ms, the interval 0.3.3 polled at, where it
  does not (no such facility, no descriptor to spare, or a process already exiting at spawn). The event
  never decides anything: the look after it does. The tree-wide grace of `terminate_tree*` lists the
  group every 5 ms until only the exited, unreaped leader is left, or the grace ends. Each child holds
  one extra descriptor (the hint) while it is alive. Waiting threads wake about once a second with the
  hint and about 200 times a second without it, for as long as the child lives (0.3.3's Linux and macOS
  `wait()` blocked in the kernel).
- **Where the shared lock is held across a wait or a long action: the complete list.** Everything else
  releases it between looks.
  1. **`wait` through the `OwnedChild::child()` guard (`UnixChild::wait`).** The guard is the lock, so
     the wait holds it until the child has exited, and a bounded wait on a clone, a `kill`,
     `terminate_tree` or `Drop` on another thread queues behind it and can exceed its own deadline. The
     caller chose to hold the guard; `std::process::Child::wait` through the same accessor did the same
     in 0.3.3, and the accessor's contract has always been "release this guard before another accessor
     or exit reader". It still reaps only with non-blocking looks. Wait through `OwnedChild::wait`,
     `wait_timeout` or a `ProcessHandle` when anything else must stay live. (Windows:
     `WindowsChild::wait` through the guard blocks in `WaitForSingleObject` under the lock, as in 0.3.3.)
  2. **Failed-spawn cleanup with no timeout** (the default), and
  3. **the `rightkit-partial-reap` thread** it hands a timed-out child to. Both wait under the lock, as
     0.3.3 did. They run only for a child whose spawn failed after the process existed, and nothing
     but the spawning thread (then the reaper thread) holds that child: the partial `OwnedChild` was
     never returned, no `ProcessHandle` was cloned from it, so no other thread can reach its lock.
  4. **`OwnedChild::while_unreaped`** holds the lock while the caller's closure runs (that keeps a reap
     out between the proof and the action), so a bounded wait on another thread is delayed by as long
     as the closure takes. `rightkit-sandbox` passes a descendant sweep with a 200 ms grace. New since
     0.3.3.
  The spawn-time identity check (`require_identity`) reaps a just-killed child with a blocking wait
  before the child is shared with anyone.
- `OwnedChild::child()` no longer exposes the std `Child` on Unix. It returns `UnixChild`
  (stdio fields, `id`, `try_wait`, `wait`, `kill`), so a reap through it is recorded.
- `UnixChild::kill`, and every direct-pid signal for detached children, follow the same rule.

## Also (0.3.0)

- `OwnedChild::child()` borrows `UnixChild` on Unix (0.4.0; std `Child` before) or `WindowsChild` on Windows through
  a lock guard, including stdin/stdout/stderr and wait/try_wait; existing stream-taking APIs remain.
- `unix_containment(UnixContainment::Session)` runs `setsid`; Windows
  `job_assignment_mode(JobAssignmentMode::BestEffort)` retains non-fatal errors
  in `job_assignment_failures()`. Strict assignment remains default.
- `terminate_tree_with_options(TerminationOptions)` sends Unix TERM immediately,
  then KILL after configurable tree-wide `grace` (zero means immediate escalation).
  On Unix both signals go out while the leader is unreaped (see the identity rule above);
  Windows' retained job also works after parent exit; `windows_exit_code` accepts 1067.
  `terminate_tree_bounded_with_options(timeout, options)` bounds grace plus reap.
- `spawn_uncontained_detached()` skips owner containment and survives owner drop.
  Windows requires parent-job breakaway permission and fails without fallback.
  Unix starts a separate session and reaps asynchronously; configure stdio so EOF
  or closed pipes do not request application shutdown.
- `process_handle()` returns an independent `ProcessHandle` exit reader/waiter.
  Windows implements `AsHandle` with an owned duplicate and exposes FILETIME
  `creation_time_ticks()`; `creation_time()` reports native time on Windows/macOS,
  spawn observation time on other Unix hosts. Unix readers share the owner's cached reap
  state, and a reap by a reader ends group signalling for the owner (identity rule above).
  `wait_exited()` / `wait_exited_timeout(timeout)` wait for the exit without reaping (Unix) and
  so leave the group killable; use them for a monitor.
  Release `child()` guards before calling another accessor or reader.
- `partial_spawn_cleanup_timeout(Duration::from_secs(5))` bounds failed-spawn
  cleanup; default remains unbounded. Timed-out children go to a background reaper.

## Also (0.4.0)

- `OwnedCommand::kill_on_parent_exit(true)` is an opt-in tie to the supervising process for the case where
  the parent dies without cleanup (SIGKILL, abort, crash). **Linux only.** `prctl(PR_SET_PDEATHSIG, SIGTERM)`
  is set in `pre_exec`, and `getppid()` is checked afterwards so a supervisor that died before the prctl makes
  the spawn fail. The signal goes to the direct child only. It fires when the thread that spawned the child
  exits, not only when the process exits, so spawn from a thread that lives as long as the child. The child
  gets one TERM and no KILL escalation, so a child that ignores TERM survives. Descendants in the group are not
  signalled.
  - macOS and other Unix hosts: the spawn fails with `ErrorKind::Unsupported` before anything is started. A
    macOS helper process was written and removed from this release (see `CHANGELOG.md`); until a parent-death
    tie can be established there with an owner that cannot be confused with another child, the option is
    refused rather than run the child untied.
  - Windows: a no-op. Kill-on-close job objects already terminate the tree when the owner handle closes.
  - Combining the option with `spawn_uncontained_detached` returns `InvalidInput`.
- `tests/parent_death.rs` (Linux) re-executes the test binary as the parent, SIGKILLs it, and checks the child
  pid. `src/owned.rs` has the unit tests for the refusals.

## Known limits

Open in this release; none is new in the sense of being introduced by it, and each is stated so a
caller can decide.

- `StderrTail::finish_within` returns at its timeout and never joins the drain thread, so the thread may still be
  running the caller's `map`/`on_line` destructors (and their thread-local destructors) after it returns. `finish`
  joins and has no bound: a callback or destructor that blocks makes it wait.

- **Unix: proof and signal are two steps.** The identity proof above runs before every signal,
  wait and probe, but the proof and the call it guards are separate system calls. Foreign code in
  the same process that reaps one of our children in exactly that gap, followed by a replacement
  process that takes the same pid, defeats it for that one call. The identity is also read just
  after the spawn, not by it. No user-space API available here closes this (there is no `kill` or
  `waitpid` conditional on a start time on macOS). An application that embeds a foreign reaper
  (a `waitpid(-1)` loop) needs that reaper isolated from the children this crate owns; another
  check in this crate cannot close it.
- **Windows: inherited handles.** `inherit_handles_only` raises the inherit flag on the caller's
  handles for the length of `CreateProcessW`. This crate's own ordinary spawns take the same lock,
  so they cannot run in that window, but a process created by other code (a plain
  `std::process::Command::spawn`, another library) can, and inherits those handles.
  `with_windows_spawn_lock` lets such code join the lock; nothing enforces it, and the lock is not
  re-entrant (do not create a RightKit process inside it). Not addressed further here and not
  verified on Windows.
- **macOS: descriptor bound.** `inherit_handles_only` closes descriptors up to a bound taken
  before the fork (as in 0.3.3). A descriptor opened above it, or a soft limit raised, between that
  snapshot and the fork is not closed.
- **Hosts that will not describe a child** (Linux without a readable `/proc`, macOS where
  `proc_pidinfo` is refused) cannot prove a pid later, so the spawn is stopped and fails. Other Unix
  hosts have no identity source and keep the unreaped-child rule alone.

## Example and usage

Illustrative; not compiled as a doctest. The compiled checks are `tests/api.rs`, `tests/supervision_primitives.rs`, and `tests/restart_health.rs`.

`OwnedCommand::inherit_handles_only` restricts inheritance to caller handles/fds plus stdio; Windows requires `windows_spawn_config` for opaque stdio, environment-inheritance & raw-argument settings.

```rust,ignore
use std::{process::Stdio, time::Duration};
use rightkit_child_process::{OwnedCommand, WaitOutcome};

let mut command = OwnedCommand::new("my-engine");
command.command_mut().stdout(Stdio::piped());
command.windows_hide();
let mut child = command.spawn()?;
let stdout = child.take_stdout();

// The app sends its own graceful protocol message before this call on Windows.
match child.wait_or_kill(Duration::from_secs(2))? {
    WaitOutcome::Exited(status) => assert!(status.success()),
    WaitOutcome::Terminated(_) => { /* grace expired */ }
}
# Ok::<(), std::io::Error>(())
```

`StderrTail::capture(stderr, max_bytes, map, on_line)` keeps at most `max_bytes` bytes of the most recent
lines (a single longer line is cut to its last bytes) and shows `map`, the redactor, only whole lines. A
line longer than four times `max_bytes` is discarded up to its newline and replaced by one marker line
(`[stderr line over N bytes dropped]`), because handing a line-based redactor the pieces of a cut line
would let a secret that spans the cut through.

`RestartTracker` is a pure rolling-window/backoff state machine. Restart
permits are single-use scheduling authorities: issuing a replacement permit,
starting a new generation, or shutting down invalidates every older permit.
`RestartMode` is `Automatic`, `OnDemand`, or `Disabled`.

`HealthTracker` is also pure: callers provide monotonic `Duration` timestamps
and probe outcomes, making startup timeout and consecutive-failure thresholds
deterministic in tests.

## Verification

Run through the RightKit wrapper, per the workspace's `docs/rules/rightkit.md`
(run the Unix suites in both thread modes: default and `RUST_TEST_THREADS=1`):

```text
rightkit cargo test -p rightkit-child-process --all-targets
rightkit cargo fmt --check
rightkit cargo clippy -p rightkit-child-process --all-targets -- -D warnings
rightkit cargo package -p rightkit-child-process --allow-dirty --no-verify --list
```

`tests/windows_process_tree.rs` covers parent and grandchild termination, sibling
survival, bounded grace, kill-on-drop, and partial-spawn cleanup on Windows.
`tests/unix_process_group.rs` covers Unix process-group behavior. Run both suites on
their own host platforms.

## Windows

- Spawn: `CreateProcessW` with `CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT`, plus `CREATE_NO_WINDOW` (the default) or `CREATE_BREAKAWAY_FROM_JOB` (detached). Inherited handles are limited by a `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` built from the caller's handles and stdio.
- Ownership order: a caller job from `windows_job()` is assigned first, then a new job with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The primary thread is resumed through the thread handle `CreateProcessW` returned (`src/spawn_windows.rs:147-150`). Resume by process ID from a Toolhelp thread snapshot (`src/process_windows.rs:132`) runs only when that handle is no longer held and the child is not detached. Either way the child is resumed after its job assignment.
- `job_assignment_mode(BestEffort)` records caller-job and owned-job errors in `job_assignment_failures()`. Strict, the default, fails the spawn.
- Termination: `terminate_tree` calls `TerminateJobObject` on the owned job, then `TerminateProcess` on the direct child. An ACCESS_DENIED from a child that is already exiting gets a 2 s wait. Processes outside the job are untouched.
- Detached spawns (`spawn_uncontained_detached`) need the parent job to allow breakaway. The spawn fails without fallback when it does not, and a detached spawn cannot take a caller job.
- Errors: a `HRESULT_FROM_WIN32` value (0x8007xxxx) is returned as the raw OS error. Other failures are `io::Error::other`.
- Dependency: `windows` 0.62 under `cfg(windows)` (`Cargo.toml:18`), with features `Win32_Foundation`, `Win32_Security`, `Win32_System_Diagnostics_ToolHelp`, `Win32_System_JobObjects`, `Win32_System_ProcessStatus` (resource usage) and `Win32_System_Threading`. Consumers enable no Windows features.
- The code has no session or desktop check. Nothing here was run on Windows for this section.

Licensed under either MIT or Apache-2.0, at your option.