Run a command, exercise its shutdown contract, and detect unfinished cleanup, surviving subprocesses, and output pipes that never close.
ExitScope 0.1.1 is a Linux CLI for testing wrappers, task runners and launchers. It sends either SIGTERM to the wrapper PID alone or SIGKILL to its dedicated process group. It observes root exit, worker events, live cgroup members and stdout/stderr EOF independently. A successful harness cleanup never changes a failed shutdown check into PASS.
- Linux kernel 5.14+, unified cgroup v2, mounted procfs exposing the run's PIDs.
cgroup.killrequires 5.14; pidfds require 5.3. Seccomp/LSM must permit those syscalls,setsid, signals and migration into the test cgroup. - An already delegated, writable subtree. ExitScope creates only a fresh
child within the path passed in
--cgroup-parent; it does not mount cgroupfs, configure systemd, enable controllers, or elevate privileges. - Rust stable, minimum 1.85.0, Cargo. Python 3.11+ is needed only for the integration and upstream validation scripts. No Python packages are required.
cargo install --path . --locked
exitscope --help
exitscope run --config examples/parent-sigterm.json \
--cgroup-parent /path/to/delegated/runs
exitscope run --config examples/group-sigkill.json \
--cgroup-parent /path/to/delegated/runs --jsonRuntime support is Linux only. A macOS/Windows build reports the limitation; it is not a substitute for Linux validation. No claim of rootless operation on every Linux host is made.
Have your administrator or service manager delegate a private subtree with
permission to create directories and write cgroup.procs in the destination
and the common ancestor of source/destination. The new child's cgroup.kill
must be writable. Merely making a directory writable is insufficient.
The runner must stay outside its test child. Keep other users' processes out
of the delegated subtree, and do not share run directories between concurrent
tests. See kernel delegation rules.
One possible setup, when the user systemd manager supports delegation, is an explicit delegated service shell:
systemd-run --user --pty --collect --property=Delegate=yes bash
# Inside that shell:
parent="/sys/fs/cgroup$(awk -F: '$1 == "0" { print $3 }' /proc/self/cgroup)"
mkdir "$parent/harness" "$parent/runs"
printf '%s\n' "$$" > "$parent/harness/cgroup.procs"
exitscope run --config /absolute/path/to/profile.json \
--cgroup-parent "$parent/runs" --jsonThis is a setup example, not an automatic fallback; systemd versions and
administrator policy differ. Moving the shell into harness avoids keeping
the runner in a parent that may enable resource controllers. Verify delegation
locally. Do not recursively chown the global cgroup hierarchy or make it
world-writable. The tested environments and exact Docker and systemd setup commands are
recorded in the engineering report.
JSON is the only configuration format. Unknown fields are rejected. Start from
the profiles in examples/. Paths in cwd must be absolute. executable and
each args element are passed without shell interpolation; a shell is used
only if explicitly configured. {fixture} in either field expands to the
absolute ExitScope executable. The wrapper receives EXIT_SCOPE_BIN and
EXIT_SCOPE_SOCKET; other environment variables are inherited, never reported.
Invoke "$EXIT_SCOPE_BIN" fixture --cleanup-ms 200 through the wrapper's normal
workload mechanism. For pnpm this is a package.json script, for uv it is the
command after uv run. Adapter-specific setup and historical flags live in
scripts/external.py; containment, observations, verdict and cleanup live in
the common Rust engine.
readiness: "fixture" requires an acknowledged handler_installed followed by
ready event. The worker has installed a blocked-signal/signalfd handler before
these events. readiness: "exec" means only successful exec of the command,
not application readiness. Use it for uninstrumented commands only when
that is the readiness contract you intend. It cannot establish correct signal
receipt or cleanup. An otherwise successful run with required graceful evidence
but no ready worker is UNRESOLVED.
readiness_ms runs from harness start; shutdown_ms and output_ms run from
the test impact. All are monotonic, bounded to 1..3600000 ms. EOF and shutdown
have separate deadlines, both measured from impact. v0.1 always requires root
exit by shutdown_ms. Contract booleans additionally select signal receipt,
finished cleanup, root waiting for cleanup, no live members, and both stream
EOFs. Setting no_survivors: false permits intentional background processes
in the observation verdict; disposable run members are still killed afterward.
SIGKILL profiles cannot require graceful events.
When signal_receipt is enabled, SIGTERM confirmation must be observed at
signal_received.at_ms <= impact_at_ms + shutdown_ms (inclusive boundary,
integer monotonic milliseconds). This is the observer's receipt time, not the
kernel's signal delivery time. A larger output_ms only extends the EOF window.
Late confirmation remains in the trace, but with acknowledged worker readiness
produces SIGNAL_NOT_RECEIVED and FAIL. Missing required telemetry without
confirmed readiness remains UNRESOLVED unless another violation establishes FAIL.
Disabling signal_receipt makes a late receipt evidence only: a selective contract
requiring just timely root exit and output EOF can still PASS. Other enabled
conditions keep their existing deadlines and semantics.
The runner launches a fresh copy of itself as a gated launcher. It enters the
new cgroup before executing any wrapper code, calls setsid, authenticates
to the harness over a private Unix stream socket, and waits for ACK. The diagnostic
connection is opened before cgroup entry so failed delegation can report its errno. The harness
checks cgroup membership, session/group identity and opens a root pidfd before
releasing the gate. CLOEXEC closure of the gate indicates successful exec;
exec failures are infrastructure errors. stdin is /dev/null.
For SIGTERM, only the root pidfd receives the test signal. The harness never
forwards SIGTERM to descendants. For SIGKILL, the test signal goes to the root's
dedicated group. The still-unreaped root anchors its PGID against PID reuse at
impact; a private session prevents cooperative unrelated processes from joining.
Process identities contain PID, /proc start ticks, PPID, PGID, SID and state.
Membership comes from cgroup v2, including nested cgroups, rather than PPID or
command matching. Survivor identities are checked with pidfds and membership
again to reject exit/reuse races.
The control socket is independent of output pipes. SO_PEERCRED authenticates
peer PIDs and pidfds pin them; only live run members are accepted. One
instrumented worker is supported. Frames are newline-delimited JSON of exactly
{"kind":"EVENT"}, each followed by harness ack\n. signal_received additionally
requires a numeric signal field, for example {"kind":"signal_received","signal":15}:
handler_installedreadysignal_received(the controlled workload's SIGTERM)cleanup_startedcleanup_finished
Each frame must wait for its ACK. In particular, the worker stays alive after
cleanup until the final ACK. The harness checks root pidfd liveness before that
ACK. root_waits_cleanup is therefore a strict witnessed contract: root must
still be alive at the acknowledged completion boundary. A wrapper intentionally
exiting immediately when it independently sees cleanup completion can fail this
stricter profile; disable that condition if it is not your contract. External
workloads implementing this protocol are trusted instrumentation, not adversaries.
Nonblocking reads and bounded draining keep pipes, control messages and process
observation independent. Read ends and accepted control connections stay open
through verdict recording and forced cleanup. A 2 ms bounded wait reduces CPU
use; it is never used to prove readiness. Observation receipt timestamps have
scheduler/polling uncertainty. A kernel event near a deadline may be observed
after it and fail a strict deadline contract. Cgroup emptiness is sampled throughout
shutdown; cgroup_empty_at_ms records its first observation. A late empty snapshot
without emptiness witnessed by the deadline yields UNRESOLVED, never PASS.
Queued events from an already authenticated worker remain evidence after its death;
interrupted cleanup is a target failure, not an infrastructure failure.
PASS, exit 0: all required conditions have evidence.FAIL, exit 1: an observed contract violation. Missing required graceful events after confirmed fixture readiness is a violation.UNRESOLVED, exit 2: no observed violation, but required evidence is absent.INFRASTRUCTURE_ERROR, exit 3: invalid config, containment/exec/IPC/observation failure, readiness failure, runner interruption, or failed harness cleanup. clap syntax errors use its standard exit 2, with no run report.
Human output is default; --json emits schema version 1. Reports include the
scenario and contract, capabilities observed during setup, root identity and
exit disposition (code versus signal), event timestamps, survivors at the
shutdown snapshot (with its actual timestamp), first observed cgroup emptiness,
stream byte counts and EOF times, impact/snapshot/verdict
times, stable findings, cleanup actions and limitations. observed_outcome
is frozen before cleanup. outcome ordinarily matches it but becomes
INFRASTRUCTURE_ERROR if forced cleanup fails. Cleanup does not rewrite event,
survivor, EOF or root-exit evidence. Root exits during forced cleanup are not
presented as target shutdown success.
Stable finding IDs: ROOT_DEADLINE, LIVE_MEMBERS, STDOUT_OPEN, STDERR_OPEN,
SIGNAL_NOT_RECEIVED, WRONG_SIGNAL, CLEANUP_INCOMPLETE, ROOT_DID_NOT_WAIT, CLEANUP_DEADLINE,
TELEMETRY_MISSING, CGROUP_DEADLINE_UNOBSERVED, READINESS_TIMEOUT, RUNNER_INTERRUPTED, INFRASTRUCTURE,
OBSERVATION_ERROR, CLEANUP_ERROR. IDs may have more detailed text per run.
SIGNAL_NOT_RECEIVED means no SIGTERM confirmation by the shutdown deadline;
its detail distinguishes an absent confirmation from an observed late one.
launch_error is null or an object containing a bounded stage name and numeric
errno. Stages distinguish signal mask, cgroup entry, session creation, working
directory and exec failures; neither command arguments nor raw error output is
stored. ROOT_DID_NOT_WAIT requires a witnessed failed completion handshake,
rather than absence of a completed handshake. Late cleanup affects the verdict
only when cleanup_finished is required.
Configuration input is limited to 64 KiB, IPC to 16 peers, 64 events, and 4 KiB per frame buffer. Cgroup traversal allows 256 directories and 4096 PIDs. Raw stdout/stderr is not retained, only bounded counters; flooding output is drained without unlimited memory. argv and executable are omitted from reports to avoid recording command-line credentials. Reports never contain a full environment. Paths and known controlled fixture events are still visible.
After observations and verdict freeze, an already-open descriptor to this
run's cgroup.kill kills only members of that cgroup and its descendants,
regardless of their process groups, sessions or reparenting. The harness waits
up to two seconds for populated 0, removes nested run directories, reaps its
own direct child with a bounded wait, and removes private IPC files. There is
no recursive PID kill fallback. A not-yet-released owned launcher can be killed
by its pidfd if setup fails before it joins the cgroup.
SIGINT/SIGTERM directed at ExitScope are caught through signalfd and yield an infrastructure report followed by the same cleanup. Intermediate errors use the same path; RAII also attempts cleanup on unwinding. Repeated interruption does not abort forced cleanup. SIGKILL, process abort, host failure, persistent uninterruptible kernel tasks, lost permissions or removed cgroupfs can defeat cleanup. The printed run cgroup path identifies residual resources for the operator. No detached privileged watchdog is installed.
ExitScope is not a security sandbox. The target must not deliberately migrate
out, forge telemetry, manipulate cgroup directories, move to the runner's
session, or kill the observer. Migrated-out target PIDs are not blindly signaled.
Run trusted workloads in a private namespace/subtree. cgroup.procs excludes
zombies: an empty live set is not proof of correct reaping. There are no PTY,
terminal Ctrl-C, Windows/macOS runtime, HTTP draining, production supervision,
eBPF tracing, arbitrary fault injection, or universal shutdown guarantees.
EXITSCOPE_CGROUP_PARENT=/path/to/delegated/runs sh scripts/linux-checks.sh
python3 scripts/external.py --cgroup-parent /path/to/delegated/runsThe external batch downloads SHA256-pinned upstream archives and uses disposable
projects/caches. It pins Linux ARM64 and x86_64 assets. --discover is an explicit
maintainer bootstrap that changes the source manifest; normal validation verifies
it. Each batch writes a new directory below validation/; unexpected and unresolved
outcomes are saved before checking expectations. CI is configured to execute the
same engine on native Linux ARM64 (ubuntu-24.04-arm) and x86_64 (ubuntu-24.04), preserves architecture
specific artifacts even on failure, and checks Rust's minimum version. Formatting,
clippy and minimum Rust checks run once; build, unit, integration, observer,
delegation and all four pinned external cases run on each architecture.
Generated batch output is ignored by Git. The tracked manifest, selected deadline reports and permanent historical evidence archive are described in validation/README.md. Full CI reports are uploaded as artifacts; they are not required to build or run ExitScope.
Local v0.1.1 evidence uses Debian 13 containers on Docker Desktop's Linux
7.0.14-linuxkit: ARM64 execution is native and x86_64 execution uses emulation
on the ARM64 development host, where pidfd_open returns ENOSYS and blocks runtime
validation. x86_64 build and pure tests succeeded, but Linux conformance and its
four external outcomes were not established by that emulation attempt. Subsequent
hosted native ARM64 and x86_64 runs passed all suites and the four expected external
cases on each architecture. The systemd recipe passed outside Docker on an Ubuntu
24.04.5 x86_64 VM, kernel 6.17.0-1022-azure, systemd 255.4, under ordinary UID 1001
inside an explicitly delegated service. Kernel 5.14 is a documented requirement,
not an execution tested minimum. See the report for exact commands and evidence.
Own results, upstream reports, reproduction details and remaining uncertainty are distinguished in docs/engineering-report.md.