ProcessGroup Type
A kill-on-dispose container for a process *tree*. Every process started into the group — and everything those processes spawn — is reaped when the group is disposed (deterministic under `use`) or, failing that, when the GC finalizes it. The OS primitive is chosen at creation and reported honestly by `Mechanism` — a Windows Job Object (`KILL_ON_JOB_CLOSE`), a Linux cgroup v2 (when resource limits are requested), a FreeBSD `procctl(2)` process reaper (whole-tree, `setsid` escapees included), or a POSIX process group (`killpg` teardown). All of that lives behind an `IContainmentBackend`; this type only orchestrates once-only teardown, the stdin/stream wiring, and the runner/disposable seams.
Instance members
| Instance member |
Description
|
|
Adopt an already-running **external** process — one this group did not start (created by other
code, inherited from another layer, or located by pid) — into the container, so from now on it
obeys the same whole-tree rules as a process started with `StartAsync`: kill-on-dispose, and
participation in `Signal`/`Suspend`/`Resume`/`Members`/`MembersInfo`/`Stats` and any resource
limits. This restores the "kill the whole tree" guarantee for a wrapper whose child was not
launched through ProcessKit.
**The argument is a `System.Diagnostics.Process`, not a bare pid — deliberately.** A raw pid is
subject to number reuse: between the caller obtaining it and the adopt landing, the OS may have
recycled it onto an unrelated process, which the adopt would then contain by mistake. A live
`Process` holds an open OS handle to the target, which on **Windows** pins the pid (the OS will not
reuse it while a handle is open), so the adopt cannot race a recycle; this call keeps the `Process`
alive across the native adopt for exactly that reason. On **Linux** there is no handle that pins a
pid, so the `Process` gives the pre-adopt `HasExited` guard and a narrower window, but a pid
recycled in the residual window cannot be fully ruled out by number alone — the honest limitation
is documented, not hidden.
A caller who has only a number — from a pidfile, a registry, an FFI or IPC boundary — cannot use
this overload at all, and `AdoptByPid` is the door for that case: it takes an identity anchor of
its own for whatever the number currently names (the process object on Windows, cgroup membership
on cgroup v2, a re-verified start-time token on the POSIX process group) rather than trusting the
number. It is a strictly weaker guarantee than the pinning handle THIS overload gets on Windows,
because nothing can close the window before the call — so where a live `Process` is available, this
one stays the better choice.
**Per-platform behaviour** (honest and typed, never a silent no-op):
* **Windows (Job Object)** — `AssignProcessToJobObject`; supported with or without limits.
* **Linux cgroup v2** — writes the pid to the group's `cgroup.procs`; available only on a group
created **with** resource limits (which is what selects the cgroup mechanism). A plain,
limit-free Linux group uses the POSIX process-group mechanism and cannot adopt (below).
* **POSIX process group (macOS and the other BSDs, or Linux without limits)** —
`ProcessError.Unsupported`: `setpgid` only relocates our own children before they `exec`, so a
foreign process cannot be moved into our group at all.
* **FreeBSD process reaper** — `ProcessError.Unsupported`, refused by the POSIX process group it
layers over: a reaper contains this process's own *descendants*, and no `procctl(PROC_REAP_*)`
call turns a foreign process into one (`PROC_REAP_ACQUIRE` does not even re-attach children
forked before it ran).
**Edge cases**, each a distinct typed failure rather than a fabricated success:
* a `null` argument throws `ArgumentNullException` (a programming error, surfaced eagerly);
* a process that has already exited, or a pid that no longer exists (a TOCTOU race lost to the
target's own exit), returns `ProcessError.Adopt`;
* missing rights to the foreign process returns `ProcessError.Adopt` (a typed error, not an
escaping exception);
* a Windows process already assigned to a Job that does not permit nesting on this OS
configuration returns `ProcessError.Adopt` — never a "looks like it worked" no-op.
**The adopted process is not our child**, so ProcessKit does **not** reap it via `waitpid` and does
**not** signal its process group (which it does not own): the container primitive alone contains and
kills it — the Job's `KILL_ON_JOB_CLOSE` / a cgroup `cgroup.kill` at teardown — while its real
parent (or `init`, once reparented) reaps it. The **exit-observation (wait) path is the caller's
own `Process`** (`Process.WaitForExitAsync` / `HasExited`); this method adds containment, and returns
`Result
|
|
Adopt an already-running **external** process into the container from a **bare pid** — the door for
a process this library did not start and for which the caller holds no `System.Diagnostics.Process`
at all: one an outside supervisor launched, one whose id came from a pidfile, a registry or an
FFI/IPC caller. `Adopt(Process)` covers only the case where a live `Process` object is in hand; this
takes the one identifier every such caller does have. Both are unchanged in what containment means:
kill-on-dispose, and participation in `Signal`/`Suspend`/`Resume`/`Members`/`MembersInfo`/`Stats`
and any resource limits.
### A pid is an address, not a handle
The number is used to *find* the process; it is not what the group keeps afterwards. Once a process
is reaped the OS may give its number to an unrelated one, so this call captures an **identity
anchor of its own** for whatever the number names at the moment it runs, and binds the group to
that:
* **Windows (Job Object)** — the process **object**. The number is used exactly once, by this
call's `OpenProcess`; `AssignProcessToJobObject` then puts that object into the Job, and the
kernel keeps membership per object. Nothing later resolves the number again.
* **Linux cgroup v2** — kernel-maintained **cgroup membership**, per task. A `/proc/
|
|
Immediately hard-kill every process currently in the group (the honest name for the tree kill — no graceful signal). Idempotent; after an `Ok` return the group stays usable for further spawns. Returns `Result` for parity with the other tree-control verbs. On Linux cgroup v2, both the atomic `cgroup.kill` path and its legacy per-member fallback explicitly thaw and verify the reusable cgroup before returning `Ok`. A freezer that still reports frozen or cannot be read returns `ProcessError.Io`; an already-unfrozen or removed freezer remains a best-effort success. On a kernel without `cgroup.kill` (< 5.14), the legacy fallback can additionally report `ProcessError.Io` when a member could not be signalled AND the cgroup is still populated — or its membership unreadable — once the sweep ends. A cgroup that did drain is not reported as a kill failure, because the drain check is the authority on whether the tree died. That delivery case includes a kernel without pidfd (< 5.3), where the identity-safe sweep kills nothing at all rather than downgrading to a raw `kill(pid, ...)` that a recycled pid could land on an unrelated process. An error makes no reuse guarantee; final disposal still runs its bounded best-effort drain and reclaim attempt, recording a cgroup it could not remove. **Windows** reports `ProcessError.Io` when `TerminateJobObject` is REFUSED and the Job still holds live members (or its accounting cannot be read at all, which is never read as "drained"). A refusal on an already-empty Job stays a successful no-op, so repeating `KillAll` on a reaped tree — or racing the tree's own exit — is idempotent as before. Closing the Job handle (`Dispose`) still kills the tree through `KILL_ON_JOB_CLOSE`, but that backstop fires at teardown, not now, so it is deliberately NOT reported as a completed kill here.
|
|
Post-run, per-axis evidence of whether a resource cap this group ever configured actually **fired** — the question a plain exit code or signal cannot answer, and the other side of `ProcessError.ResourceLimit` (which answers "could the cap be applied at all", never "did it fire"). One `LimitVerdict` (`Tripped`/`NotTripped`/`Unknown`) per axis — `Memory`/`Processes`/`Cpu` — read from the container's own authoritative post-mortem counters, never re-derived from the `ResourceLimits` that requested the cap and never inferred from the run's exit code or signal (a cap-driven kill and a self-inflicted crash can look identical from the outside — see `LimitVerdict`'s own doc comment). **Available only after the group has been torn down** — `ShutdownAsync`/`Dispose`/`DisposeAsync`, or the finalizer — deliberately the OPPOSITE lifetime rule `Stats()` follows. The evidence is captured exactly once, from the still-live container, in the instant immediately before its counters (and, for a Linux cgroup v2 group, the cgroup directory itself) are torn down, and is then cached: any number of later reads — including well after teardown, on any thread — return that same immutable snapshot rather than a re-derived or stale one. Calling this **before** teardown has completed returns a non-transient `ProcessError.Unsupported`: an honest "not yet available", never a fabricated verdict read off counters that are still changing. Per axis, deliberately never folded into one whole-group verdict — see `LimitVerdict` for what each value means, and `LimitEvidence` for why folding would misrepresent "no evidence" as "no" (and for why `IoMax`/`CpuAffinity` have no axis here at all). Only the Linux cgroup v2 mechanism can ever answer `Tripped`/`NotTripped` from real evidence (see `Native.Cgroup.limitEvidence` for exactly which kernel counters back each axis, and `LimitEvidence.Cpu` for the further `CpuTimeMax` refinement that applies on every mechanism). The two mechanisms differ in what they say about an axis this group never capped: a Windows Job Object keeps no post-mortem record that any of these caps fired, so every axis it ever capped reads `Unknown` — not an oversight, a measured conclusion about what it actually preserves after the fact — but an axis it never capped still reads `NotTripped` (nothing was capped, so nothing could fire) without touching native at all. The POSIX process-group fallback and the FreeBSD process reaper have no whole-tree resource-accounting apparatus whatsoever — the same reason `Create`/`UpdateLimits` refuse any whole-tree cap on either of them — so both answer `Unknown` on every axis UNCONDITIONALLY, including one this group never capped: there is no "nothing was capped" case that lets them read `NotTripped` the way a Job Object can, because they never had any evidence apparatus to begin with.
|
|
The OS primitive containing this group on the current platform.
|
|
Return a point-in-time resource snapshot for each current member. CPU time and resident memory are optional per member, and I/O counters are optional on platforms without a per-process counter API. The native backend owns both membership enumeration and metric reads under this lifecycle gate: a member that vanishes during sampling is omitted, and a recycled pid cannot contribute metrics from a foreign process.
|
|
The pids of the processes currently in the group — a point-in-time snapshot. On Windows the whole Job tree, on cgroup v2 the whole cgroup, on the FreeBSD process reaper the whole live descendant tree (`setsid` escapees included, exited members excluded), on the POSIX fallback the tracked group leaders plus any process adopted by pid (`AdoptByPid`) whose identity anchor still matches.
|
|
An enriched, point-in-time snapshot of the group's members: the same pids `Members` reports, in the same platform matrix (the whole Job tree on Windows, the whole cgroup on cgroup v2, the whole live descendant tree on the FreeBSD process reaper, the tracked group leaders and any anchor-matching `AdoptByPid` member on the POSIX fallback), each carrying its parent pid, executable image name, and OS-reported start time **where the platform can honestly report them** — every enriching field is `option` and is `None` otherwise, never a fabricated value. A member that exits between the enumeration and its metadata read is **omitted** rather than filled with invented fields. The member's command line and environment are **never** included on any platform — argv routinely carries secrets and redaction is the consumer's policy — the same exclusion the logging / tracing / metrics paths enforce. Enumeration errors the same typed way as `Members` once the group is released.
|
|
The options the group is currently configured with (shutdown grace, resource limits). The resource limits reflect the latest successful `UpdateLimits`, not only the create-time set.
|
|
Resume a tree suspended by `Suspend`. Failed native delivery or cgroup thaw writes return `ProcessError.Io`, on the same terms as `Suspend`; a member that exited concurrently is a successful no-op.
|
Full Usage:
this.SampleStatsAsync
Parameters:
TimeSpan
Returns: IAsyncEnumerable<ProcessGroupStats>
|
A periodic `ProcessGroupStats` series: the first sample immediately, then one per `interval`. **Pull-based** — it samples only as the enumeration is pulled and runs no background task, so it neither keeps the group alive nor leaks if abandoned. The series ends on the first snapshot the group fails to report (notably after it is torn down) or when the enumerator's token fires. A non-positive `interval` (`<= TimeSpan.Zero`) is rejected with `ArgumentOutOfRangeException`, thrown eagerly by this call rather than deferred to enumeration — a sampling cadence must be a positive duration.
|
|
`ShutdownAsync` using the group's configured `Options.ShutdownTimeout`.
|
|
Tear the group down gracefully, then release it. On Unix: the configured `Options.StopSignal`, then SIGKILL if still alive after `gracePeriod`. On Windows: best-effort `WM_CLOSE`, then the atomic Job kill. A negative `gracePeriod` is rejected with `ArgumentOutOfRangeException`; `TimeSpan.Zero` escalates immediately. Idempotent with `Dispose` in the sense that matters — the one-shot teardown (`hardRelease`) still runs exactly once no matter how many callers race `ShutdownAsync`/`Dispose`/`DisposeAsync`, and every `GracefulKillTree` failure still leaves the container released, never leaked. It is NOT idempotent in the sense of "every call observes the same completion": the caller that loses the `claimRelease` race returns immediately without waiting for the winner's (possibly still in-flight, up-to-`gracePeriod`) teardown to finish — unlike `RunningProcess.StopAsync`, which funnels concurrent callers onto one shared conclusion. A loser that needs "the tree is fully torn down" must await the SAME `Task` the winner returned, or otherwise synchronize with it itself.
|
|
`ShutdownReportAsync` using the group's configured `Options.ShutdownTimeout`.
|
Full Usage:
this.ShutdownReportAsync
Parameters:
TimeSpan
Returns: Task<Result<ShutdownReport, ProcessError>>
|
Gracefully tear the group down, additionally reporting what the teardown **actually observed** — the introspective sibling of `ShutdownAsync(gracePeriod)`. Drives the EXACT SAME teardown as `ShutdownAsync(gracePeriod)` — the configured `Options.StopSignal` (a best-effort `WM_CLOSE` on Windows), then up to `gracePeriod` for the tree to drain, then an UNCONDITIONAL hard kill of any survivor, then release — and is purely additive: it changes nothing about `ShutdownAsync`'s own behaviour or signature. What it adds is the `ShutdownReport`: the soft signal's fate, the member counts before/after, whether the tree drained within the grace or needed the hard kill, and how long it actually took — so a caller that owns its own end-of-run race (a deadline that is not a fixed timeout, but a timeout x Ctrl-C x control-socket race) can report the OBSERVED tier instead of re-deriving it from `ShutdownAsync`'s bare success. # Deliberately NOT a "keep the group usable, spare survivors" mode ProcessKit-rs's `ProcessGroup::stop` additionally takes an `escalate: bool` that, when `false`, leaves any survivor running and keeps the group usable — seemingly the Rust source this method otherwise mirrors closely. This port deliberately does not offer that combination — see the "Deliberate divergence" note above `GracefulKillTree` for why: this port's kill-on-drop guarantee is unconditional, and there is no honest way to offer "spare the survivors" on a teardown that still releases the container the way `ShutdownAsync`/this method both document. On Windows in particular, closing the Job handle unconditionally kills whatever the Job still holds (`KILL_ON_JOB_CLOSE`), so a "spared" survivor would be silently killed anyway by the very release this method still performs at the end — reporting `Escalated = false` there would misrepresent a kill this call itself causes as a spare. `Escalated` instead answers an honest, narrower question: did THIS teardown's grace window need the hard kill, or did the tree drain on the soft signal alone. See `ShutdownAsync(gracePeriod)`'s own doc comment for the full teardown/idempotency contract this shares (negative-grace rejection, `Dispose`/`ShutdownAsync` idempotency, the "only the winner awaits the in-flight teardown" rule). A caller that loses the underlying release race — a concurrent `Dispose`/`ShutdownAsync`/`ShutdownReportAsync`/finalizer already claimed it — gets `ProcessError.Unsupported` here rather than a fabricated report, since there is no teardown left for THIS call to have observed.
|
|
Broadcast `signal` to every process in the group. A member that has already exited (or an already-empty group) is a best-effort success — that races the target's own exit, not a caller error. `Signal.Other 0` (a liveness *probe* that delivers nothing) and a negative number are not deliverable signals at all: they are refused up front with `ProcessError.Unsupported`, never a false success, even on an empty group. A number that IS a signal but the platform rejects (e.g. an out-of-range `Signal.Other 999`, EINVAL) is a genuine delivery failure and returns `ProcessError.Io` with the errno detail; when the group has several members, the first genuine failure is reported but every member still receives the signal. On **Windows** `Signal.Kill` maps to the atomic Job terminate, and reports a refusal on exactly the terms `KillAll` documents — `ProcessError.Io` while the Job still holds live members, an idempotent success once it is empty. `Signal.Int` and `Signal.Term` are a best-effort soft stop combining two individually-targeted deliveries: a console **CTRL+BREAK** to each child started with `Command.WindowsCtrlSignals()` (spawned in its own console process group) AND a **`WM_CLOSE`** posted to the top-level windows of every member that has one (a windowed Electron/GUI child), targeted strictly by process id so no foreign window is touched. Either mechanism reaching a member is a best-effort `Ok`; the call returns `ProcessError.Unsupported` ONLY when the group has neither a CTRL-capable child nor a windowed member — never a silent downgrade to a kill. Delivery is not compliance even on success — a child may install its own handler or a window may prompt/veto the close. Every other Windows signal returns `ProcessError.Unsupported`. On Linux cgroup v2, `Signal.Kill` uses the same reusable hard-kill path as `KillAll`, including its verified post-kill thaw and the same `ProcessError.Io` failure terms.
|
|
How far a soft stop (`Signal.Int`/`Signal.Term`) reaches on this group's CURRENT live membership, right now — see `SoftStopScope` for the full per-mechanism table. A capability report, not an action: side-effect-free, delivering no signal and mutating nothing, so asking never changes the answer a later `Signal`/`ShutdownAsync`/`ShutdownReportAsync` soft stop gets. Errors the same honest way every other control verb does once the group is released (`ProcessError.Unsupported`) — there is no live membership left to read.
|
Full Usage:
this.StartAsync
Parameters:
Command
?cancellationToken : CancellationToken
Returns: Task<Result<RunningProcess, ProcessError>>
|
Start `command` into this shared group and return a live `RunningProcess`. The **group** owns the child's lifetime: disposing the returned process detaches its I/O but does not kill it — reap the tree with `ShutdownAsync`/`Dispose`, or this one run with its `Kill`. `cancellationToken` is checked once, before the spawn (an already-cancelled token reports `ProcessError.Cancelled` and starts nothing); once the child is running the token is not tracked — this live handle is caller-driven, so kill or reap it yourself. (The capture/completion verbs on a `ProcessGroup` do watch the token — and the command's `CancelOn` — for the whole run.)
|
|
A snapshot of the group's resource usage. On Windows this reads the Job Object's accounting (CPU + peak committed memory + I/O + active count); on cgroup v2 it reads the cgroup accounting; on the POSIX fallback only the live count (its tracked groups plus any anchor-matching `AdoptByPid` member) is available, and on the FreeBSD process reaper only the live count as well — the whole contained tree there, since a reaper contains a tree but accounts for nothing in it. Errors once the group is released.
|
|
Suspend (freeze) every process in the group. POSIX: `SIGSTOP` (level-triggered, idempotent). Cgroup v2: `cgroup.freeze`. Windows: suspend every thread of every member; suspend counts stack, so N `Suspend`s need N `Resume`s. A failed native delivery or cgroup write returns `ProcessError.Io` — including a Windows member that was confirmed to be in the job and still could not be suspended, which is reported even when the other members were frozen (a partial freeze is reported, not rolled back). A member that exited concurrently is a successful no-op on every platform, as is a Windows pid that was recycled onto an unrelated process.
|
Full Usage:
this.UpdateLimits
Parameters:
ResourceLimits
Returns: Result<unit, ProcessError>
|
Apply a new whole-tree resource-limit set to this **live** group, without recreating it or restarting its children — adaptive resource control (tighten memory on a sagging batch, widen a long-lived worker pool's CPU quota) at runtime. The `limits` are a full REPLACEMENT of the caps in force: a dimension left `None` becomes unbounded again, not left at its previous cap. A limit-capable mechanism re-applies the caps to its live container — **Windows** re-issues `SetInformationJobObject` on the Job (the caps, the CPU-affinity mask, and the UI restrictions), **Linux cgroup v2** rewrites `memory.max`/`memory.oom.group`/`pids.max`/`cpu.max`/`cpuset.cpus` in place — while the **POSIX process-group** mechanism (macOS/BSD, or Linux without cgroup v2) has no whole-tree limit primitive and returns `ProcessError.ResourceLimit`, the same honest, typed refusal `Create` gives for a limited group there — never a silent no-op. The **FreeBSD process reaper** answers that same `ProcessError.ResourceLimit` for its own reason: it contains a whole tree but accounts for nothing in it, so there is no whole-tree cap to update — and never a per-process `RLIMIT_*` surrogate presented as one. On Linux the CPU-affinity pin additionally needs the `cpuset` controller: a hierarchy that does not carry it at all cannot enforce a pin, so an update requesting one is refused with that same typed `ProcessError.ResourceLimit` — and refused before any controller file is written, so the previous caps stay in force — never a dropped pin. Routed through the same lifecycle gate as the other control verbs: the released-flag check AND the native re-apply run in one critical section, so a call racing (or following) teardown either applies fully on the live container or observes the flag and returns a non-transient `ProcessError.Unsupported` BEFORE touching the closed/recycled native handle — never a use-after-teardown. Only on a successful apply is the `Options` snapshot swapped to the new set, under the same lock, so `Options.Limits` a consumer reads back matches what is actually enforced. The caps land through several sequential native writes, so a later write can fail after an earlier one applied. A failed apply is honest about that: a limit-capable mechanism best-effort **restores the previous set** to its live container before returning the error, so a failed `UpdateLimits` leaves BOTH the container and the `Options` snapshot on the previous set (nothing net changed) — the container and `Options.Limits` never silently diverge. In the rare case the restore itself also fails, the returned `ProcessError.ResourceLimit` says so explicitly (its message notes the limits may be partially applied) and `Options.Limits` keeps reporting the previous set: the error is the explicit signal that the container's state is uncertain, so a consumer must not treat the snapshot as authoritative there — never a silent divergence.
|
Static members
| Static member |
Description
|
Full Usage:
ProcessGroup.Capabilities(options)
Parameters:
ProcessGroupOptions
Returns: ContainmentCapabilities
|
What process containment can actually do on **this** host for `options` — the mechanism `Create(options)` would select, and, per axis, either a real availability or a typed `Capability.Unsupported` naming the precondition that is missing. Taken WITHOUT creating a group, spawning a process, or touching any container, so a long-lived orchestrator can choose a portable policy *before* the first spawn instead of creating a group and probing each operation. The mechanism it reports comes from the very decision `Create` dispatches on, and each capability from the very probe the corresponding spawn path consults, so the snapshot cannot drift from what the real calls do. It is a point-in-time report and deliberately not cached — see `ContainmentCapabilities` for what that does and does not promise.
|
|
What process containment can actually do on **this** host, for the default options — a side-effect-free snapshot taken WITHOUT creating a group or spawning anything. See the `ProcessGroupOptions` overload.
|
Full Usage:
ProcessGroup.Create(options)
Parameters:
ProcessGroupOptions
Returns: Result<ProcessGroup, ProcessError>
|
Create a new kill-on-dispose group with `options` (graceful-shutdown window and whole-tree resource limits). When `options.Limits` is set, the group needs a limit-capable mechanism — a Windows Job Object or a Linux cgroup v2 at the real cgroup root; otherwise creation fails fast with `ProcessError.ResourceLimit` rather than leaving the tree unbounded. Without limits the group uses the platform's default mechanism (Job Object on Windows, the `procctl(2)` process reaper on FreeBSD when reaper status can be acquired, POSIX process group elsewhere). On the Linux cgroup v2 mechanism, a spawned child is migrated into the cgroup right after it starts, and the limits then apply to it and every descendant it forks *afterwards*. A grandchild forked in the brief spawn→migrate window is created in the parent cgroup and stays there — still reaped by kill-on-drop teardown, but outside the resource limits. If the child cannot be migrated at all (e.g. the cgroup was torn down underneath the spawn), it is killed and reaped and the spawn fails with `ProcessError.ResourceLimit` — never left running unconstrained.
|
|
Create a new, empty kill-on-dispose group on the current platform (no resource limits).
|
ProcessKit API Reference