Own native subprocesses in a structured scope.

Start an executable with typed arguments, exchange bytes through task-aware pipes, classify exit status, and clean up descendants that remain in the owned process group.

MODEL

Separate the command from the process owner.

A Command is a copyable launch description. Build it with To_Command. It contains the executable, ordered arguments, environment policy, working directory, and path-search policy.

A Process is the limited owner created by Spawn. It owns the root process, its process group, the parent ends of three pipes, and the reaper that prevents a zombie.

One owned subprocess lifecycle A typed command is spawned into a process owner. The owner connects standard input, output, error, and exit readiness to a native child process group. Observation, termination, and release complete the structured scope. Typed command executable + argv Process owner pipes + group + reaper Native child group blocking fd 0, 1, 2 stdin writer write, then close for EOF stdout + stderr readers drain both to EOF exit wake reaper publishes status Observe or stop, then release

The child receives blocking descriptors 0, 1, and 2. The parent ends are nonblocking and close-on-exec. A lightweight caller suspends only its task on pipe or exit readiness. A native caller can block only its own pthread.

CHOICE 01

Choose capture or explicit pipe ownership.

Flyology.Subprocesses.Capture
Use one finite input value and bounded retained output. The helper owns all pipe progress, exit waiting, timeout cleanup, and cancellation cleanup.
Owned Process
Use a streaming or interactive protocol, incremental parsing, custom signal policy, or output destinations that should not accumulate in memory.
Native-task boundary
Use this boundary when synchronous spawn latency is not acceptable on an event-loop pthread. Pipe and exit waits remain task-aware after spawn.

Capture is the normal choice for short control commands. Use the low-level owner only when the application must control the stream lifecycle itself.

COMMAND 02

Build argv and launch policy explicitly.

Arguments
Call Append_Argument once for each exact argv value. Empty arguments remain present. NUL bytes are rejected.
Environment
The default inherits the complete parent environment. Set_Environment_Variable immediately switches to an explicit environment containing only variables set on that command. Call Clear_Environment first when an intentionally empty starting point makes that choice clearer. Inherit_Environment restores inheritance.
Directory
The default inherits the parent working directory. Set_Working_Directory applies a spawn file action before the executable starts.
Path search
The default requires an executable path. Set_Path_Search selects posix_spawnp. Lookup uses the parent process's PATH, even when the child receives an explicit environment.

A value such as "*.txt", "$HOME", or "a | b" remains one literal argument. When the application needs a pipeline, start separate processes and pump bytes through their parent pipe operations, or use a separate pipeline adapter.

CAPTURE 03

Run a finite command with bounded output.

Capture.Run progresses stdin, stdout, stderr, process exit, and cancellation in one readiness loop. It returns a bounded Result only after the root is reaped and all three pipes are closed.

Test Successful, then read Standard_Output. On command failure, inspect Standard_Error and the captured Status. That Exit_Status has an Exit_Kind and the Kind, Code, and Signal fields used below. Treat Output_Truncated or Error_Truncated as an explicit data-loss condition.

send stdin and check every bounded result
with Ada.Text_IO;
with Flyology.Subprocesses;
with Flyology.Subprocesses.Capture;

procedure Uppercase is
   package Processes renames Flyology.Subprocesses;
   package Capture   renames Flyology.Subprocesses.Capture;
   use type Processes.Exit_Kind;

   Command : Processes.Command := Processes.To_Command ("/usr/bin/tr");
begin
   Processes.Append_Argument (Command, "[:lower:]");
   Processes.Append_Argument (Command, "[:upper:]");
   declare
      Result : constant Capture.Result := Capture.Run
        (Command,
         Standard_Input => "flyology" & ASCII.LF,
         Maximum_Output => 4 * 1_024,
         Maximum_Error  => 4 * 1_024,
         Timeout        => 5.0);
      Status : constant Processes.Exit_Status := Capture.Status (Result);
   begin
      if Capture.Output_Truncated (Result)
        or else Capture.Error_Truncated (Result)
      then
         raise Program_Error with "tr output exceeded its contract";
      elsif Processes.Successful (Status) then
         Ada.Text_IO.Put (Capture.Standard_Output (Result));
      elsif Status.Kind = Processes.Exited then
         raise Program_Error with
           "tr exited" & Natural'Image (Status.Code) & ": "
           & Capture.Standard_Error (Result);
      else
         raise Program_Error with
           "tr received signal" & Natural'Image (Status.Signal);
      end if;
   end;
end Uppercase;

The program prints FLYOLOGY. It passes each argument separately, writes the complete input, publishes stdin EOF, and verifies both retention bounds.

Each bound limits retained bytes, not drained bytes. After a bound fills, capture discards later bytes until EOF. The defaults retain 64 KiB from each output stream and use a 30-second timeout.

Continued draining prevents a full pipe from blocking a verbose child. Input writes are interleaved with both output streams, so output-before-input protocols do not create a two-pipe deadlock.

Captured strings preserve raw eight-bit values and perform no character decoding. Stdout and stderr remain separate, so their relative byte ordering is not available in the result.

RESULT 04

Classify launch separately from completion.

An Exit_Status describes a root process that was successfully launched and later reaped. Its Exit_Kind is Exited or Signaled. A nonzero exit is data, not a spawn exception.

Exited, code 0
Successful returns true. The command completed with the conventional success code.
Exited, nonzero code
Kind is Exited, and Code contains 1 through 255. Apply command-specific meaning.
Signaled
Kind is Signaled. Signal contains the host signal number, and Core_Dumped reports the host wait status.

Identifier returns the root's launch-time process identifier for diagnostics. Once the root is reaped, the host can reuse that value for an unrelated process even while the owner remains open. Never use it for signaling or as a durable application identity.

LOW LEVEL 05

Loop over partial pipe operations.

The low-level owner exposes synchronous Write_Standard_Input, Read_Standard_Output, and Read_Standard_Error operations. Call Close_Standard_Input to publish EOF after the final input byte.

One call transfers one available chunk. A write can stop before the end of its source slice. A read reports EOF when Last precedes the destination's first index. Loop until every input byte is accepted and each output stream reaches EOF.

loop conditions for one writer and one reader
procedure Write_All
  (Child : in out Processes.Process;
   Data  : Ada.Streams.Stream_Element_Array)
is
   Next : Ada.Streams.Stream_Element_Offset := Data'First;
   Last : Ada.Streams.Stream_Element_Offset;
begin
   while Next <= Data'Last loop
      Processes.Write_Standard_Input
        (Child, Data (Next .. Data'Last), Last, Timeout => 5.0);
      Next := Last + 1;
   end loop;
   Processes.Close_Standard_Input (Child);
end Write_All;

procedure Drain_Stdout (Child : in out Processes.Process) is
   Buffer : Ada.Streams.Stream_Element_Array (1 .. 16 * 1_024);
   Last   : Ada.Streams.Stream_Element_Offset;
begin
   loop
      Processes.Read_Standard_Output
        (Child, Buffer, Last, Timeout => 5.0);
      exit when Last < Buffer'First;
      Consume (Buffer (Buffer'First .. Last));
   end loop;
end Drain_Stdout;

Run the stdin writer, stdout reader, and stderr reader concurrently. Give each standard stream at most one active operation. A child can otherwise block while writing one full output pipe before another stream is drained.

stdin owner
Loop over partial writes. Close stdin exactly once after the last byte so the child observes EOF.
stdout owner
Loop until stdout EOF. Parse or forward every returned slice before reusing the buffer.
stderr owner
Run an independent read loop until stderr EOF. Do not wait for stdout to finish first.
scope owner
Start all stream owners, call Wait after the readers can drain, join all operations, then call Close.
  1. Spawn
  2. Write and drain concurrently
  3. Close stdin
  4. Read both EOFs
  5. Wait
  6. Join operations
  7. Close

Join every stream operation before calling Close or finalizing the owner. On exceptional exit, cancel active stream waits and apply a terminal policy with Stop or Kill, then join the operations before closing the owner. A catchable signal alone may be ignored and does not guarantee that a blocked stream operation can finish.

Close_Standard_Output and Close_Standard_Error abandon unread bytes. Serialize either close after the affected reader returns. A later child write can then fail with EPIPE or terminate under its own SIGPIPE disposition.

Pipe readiness uses the existing Flyology.IO descriptor machinery. Each parent write suppresses SIGPIPE for that write on the calling pthread; a closed child read end raises Pipe_Error instead.

CONTROL 06

Distinguish waiting from cleanup policy.

Pipe operations and Wait accept a relative monotonic timeout and an optional Flyology.Cancellation.Token. A low-level timeout or cancellation ends only that operation. It does not signal or close the process.

Each low-level call starts its own deadline. Repeating a five-second read can wait five seconds on every iteration. Compute a remaining duration from one application deadline when the complete protocol needs a shared bound, or use Capture.Run.

A negative timeout waits without a deadline. A zero timeout permits only an immediate transfer or readiness observation.

Capture.Run starts one command-progress deadline before spawn. Synchronous spawn cannot be interrupted by that deadline. After spawn returns, capture checks the deadline and cancellation between readiness operations, even while output remains continuously ready.

Any task can call Token.Request. Pass the token with Token => Stop'Access to a pipe operation, Wait, or capture.

Structured cleanup runs outside the capture deadline. Signal delivery, root reaping, reaper joining, and kernel task states can therefore delay exception delivery.

CONTROL 07

Choose graceful or hard termination explicitly.

Send_Signal
Send interrupt, graceful termination, or hard kill to the original process group. This operation does not wait or close pipes.
Kill
Send the uncatchable hard signal without waiting. Follow it with Wait or Close to reap the root.
Stop
Send graceful termination, wait for the root, and escalate when the grace interval expires. Output pipes remain open for explicit draining or close.
Close
End ownership. Close stdin, hard-terminate a running group, reap the root, close both output pipes, join the reaper, then release the exit wake source.

A nonpositive Stop interval permits only an immediate root observation before hard termination. The grace interval bounds waiting for the root, not graceful survival of every descendant.

The root process starts in a new process group. Before publishing exit readiness, the reaper hard-terminates members that remain after root exit. This cleanup can occur before the Stop grace interval ends because the root already terminated.

Finalization performs the same ownership cleanup as Close but does not propagate an exception. Use explicit close when the application must observe cleanup failure.

FAILURES 08

Handle launch, progress, and command failure separately.

Spawn_Error
The executable, pipes, spawn attributes, working-directory action, exec step, exit-readiness setup, or reaper setup failed. No command result exists.
Nonzero exit
Spawn succeeded. Inspect Exit_Status and command output. Do not catch this as a launch exception.
Flyology.IO.Timeout_Error
The applicable monotonic deadline expired. Low-level ownership remains open. Capture performs structured cleanup before propagation.
Operation_Cancelled
The supplied token was requested. Low-level ownership remains open. Capture performs structured cleanup before propagation.
Pipe_Error
A standard-stream descriptor or transfer failed. Decide whether retained protocol state is still usable, then stop or close the owner.
Process_Error
Exit observation, signaling, reaping, or explicit cleanup failed. The exception marks an ownership failure, not a child exit code.
Flyology.IO.Device_Error
The capture readiness multiplexer failed. Capture closes the structured child scope before propagation.
Constraint, storage, or program error
The command description is invalid, allocation failed, or the caller violated owner state. These are caller or runtime failures rather than child results.
OWNERSHIP 09

Put the process inside one task generation.

A supervised generation should declare its process owner inside the task body. Its callback receives a Generation_Control. Call Mark_Ready only after spawn and application validation succeed.

Pass Stopping as the wait token. On cancellation, stop the child and close it before the task returns.

generation-owned subprocess
procedure Execute
  (Context : in out Service_Context;
   Control : not null access Generation_Control)
is
   Command : Processes.Command := Build_Command (Context);
   Child   : Processes.Process;
   Status  : Processes.Exit_Status;
begin
   Processes.Spawn (Command, Child);
   Validate_Child (Child, Context);
   Mark_Ready (Control.all);

   begin
      Processes.Wait
        (Child, Status, Token => Stopping (Control.all));
   exception
      when Flyology.Cancellation.Operation_Cancelled =>
         Processes.Stop (Child, Grace => 2.0, Status => Status);
   end;

   Processes.Close (Child);
end Execute;
  1. Generation N owns child
  2. Stop requested
  3. Root reaped
  4. Pipes closed
  5. Reaper joined
  6. Task joined
  7. Generation N+1 validates
  8. Generation N+1 may publish

The example has the callback profile of Flyology.Supervision.Children. Its Run call returns only after the generation's Ada master has joined the task and completed task-body finalization. Applications with a custom task type can use Flyology.Supervision.Task_Generations as the lower-level alternative. A controller must cross the chosen join boundary before it publishes replacement readiness.

If spawn or validation raises before Mark_Ready, controlled finalization still closes the local process owner. The failed generation never publishes readiness.

Process termination remains application policy. Flyology supervision does not automatically kill arbitrary descendants of every supervised Ada task. Only an explicitly declared Process owner applies subprocess cleanup.

RECOVERY 10

Reconcile externally durable effects before restart.

Process ownership controls one launched operating-system process group. It does not reverse external effects created by that process. A command can modify files, network services, remote state, namespaces, or persistent registries before it exits.

Set supervision Restart_Safe only when a replacement can inspect and reconcile those effects. Use stable application identities and idempotent operations. Do not infer external state only from the old process identifier or its exit status.

For example, a command can rename a completed report into its final path and exit before the generation publishes readiness. The replacement must inspect the stable path and validate the report. It can adopt a complete result, remove an incomplete temporary file, or report a conflicting result. Starting the command again without that check is not restart-safe.

Identity
Record an application-selected job, resource, or output identity that survives process replacement.
Inspection
Classify the external state as absent, incomplete, complete, or conflicting before publication.
Action
Adopt, finish, replace, or reject that state through an idempotent operation with explicit authority.

If a child deliberately leaves the original process group with setpgid or setsid, Flyology can no longer contain it through that group. Treat that behavior as an external durable resource. Record its identity, reconcile it before publication, and assign its cleanup to an explicit authority.

PLATFORM 11

Use the Darwin and Linux POSIX boundary.

Spawn
Darwin and Linux use synchronous posix_spawn or posix_spawnp. Flyology does not call fork and then enter Ada or GNARL state in the child. Spawn resets the child signal mask and catchable dispositions before exec.
Directory
Darwin uses its compatible addchdir spawn action. Supported glibc Linux builds provide posix_spawn_file_actions_addchdir_np. Older glibc versions without that extension are outside the current build matrix; other hosts reject a configured directory.
Pipe waits
Darwin uses kqueue. Linux uses epoll. Native tasks use poll on both hosts.
Exit waits
Each live process owns one native reaper task. The task waits for root exit and signals an ordinary Flyology wake descriptor.
Signals
Signals target the negative process-group identifier. Hard cleanup uses the host SIGKILL value.

The native reaper gives exit readiness the same cancellation and deadline composition as other descriptors. It also consumes one native task and pthread per live subprocess. Keep large subprocess populations outside this initial slice or add a separately designed shared reaper backend.

LIMITS

Budget the ownership scope before scaling it.

Supported hosts
Darwin and Linux. The API starts native operating-system processes and never runs an Ada callback in the child.
Parent descriptors
A live owner with all streams open holds five Flyology descriptors: stdin, stdout, stderr, and both ends of the exit wake source. Closing streams releases their descriptors before owner close.
Cancellation descriptors
An unrequested token can lazily add a separate two-descriptor wake source when capture or a low-level wait borrows it. The caller owns that token, can share it across operations, and must keep it alive through every call.
Native resources
Each live subprocess owns one native reaper task and pthread. This initial design is not intended for large subprocess populations.
Capture payload
Retained payload is bounded by Maximum_Output + Maximum_Error. Total storage also includes the complete caller-supplied input and command plus implementation-dependent unbounded-string capacity and allocator overhead. Capture uses one 4,096-byte transfer buffer and a temporary retained slice of at most 4,096 bytes.
Stream ordering
Bytes stay ordered within each stream. Capture does not preserve a total order between stdout and stderr.
Descriptor inheritance
Flyology-created descriptors are close-on-exec. Application descriptors without FD_CLOEXEC follow their own inheritance policy.
Spawn latency
Spawn can occupy a lightweight caller's event-loop pthread. Its synchronous work cannot be interrupted by a capture timeout or token.
Containment
Cleanup reaches the root and descendants that remain in its original process group. It cannot reach a deliberate setpgid or setsid escape.
Kernel completion
Finalization may wait indefinitely when the kernel cannot complete hard termination, such as an uninterruptible task state.