Skip to content

Commit c4f73cb

Browse files
committed
M1.8a: process control (Signal/Suspend/Resume/Members) + shared-group runner; fix POSIX multi-child containment
1 parent e72c53f commit c4f73cb

7 files changed

Lines changed: 569 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- Shell-free pipelines: `Command.Pipe(next)` builds a `Pipeline` that wires each stage's stdout into the next stage's stdin, running the whole chain in one kill-on-dispose group. The same verbs as a single command (`Run`/`RunUnit`/`OutputString`/`OutputBytes`/`ExitCode`/`Probe`/`Parse`/`TryParse`) plus `Pipeline.Timeout`/`CancelOn`. The exit status follows shell **pipefail** (the rightmost checked stage that did not exit 0 decides the result); `Command.UncheckedInPipe()` lets a stage fail without failing the pipeline.
2525
- Completed the `Command` convenience verbs: `RunUnit`/`ExitCode`/`Probe` now sit alongside `Run`/`OutputString`/`OutputBytes`, and every convenience verb has a `CancellationToken` overload.
2626
- `Supervisor` — keep a command alive with policy-driven restarts: `RestartPolicy` (`Always`/`OnCrash`/`Never`), exponential `Backoff` + `MaxBackoff` + `Jitter`, `MaxRestarts`, a failure-storm guard (`StormPause` + `FailureThreshold` + `FailureDecay`), and a `StopWhen` predicate, reporting a `SupervisionOutcome` (`FinalResult`/`Restarts`/`Stopped`/`StormPauses`) with `StopReason`. Runs through any `IProcessRunner` (`WithRunner`) so supervision is testable without spawning processes.
27+
- Process-tree control on `ProcessGroup`: `Signal` (the portable `Signal` type — `Term`/`Kill`/`Int`/`Hup`/`Quit`/`Usr1`/`Usr2`/`Other`; Windows delivers only `Kill`), `Suspend`/`Resume` (freeze/thaw the whole tree), `Members` (a pid snapshot), and `TerminateAll`.
28+
- `ProcessGroup` now implements `IProcessRunner` (`Start`/`OutputString`/`OutputBytes`): every run goes into that one shared kill-on-dispose group, so a fleet can share a container — e.g. `Supervisor.WithRunner(group)`. `ProcessGroup.Start` returns a `RunningProcess` whose lifetime the group owns.
2729

2830
### Changed
2931
-
3032

3133
### Fixed
32-
-
34+
- POSIX containment now reaps **every** child of a multi-child group (e.g. a pipeline), not just the last. Each `posix_spawn` forms its own process group, so the group tracks all of them; previously only the most-recent pgid was killed, letting an earlier long-running stage linger until its natural exit.
3335

3436
[Unreleased]: https://github.com/ZelAnton/ProcessKit-fSharp/commits/main

src/ProcessKit/Native.fs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,75 @@ module internal Native =
365365
GetExitCodeProcess(hProcess, &code) |> ignore
366366
Outcome.Exited(int code))
367367

368+
/// Hard-kill one Windows process (not its descendants — for that, terminate the whole Job).
369+
let terminateWindowsProcess (hProcess: nativeint) =
370+
TerminateProcess(hProcess, 1u) |> ignore
371+
372+
// Tree introspection / suspend-resume for the `process-control` surface.
373+
[<Literal>]
374+
let private JobObjectBasicProcessIdList = 3
375+
376+
[<Literal>]
377+
let private PROCESS_SUSPEND_RESUME = 0x0800u
378+
379+
[<DllImport("kernel32.dll", SetLastError = true)>]
380+
extern bool private QueryInformationJobObject(
381+
nativeint hJob,
382+
int infoClass,
383+
nativeint lpInfo,
384+
uint32 cbInfo,
385+
uint32& returnLength
386+
)
387+
388+
[<DllImport("kernel32.dll", SetLastError = true)>]
389+
extern nativeint private OpenProcess(uint32 dwDesiredAccess, bool bInheritHandle, uint32 dwProcessId)
390+
391+
// NtSuspendProcess/NtResumeProcess freeze/thaw every thread of a process in one call. They are
392+
// undocumented ntdll entry points but stable and the standard way to suspend a whole process;
393+
// the documented alternative (snapshot every thread + SuspendThread) is far more code.
394+
[<DllImport("ntdll.dll")>]
395+
extern int private NtSuspendProcess(nativeint hProcess)
396+
397+
[<DllImport("ntdll.dll")>]
398+
extern int private NtResumeProcess(nativeint hProcess)
399+
400+
/// Snapshot the pids assigned to a Job Object (the whole contained tree). A point-in-time view;
401+
/// a process can exit immediately after. Caps at 1024 members (ample for a diagnostic snapshot).
402+
let membersWindows (job: nativeint) : int list =
403+
let capacity = 1024
404+
let headerSize = 8 // two DWORDs: NumberOfAssignedProcesses, NumberOfProcessIdsInList
405+
let size = headerSize + capacity * IntPtr.Size
406+
let buffer = Marshal.AllocHGlobal size
407+
408+
try
409+
let mutable returnLength = 0u
410+
411+
if QueryInformationJobObject(job, JobObjectBasicProcessIdList, buffer, uint32 size, &returnLength) then
412+
let count = min (Marshal.ReadInt32(buffer, 4)) capacity
413+
414+
[ for i in 0 .. count - 1 -> int (Marshal.ReadIntPtr(buffer, headerSize + i * IntPtr.Size)) ]
415+
else
416+
[]
417+
finally
418+
Marshal.FreeHGlobal buffer
419+
420+
// Suspend / resume every member process of a Job. Best-effort and not atomic: a process can
421+
// spawn between the snapshot and the suspend; Windows keeps per-thread suspend counts, so nested
422+
// suspends stack and need matching resumes (unlike the level-triggered POSIX SIGSTOP/SIGCONT).
423+
let private forEachMemberHandle (job: nativeint) (action: nativeint -> unit) =
424+
for pid in membersWindows job do
425+
let handle = OpenProcess(PROCESS_SUSPEND_RESUME, false, uint32 pid)
426+
427+
if handle <> IntPtr.Zero then
428+
action handle
429+
CloseHandle handle |> ignore
430+
431+
let suspendWindows (job: nativeint) =
432+
forEachMemberHandle job (fun handle -> NtSuspendProcess handle |> ignore)
433+
434+
let resumeWindows (job: nativeint) =
435+
forEachMemberHandle job (fun handle -> NtResumeProcess handle |> ignore)
436+
368437
let private buildWindowsEnvironment (command: Command) : nativeint =
369438
if not command.Config.ClearEnv && List.isEmpty command.Config.EnvOverrides then
370439
IntPtr.Zero
@@ -640,6 +709,32 @@ module internal Native =
640709
/// True while any process remains in the group (signal 0 probes existence).
641710
let processGroupAlive (pgid: int) = killpg (pgid, 0) = 0
642711

712+
// SIGSTOP / SIGCONT numbers differ between Linux and the BSD/macOS table (so do SIGUSR1/2);
713+
// resolve them per-platform.
714+
let private sigStop = if isMacOs then 17 else 19
715+
let private sigCont = if isMacOs then 19 else 18
716+
717+
/// The raw POSIX signal number for a portable `Signal`, resolved for the current platform.
718+
let signalNumber (signal: Signal) : int =
719+
match signal with
720+
| Signal.Term -> SIGTERM
721+
| Signal.Kill -> SIGKILL
722+
| Signal.Int -> 2
723+
| Signal.Hup -> 1
724+
| Signal.Quit -> 3
725+
| Signal.Usr1 -> if isMacOs then 30 else 10
726+
| Signal.Usr2 -> if isMacOs then 31 else 12
727+
| Signal.Other n -> n
728+
729+
/// Broadcast a raw signal to a POSIX process group; `true` if the send was accepted.
730+
let signalProcessGroup (pgid: int) (signalNum: int) : bool = killpg (pgid, signalNum) = 0
731+
732+
/// Freeze a POSIX process group (SIGSTOP).
733+
let suspendProcessGroup (pgid: int) = killpg (pgid, sigStop) |> ignore
734+
735+
/// Thaw a POSIX process group (SIGCONT).
736+
let resumeProcessGroup (pgid: int) = killpg (pgid, sigCont) |> ignore
737+
643738
// Create a pipe whose ends are close-on-exec so a *different* concurrent spawn does not
644739
// inherit this run's pipe ends (which outlive the spawn). Linux sets it atomically with
645740
// pipe2(O_CLOEXEC); macOS lacks pipe2, and relies on POSIX_SPAWN_CLOEXEC_DEFAULT instead.

0 commit comments

Comments
 (0)