Skip to content

About

A Delphi library for running processes and capturing out, interacting with it.

Topics

Resources

Stars

19 stars

Watchers

3 watching

Forks

Repository files navigation

VSoft.Process - Modern Process Execution for Delphi

Delphi: XE2+ Platform: Windows Platform: MacOS Platform: Linux License

VSoft.Process is a cross-platform process library for Delphi. It wraps platform process APIs (CreateProcess on Windows, fork/execve with epoll/kqueue on POSIX) behind a small interface-driven API, and adds the bits you usually have to write yourself: command-line construction with safe quoting, stdin/stdout/stderr redirection, line-oriented capture, interactive stdin, timeouts, cancellation tokens, process groups, and credential-aware launching on Windows.

The goals are:

  • One API, three platforms - Win32, Win64, Linux and macOS share the same IProcess surface.
  • Interface-based - everything you touch is an interface, so lifetime is reference-counted and IProcess plays well with composition, fluent chaining, and cloning.
  • Async I/O on every platform - large or chatty processes do not deadlock waiting for buffers to drain.
  • Sensible defaults - safe quoting, process-tree termination, exit codes and post-exit output draining work out of the box; attach a pipe target to capture output.

Features

  • Cross-platform: Win32, Win64, Windows ARM64EC (Delphi 13.2+), Linux, macOS share a single IProcess API.
  • Fluent or imperative: chain WithStartInfo(...).WithCommandLine(...).Execute or set properties step-by-step.
  • Three execution modes: synchronous Execute, fire-and-monitor ExecuteAsync, fully-detached ExecuteDetached.
  • I/O redirection: bind stdout/stderr to streams, files, TStringBuilder, or per-line delegates; feed stdin from strings, bytes, files, streams, or an interactive source, or make a file the child's stdin itself (TPipeSource.FileAsStdIn).
  • Interactive stdin: write to a running child's stdin from anywhere; the writer thread blocks on an event instead of busy-spinning.
  • Command-line builder: declarative argument construction with per-argument quoting; sensitive arguments are masked in ToLogString but emitted normally to ToString.
  • Environment builder: inherit, override, or strip variables; the platform impl only builds a custom block when you actually modify it.
  • Timeouts and cancellation: per-process timeout with a configurable exit code, plus ICancellationToken / TEvent-based cancellation for Execute and ExecuteAsync.
  • Process trees: on Windows the child runs in a Job Object, so Terminate / timeout / cancellation stop everything it started, and JobMode decides whether leftovers are killed when the IProcess is released (Job mode); POSIX signals the child's process group.
  • Credentials (Windows): launch as a different user via CreateProcessWithLogonW; passwords are zeroed on builder destruction.
  • Pipe fan-out: a single stdout pipe can feed any number of targets simultaneously via TPipeTarget.ToAll.

Requirements

  • Delphi XE2 or later (the code base intentionally avoids inline var, string helpers, and other post-XE2 syntax). Linux64 needs Delphi 10.2 or later, macOS x64 10.3 or later and macOS ARM64 11.0 or later; Windows ARM64EC needs 13.2 or later.
  • Windows Vista or later at run time: the Windows implementation imports Vista APIs (threadpool I/O, CancelIoEx, process attribute lists) statically.
  • VSoft.CancellationToken (the test project pulls it via DPM; see Tests/VSoft.Process.Tests.dproj for the package-path pattern).

Installation

DPM

In the project folder :

dpm install VSoft.Process

This will install VSoft.Process and it's dependencies and add them to your project search path.

Manual

  1. Add the Source/ directory to your Delphi library path, or include the units directly in your project.
  2. Add VSoft.Process to your uses clause. The platform implementation unit (VSoft.Process.Windows or VSoft.Process.Posix) is selected automatically via conditional uses in the main unit - you do not need to add it yourself.
  3. Make sure VSoft.CancellationToken is reachable on your search path.

Quick Start

uses
  System.SysUtils,
  System.Classes,
  VSoft.Process;

var
  proc   : IProcess;
  output : TStringBuilder;
begin
  output := TStringBuilder.Create;
  try
    proc := TProcess.Create
      .WithStartInfo(procedure(const info : IStartInfoBuilder)
        begin
          info.WithExecutable('C:\Windows\System32\cmd.exe')
              .WithHideWindow(True);
        end)
      .WithCommandLine(procedure(const cmd : ICommandLineBuilder)
        begin
          cmd.Add('/c').Add('echo hello-world');
        end)
      .WithStdOutPipe(TPipeTarget.ToStringBuilder(output, TEncoding.Default));

    proc.Execute;

    if proc.Status = TProcessStatus.Exited then
      Writeln('Exit ', proc.ExitCode, ': ', output.ToString)
    else
      Writeln('Process did not exit normally: ', Ord(proc.Status));
  finally
    output.Free;
  end;
end;

The same configuration with property assignments instead of the fluent helpers:

    proc := TProcess.Create;
    proc.StartInfo.Executable := 'C:\Windows\System32\cmd.exe';
    proc.StartInfo.HideWindow := True;
    proc.CommandLine.Add('/c');
    proc.CommandLine.Add('echo hello-world');
    proc.WithStdOutPipe(TPipeTarget.ToStringBuilder(output, TEncoding.Default));

    proc.Execute;

Core Concepts

Builders

Configuration lives on four interfaces hung off IProcess:

Interface Reached via Configures
IStartInfoBuilder proc.StartInfo Executable, working directory, timeout and what it does, job mode, hide-window, priority, processor affinity, encoding, exit-code overrides
ICommandLineBuilder proc.CommandLine Arguments (positional, name/value, sensitive), quoting style, separators
IEnvironmentBuilder proc.Environment Environment variables (override, add, remove, clear, reset)
ICredentialsBuilder proc.Credentials Windows-only user / domain / password / profile flags

You can either drive these directly with property assignments or use the fluent helpers (WithStartInfo, WithCommandLine, ...) that take an anonymous procedure.

Execution modes

Mode API Returns when
Synchronous Execute The child exits, times out, is cancelled, or fails to start
Asynchronous ExecuteAsync The child has started; monitor via Status, events, Terminate
Detached ExecuteDetached The child has started; the library no longer tracks it

ExecuteDetached has explicit caveats: Status stays Running, ExitCode and TimedOut raise EInvalidOpException, and Terminate returns False (the OS no longer notifies us). The configured pipes, JobMode, the timeout and TerminateOnTimeout are ignored (no target's Start or Done is called, and the rule that rejects TerminateOnTimeout = False with JobMode = KillOnRelease does not apply), OnStarted fires but OnExit never does. When ExecuteDetached returns False (validation or the launch failed) there is no detached child: Status stays Idle, and ExitCode / TimedOut read $FFFFFFFF / False instead of raising, as after any failed Execute*. Use it only for true fire-and-forget launches. The IProcess itself can still be re-used for another run afterwards.

A process object runs one child at a time: calling any Execute* while a run is still in progress raises EInvalidOpException. That includes two threads calling Execute* on the same object at once: one starts the run and the other raises.

Every Execute* first resets the object: Status becomes Idle, ExitCode $FFFFFFFF, LastError 0, and Errors and ResolvedExecutable are cleared. When the configuration is invalid (missing executable or working directory, invalid credentials, unsafe batch-file arguments, a NUL character in the command line on Windows) or the cancellation token / event is already signalled, Execute* returns False with the reason in Errors; Status stays Idle, no pipe target is started and no event fires. Some failures raise instead of returning False: a pipe target or source whose Start fails (a ToFile that can't create its file, FromFile / FileAsStdIn with a missing file) and OS errors creating the pipes.

When the configuration is valid but the operating system refuses to start the program (not a valid executable, access denied, elevation required, a wrong password with credentials), Execute* also returns False with the message in Errors, and Status stays Idle. On Windows that message names the program where the Windows text has a %1 placeholder (C:\Tools\app.exe is not a valid Win32 application.); the arguments are never included, as they can hold secrets. OnStarted doesn't fire; for Execute / ExecuteAsync the pipe targets have already been started, so their Done is called. LastError then holds the OS error code: a Win32 error code on Windows (ERROR_BAD_EXE_FORMAT, ERROR_ELEVATION_REQUIRED, ERROR_LOGON_FAILURE, ...) and an errno value on POSIX (EACCES, ...), so compare it with the platform's own constants. LastError is 0 in every other case, so after False it tells you whether the OS refused the launch (non-zero) or validation or cancellation stopped it (0). A missing executable is a validation error, so it leaves LastError at 0.

Status

TProcessStatus = (Idle, Running, Exited, Terminated, TimedOut) with {$SCOPEDENUMS ON}, so reference them as TProcessStatus.Running etc.

  • Idle - never started, or has been reset before a re-run.
  • Running - child is alive.
  • Exited - child exited under its own steam; ExitCode is the child's reported code.
  • Terminated - we killed it via Terminate or a cancellation token / event; ExitCode is StartInfo.TerminateExitCode (default 9999). On POSIX a child ended by a signal we didn't send (a crash, an external kill) is also Terminated, with ExitCode = 128 + the signal number.
  • TimedOut - the configured TimeoutInMSec elapsed; ExitCode is StartInfo.TimedOutExitCode (default 8888) and TimedOut is True. The child was killed, or left running when StartInfo.TerminateOnTimeout is False (see Timeouts). A child that exits on its own right at the timeout can report Exited with TimedOut = True.

Status leaves Running only once the run has fully completed: all output has been delivered to the pipe targets, every target's Done has been called and all handles are closed. Polling Status and then reading captured output is therefore safe. ExitCode is $FFFFFFFF until then.

Finding the executable

StartInfo.Executable can be a full path, a relative path (relative to the current directory, not WorkingDirectory) or a bare program name. A bare name (no folder part) is searched for on this process's PATH (POSIX searches /usr/bin:/bin when this process has no PATH, as execvp does). The PATH you give the child through proc.Environment is never used for the lookup, so you can give the child a constrained PATH (a single folder, say) or none at all (Environment.Clear) and the program is still found. To run a program that is only on the child's PATH, pass its full path. Only absolute PATH entries are searched; the current directory and the host program's folder never are. A program that can't be found is a validation error (Execute returns False and Errors says Executable "git" was not found on the PATH). After a run has started, IProcess.ResolvedExecutable is the full path that was launched.

On Windows this differs from CreateProcess, which looks in the host's folder, the current directory and the system folders first: .exe is appended to a name without an extension (PATHEXT is not used, so git finds git.exe but never git.cmd), and a .bat / .cmd found on the PATH (named with its extension) follows the batch-file rules below. On POSIX the file found must be executable (X_OK).

Quoting

An argument has a name, a value or both: Add(name, value) renders as name + NameValueSeparator (DefaultValueSeparator, initially a space) + value, and Add(text) adds a name-only argument. Add and AddSensitive take a TQuoted enum, default qtDouble. A value is always quoted under qtSingle / qtDouble; a name - alone or before a value - is quoted only when it is empty or holds a space, a tab or a " (the auto rule of the same quote family), so a switch such as -v stays bare while Add('C:\Program Files\Out\') stays one argument. Set IArgument.QuoteName (reach it through CommandLine.Arguments[i] / ArgumentsByName[name]) to always quote a lone name under qtSingle / qtDouble. Use qtNone to add a pre-built fragment exactly as given (Add('exit /b 17', TQuoted.qtNone)).

The modes are qtNone, qtSingle, qtDouble, qtAutoSingle and qtAutoDouble (the auto modes quote only when the value contains a space, a tab or a ", or is empty). Every mode except qtNone first strips one pair of matching quotes surrounding the value when no other quote of that kind is inside it, so a value you quoted yourself isn't quoted twice, while "a" b "c" keeps its quotes. Single quotes do not group arguments for CommandLineToArgvW, so a single-quoted value holding a space, a tab or a " is also double-quoted and escaped, and the child receives 'x y' as one argument. Whenever a value is double-quoted it is escaped following CommandLineToArgvW rules (\" and trailing-backslash doubling), so C:\Program Files\Out\ reaches the child intact and quotes inside the value round-trip into the child's argv. A quoted value that directly follows a name (plus separator) ending in backslashes would have its opening quote read as an escaped one, so those backslashes are doubled and the pair parses as one argument: the name /out:C:\build\ with an empty separator and the value my file.txt renders as /out:C:\build\\"my file.txt". A quoted name directly followed by a quoted value (empty separator) is quoted as one token. Add('', '') adds an explicit empty argument.

On Windows a NUL character (#0) cannot be passed on a command line: it would silently cut off everything after it. A NUL in the executable path, the working directory, the argument separator or any argument (name, value or separator, including AddUnsafeBatchRaw text) therefore refuses the launch with an error in Errors, for ordinary programs and batch files alike.

Values added with AddSensitive(name, value) render as **** in ToLogString while still being passed through to the actual child unchanged; the name stays visible (--password ****). AddSensitive(text), with no separate value, masks the whole argument, so AddSensitive('--password=secret') logs as ****. This only masks your logs; anyone who can list the child's command line (ps, Process Explorer, /proc) can still see the value.

Batch files (Windows): a .bat / .cmd executable is run by cmd.exe, which re-parses the command line. VSoft.Process quotes any argument containing cmd metacharacters (& | < > ^ ( ) , ; =, whitespace) and refuses to launch (validation error in Errors) when an argument contains %, !, ", CR, LF or NUL, or when the batch file's own path contains % or !, because cmd.exe expands or splits those even inside quotes. A value ending in a backslash is not backslash-doubled for a batch file, since cmd.exe does not treat a backslash as an escape. A name ending in dots or spaces (run.cmd.) is treated as the file Windows actually opens (run.cmd), so these rules still apply. If you need to pass such values, use a real executable or pass them through the environment. Running cmd.exe /c ... yourself bypasses this protection.

Passing trusted raw text to a batch file (unsafe): CommandLine.AddUnsafeBatchRaw(text) adds text exactly as given. For a .bat / .cmd that one argument skips the % ! " CR LF refusal and the quoting above, so cmd.exe expands %VAR% in it, and its quotes and & | < > ^ ( ) are cmd syntax.

  • Why it exists. It is for a caller that runs its own script with its own parameters, written as one string with their own quotes and %VAR% (a build tool's batch step, say). The batch file receives exactly what CreateProcess(nil, '"<script>" ' + text) would give it (measured byte for byte for %* and %1..%9).
  • Never with untrusted input. Never pass text that came from anyone else. Anything cmd.exe treats as syntax runs as commands: this is the "BatBadBut" class of problem (CVE-2024-24576) that the default rules protect against.
  • What is still checked. Only that argument is exempt. Every other argument is still checked and quoted, and a batch file whose path contains % or ! is still refused (including the trailing-dots-and-spaces name Windows opens). Raw text that contains a NUL character is refused too.
  • Executables and POSIX. For an executable it is the same as Add(text, TQuoted.qtNone) for non-empty text; on POSIX it is one argv slot.
  • Empty text. AddUnsafeBatchRaw('') adds nothing: no argument, Count unchanged, no argv slot on POSIX. Add('') / Add('', TQuoted.qtNone) remain the explicit empty argument.

POSIX argv assembly does NOT apply quoting. Each IArgument becomes one or two literal argv slots (Name+separator+Value as one slot, or Name and Value as separate slots when NameValueSeparator = ' ') and is encoded UTF-8 verbatim. The child sees the exact bytes you set on Name / Value - quoting is only meaningful for shell-parsed command-line strings, which POSIX never sees.

Threading and events

OnStarted and OnExit are part of IProcess. Their thread context depends on execution mode and StartInfo.MarshallEventsToMainThread:

Event Sync Execute ExecuteAsync
OnStarted Caller's thread, before the wait loop Caller's thread, before ExecuteAsync returns
OnExit Caller's thread, after the wait loop Worker thread, inside the spawned anonymous thread

With MarshallEventsToMainThread := True, a handler that would run on any thread other than the main thread is queued to the main thread with TThread.Queue instead (for OnExit the exit code is snapshotted at queue time). This applies to a synchronous Execute called from a background thread too; a handler that would already run on the main thread is called directly.

If your handler touches the VCL/FMX UI from an async run, set MarshallEventsToMainThread := True (or Synchronize / TThread.Queue inside your handler). Queued events are run by the main thread's message loop; a console application must call CheckSynchronize for them to run.

A handler that raises. A handler called on the thread that called Execute* raises to that caller. Where there is no caller to raise to, the exception is reported instead and the run goes on:

Where the handler runs What happens to its exception
OnStarted, on the thread that called Execute / ExecuteAsync The child is stopped and the run is completed (targets get their Done, OnExit fires, Status ends Terminated unless the child had already exited by itself). Then the exception is raised from Execute / ExecuteAsync, so no run is in progress when the caller gets it.
OnStarted, from ExecuteDetached Raised from ExecuteDetached. The child is already detached and stays running.
OnExit, on the thread that called Execute Raised from Execute. The run has completed, so Status, ExitCode and the output are valid.
OnExit, on the ExecuteAsync worker thread Logged to Errors, shortly after Status has left Running: that thread has no caller.
Either event, queued to the main thread Passed to the application's exception handler when there is one (System.Classes.ApplicationHandleException, which a VCL or FMX application sets), otherwise logged to Errors.

When OnStarted and OnExit both raise in one run, the caller gets what OnStarted raised and what OnExit raised is logged to Errors.

For the worker-thread cases, the implementation captures a live IProcess reference so the process object cannot be destroyed under the handler; safe to drop your IProcess ref immediately after ExecuteAsync returns. OnExit fires after the run has completed, so a handler may start the next run.

Threading and lifetime contracts

  • Pipe targets receive HandleData on a threadpool thread on Windows. On POSIX the wait loop reads the pipes itself, so it is the thread running the run: the caller's thread for a synchronous Execute, the worker thread for ExecuteAsync. Calls for all of a process's targets (stdout and stderr alike) are serialised, so a single target can safely be attached to both streams.
  • StartInfo.DeliverOnRunThread (Windows, default False) delivers on the thread running the run instead, as POSIX does: the caller's thread for a synchronous Execute, the ExecuteAsync worker otherwise. That thread is waiting in the run anyway, and it delivers each read before the next is started, so a slow target holds the child back (the pipe fills) rather than output piling up in memory. Use it when a target calls code that must stay on one thread (a COM object, a script engine). The calls, their order per stream and Done coming last are the same either way.
  • TThread.Synchronize in a target (Windows): a synchronous Execute called on the main thread services CheckSynchronize while it waits (for the child, for the drain and while it closes the pipes), so a target called on a threadpool thread may use TThread.Synchronize or TThread.Queue. Methods that other threads synchronise or queue to the main thread run inside Execute as well; an exception raised by a queued one is passed to the application's exception handler when there is one, otherwise logged to Errors, and does not end the run. Not covered: a stdin source that calls Synchronize from its data callback. For an async run see Terminate on the main thread below.
  • Attaching: each WithStdOutPipe / WithStdErrPipe call adds another target (nil raises EArgumentNilException); targets stay attached for later runs until they are detached. WithStdInPipe replaces the stdin source, and nil removes it.
  • Detaching: ClearStdOutPipes and ClearStdErrPipes detach every target of that stream, and ClearPipes detaches both and the stdin source as well. Each returns the IProcess. Like the With methods they change the configuration for the next run, and they may be called while a run is in progress, also from a target or an event handler: that run keeps the targets and source it started with (they still get the rest of the output and their Done) and lets go of them when it completes. They are not thread-safe against another thread calling a With / Clear method or Execute*.
  • Reference cycles: the process holds its targets until they are detached or it is freed. A delegate target whose anonymous method uses the IProcess variable (to call Terminate on a certain line, say) therefore forms a reference cycle (process -> target -> anonymous method -> process): neither is ever freed, and with JobMode = KillOnRelease the job is never released either. Break the cycle when you are done with the process: call ClearPipes (or the Clear method for that stream), or set the captured variable to nil.
  • The data buffer passed to HandleData is reused: only the first dataLength bytes are valid, and they must be copied if you keep them after the call returns.
  • A target that raises is logged to Errors and receives no more data for that run, from either stream when it is attached to both; the child keeps running and the other attached targets are unaffected. Start and Done are called once per run when the launch gets past validation (also when the launch itself then fails), even for a target attached to several streams.
  • ToAll: one of the combined targets raising does not stop the others. The one that raised gets no more data that run, the rest keep receiving, and its exception is raised from the group's Done, so it is logged to Errors when the run completes. If the Start of one of them raises, the ones already started get their Done before the exception propagates.
  • Line targets: a line target whose callback raised calls it no more for that run, not from Done either, so the unterminated last line is dropped. If the first raise comes in Done, on the stdout tail, the stderr tail is dropped too.
  • A Done that raises (a target's or the stdin source's) is logged to Errors and the run completes normally, also when what it raises is not an Exception object.
  • Default encodings: without an explicit encoding or StartInfo.OutputEncoding, the line targets and ToStringBuilder decode with TEncoding.Default (the ANSI code page on Windows), and TPipeSource.FromString encodes with it. IInteractivePipeSource.Write(text) uses StartInfo.InputEncoding, or UTF-8 when that isn't set.
  • Console code page (Windows): cmd.exe built-ins and classic console tools write redirected output in the console code page, which for the new console the child gets is the system OEM code page and may differ from the ANSI code page that TEncoding.Default decodes. Set StartInfo.OutputEncoding explicitly for such children (TEncoding.GetEncoding(850), say, with the code page your systems use). Don't rely on GetOEMCP to find it: an activeCodePage manifest changes what it returns for the host only.
  • ToStringBuilder clears the TStringBuilder at the start of every run.
  • Descendants holding the pipes: after the child exits (or is killed) the library keeps reading for at most StartInfo.PipeDrainTimeoutMS (default 2000 ms) for output still held by a process the child started (a background job, a build server). It then cancels the reads, logs Output may be truncated... to Errors, and completes. Set it to INFINITE to wait for every writer on a normal exit; after the child has been killed (Terminate / timeout / cancellation) the drain is always bounded. Terminate or cancellation during the drain after a normal exit ends it at once: the rest of the output is discarded (logged as possible truncation) and Status stays Exited with the child's own exit code. On Windows the job is terminated too, ending what the child started; with JobMode = NoJob, and on POSIX, those programs are left running.
  • Slow targets: after a normal exit, everything the child left in the pipes is delivered before the PipeDrainTimeoutMS clock starts, however long the targets take over it, so a slow target never loses the child's own output. The clock only limits waiting for more output from a descendant. After Terminate / timeout / cancellation the clock starts at once, so a slow target can still miss output that was in the pipe. On Windows, if the read that was pending when the child exited completes late with a descendant's data, the clock starts again once that data has been delivered (once per stream at most), so that case can take up to about twice PipeDrainTimeoutMS plus the delivery time. Increasing PipeBufferSize does not help here: it means fewer calls into the target, but it also lets the child exit with more output still unread.
  • Leftover processes: what happens to programs the child started and left running is set by StartInfo.JobMode - see Job mode.
  • Terminate called from OnStarted, a pipe target / source callback or the thread running a synchronous Execute only requests termination (it can't wait for itself) and returns True immediately, and so does Terminate(0) from any thread. From OnExit it returns False: the run has already completed. From any other thread Terminate(waitMS) with waitMS > 0 waits, also while ExecuteAsync is still starting the run on its own thread (creating the child, OnStarted); True then means the run completed. A Terminate that is overtaken by the end of the run it was aimed at, with another run already started, returns False and never stops the later run.
  • Terminate on the main thread services CheckSynchronize while it waits, so a pipe target of an async run may use TThread.Synchronize without deadlocking against it. As a result, methods queued or synchronised to the main thread, a queued OnExit (MarshallEventsToMainThread) among them, can run before Terminate returns, and an exception raised by a queued method propagates out of Terminate. Not covered: Terminate called on the main thread from inside a synchronised method still waits its full time.
  • Encodings passed to the pipe factories or StartInfo are not owned by the library - keep custom TEncoding instances alive until the run has completed.
  • Pseudo code pages (Windows): TEncoding.GetEncoding(CP_OEMCP) (and CP_MACCP, CP_THREAD_ACP) reports that pseudo number as its CodePage, not the code page it stands for on the machine. The line targets and ToStringBuilder look up the real code page, so where it is UTF-8 (the system code page set to UTF-8) a character split across two reads stays whole and the byte order mark is dropped, as with TEncoding.UTF8.
  • Interactive sources are closed automatically when the child exits; create a new one for the next run.
  • Stdin sources should return from GetData promptly. Once the child has exited and its output has been read, the stdin writer is stopped. If the source's GetData still hasn't returned a second later, the run completes anyway and Errors says so. The source's Done is still called, and the writer thread finishes on its own whenever GetData returns; until then it keeps the IProcess object alive.
  • Errors is appended to from worker threads while a run is in progress - read it once Status has left Running.

Examples

The library accepts both the fluent style and a step-by-step style. Each example shows the fluent form first, then the same configuration with property assignments. The two styles are interchangeable and can be mixed. Arguments and environment variables have no per-item properties, so the step-by-step form calls CommandLine.Add / Environment.AddOrSet once per item. The pipes have no properties either: WithStdOutPipe, WithStdErrPipe and WithStdInPipe are simply called as statements.

1. Line-by-line stdout capture

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('C:\Windows\System32\ping.exe');
    end)
  .WithCommandLine(procedure(const cmd : ICommandLineBuilder)
    begin
      cmd.Add('-n').Add('3').Add('127.0.0.1');
    end)
  .WithStdOutPipe(TPipeTarget.ToLineDelegate(
    procedure(const line : string)
    begin
      Writeln('[out] ', line);
    end, TEncoding.UTF8));

proc.Execute;

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'C:\Windows\System32\ping.exe';
proc.CommandLine.Add('-n');
proc.CommandLine.Add('3');
proc.CommandLine.Add('127.0.0.1');
proc.WithStdOutPipe(TPipeTarget.ToLineDelegate(
  procedure(const line : string)
  begin
    Writeln('[out] ', line);
  end, TEncoding.UTF8));

proc.Execute;

The line target stitches multi-byte UTF-8 sequences across pipe-read boundaries, so non-ASCII output stays intact even when a code point gets split between two reads. The same is done for UTF-16, GB18030 and the two-byte code pages (any of them on Windows; 932, 936, 949 and 950 on POSIX). Other multi-byte code pages are not guarded, so a character split between two reads can decode wrongly.

Line targets split the decoded text like this:

  • LF ends a line. A run of CRs right before the LF (CRLF, or CR CR LF from a program that writes CRLF through a text-mode stream) ends the line too and is not part of it. Output that ends in CRs without an LF loses them as well.
  • A CR that is not followed by LF (progress output such as 10%<CR>20%<CR>) stays in the line text.
  • NUL becomes a space; TAB is kept; every other control character (#1..#31, including ESC, so colour codes are lost) is dropped.
  • Bytes that are not valid in the encoding never raise: each invalid sequence becomes U+FFFD, in the line targets and in ToStringBuilder, including a sequence cut short when the stream ends. Windows decodes through MultiByteToWideChar without MB_ERR_INVALID_CHARS; POSIX decodes UTF-8 itself.
  • UTF-16 output (TEncoding.Unicode / TEncoding.BigEndianUnicode) is split on the LF code unit, never on a 0x0A byte that is half of another character.
  • A byte order mark (UTF-8, UTF-16 LE or UTF-16 BE) at the very start of each stream is dropped when it matches the target's encoding, by the line targets and by ToStringBuilder. Byte targets pass it through.

Use a byte target (ToStream, ToFile, ToDelegate) when you need the output exactly as written.

2. Building a complex command line

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('msbuild.exe');
    end)
  .WithCommandLine(procedure(const cmd : ICommandLineBuilder)
    begin
      cmd.Add('MyProject.dproj')
         .Add('/p:Configuration=Release')
         .Add('/p:Platform=Win64')
         .Add('/v:minimal')
         .WithDefaultValueSeparator('=')     // applies to arguments added after this
         .AddSensitive('/p:Password', GetSecret);
    end);

Writeln(proc.CommandLine.ToLogString); // -> ... /v:minimal /p:Password=****  (safe to log)
Writeln(proc.CommandLine.ToString);    // -> ... /v:minimal /p:Password="<real>" (sent to the child)
proc.Execute;

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'msbuild.exe';
proc.CommandLine.Add('MyProject.dproj');
proc.CommandLine.Add('/p:Configuration=Release');
proc.CommandLine.Add('/p:Platform=Win64');
proc.CommandLine.Add('/v:minimal');
proc.CommandLine.DefaultValueSeparator := '=';   // applies to arguments added after this
proc.CommandLine.AddSensitive('/p:Password', GetSecret);

3. Capturing stdout and stderr separately

var
  outSB, errSB : TStringBuilder;
begin
  outSB := TStringBuilder.Create;
  errSB := TStringBuilder.Create;
  try
    proc := TProcess.Create
      .WithStartInfo(procedure(const info : IStartInfoBuilder)
        begin
          info.WithExecutable('git.exe');
        end)
      .WithCommandLine(procedure(const cmd : ICommandLineBuilder)
        begin
          cmd.Add('status');
        end)
      .WithStdOutPipe(TPipeTarget.ToStringBuilder(outSB, TEncoding.UTF8))
      .WithStdErrPipe(TPipeTarget.ToStringBuilder(errSB, TEncoding.UTF8));

    proc.Execute;
  finally
    outSB.Free;
    errSB.Free;
  end;
end;

With property assignments, the configuration inside the try becomes:

    proc := TProcess.Create;
    proc.StartInfo.Executable := 'git.exe';
    proc.CommandLine.Add('status');
    proc.WithStdOutPipe(TPipeTarget.ToStringBuilder(outSB, TEncoding.UTF8));
    proc.WithStdErrPipe(TPipeTarget.ToStringBuilder(errSB, TEncoding.UTF8));

    proc.Execute;

4. Custom environment

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('myapp.exe');
    end)
  .WithEnvironment(procedure(const env : IEnvironmentBuilder)
    begin
      // The builder starts as a snapshot of the parent process environment.
      // Reset re-snapshots it; AddOrSet / Remove mark it as "overridden", so
      // the platform impl only builds a custom block when you have actually
      // changed something.
      env.AddOrSet('MY_FLAG', '1')
         .AddOrSet('LANG', 'C')
         .Remove('JAVA_HOME');
    end);

proc.Execute;

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'myapp.exe';
proc.Environment.AddOrSet('MY_FLAG', '1');
proc.Environment.AddOrSet('LANG', 'C');
proc.Environment.Remove('JAVA_HOME');

proc.Execute;

IEnvironmentBuilder.HasOverrides exposes the dirty bit if you need to inspect it.

The snapshot of the host environment is taken when the process is created (and again by Reset), but it is only parsed into variables on first use, so creating a process does not cost an allocation per host variable. On POSIX an inherited entry that is not valid UTF-8 does not make TProcess.Create raise: it shows U+FFFD for the invalid bytes in TryGetValue / ToString, but reaches the child byte for byte unless the variable is set again.

Clear empties the builder, so the child gets only the variables you add afterwards (possibly none):

proc.Environment
  .Clear
  .AddOrSet('SystemRoot', GetEnvironmentVariable('SystemRoot'))  // most Windows programs need it
  .AddOrSet('MY_FLAG', '1');

Or one statement at a time:

proc.Environment.Clear;
proc.Environment.AddOrSet('SystemRoot', GetEnvironmentVariable('SystemRoot'));  // most Windows programs need it
proc.Environment.AddOrSet('MY_FLAG', '1');

Clear sets HasOverrides, Clone keeps the cleared state and Reset takes a new snapshot of this process's environment. An empty environment is passed as an empty block (two NULs) on Windows and an empty envp on POSIX. A cleared environment has no PATH. That does not stop a bare Executable name from being found (it is looked up on this process's PATH, see Finding the executable), but the child, and anything it starts by name, has no PATH unless you add one.

5. Timeouts

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('slowapp.exe')
          .WithTimeout(5000)            // 5 seconds
          .WithTimedOutExitCode(999);   // custom code reported on timeout
    end);

proc.Execute;

if proc.TimedOut then
  Writeln('Process timed out')
else
  Writeln('Exit code: ', proc.ExitCode);

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'slowapp.exe';
proc.StartInfo.TimeoutInMSec := 5000;       // 5 seconds
proc.StartInfo.TimedOutExitCode := 999;     // custom code reported on timeout

proc.Execute;

By default a timeout kills the child and everything it started. Set TerminateOnTimeout to False to stop waiting but leave the child running:

proc.StartInfo
  .WithTimeout(60000)
  .WithTerminateOnTimeout(False)
  .WithJobMode(TProcessJobMode.KillOnTerminate);   // KillOnRelease would kill it later

proc.Execute;   // returns after 60 s at most

With property assignments:

proc.StartInfo.TimeoutInMSec := 60000;
proc.StartInfo.TerminateOnTimeout := False;
proc.StartInfo.JobMode := TProcessJobMode.KillOnTerminate;   // KillOnRelease would kill it later

proc.Execute;   // returns after 60 s at most

The run then completes at the timeout with Status = TimedOut, TimedOut = True and ExitCode = TimedOutExitCode. Output read before the timeout has been delivered, Done has been called on every target and no target is called after Execute returns. The library stops reading and closes its ends of the pipes, so a child that writes to stdout / stderr afterwards gets a broken pipe (ERROR_NO_DATA on Windows; SIGPIPE, which ends it unless it ignores the signal, or EPIPE on POSIX) and sees EOF on stdin. The child is not killed when the IProcess is released or run again. TerminateOnTimeout = False together with JobMode = KillOnRelease (the default) is rejected at validation, because the job would kill the child as soon as the IProcess is released or re-run. ExecuteAsync behaves the same way. On POSIX the child's process group is not signalled, and a background thread reaps the child when it exits so it does not stay a zombie.

Job mode

StartInfo.JobMode (TProcessJobMode) controls the Windows job object around the child, and with it what happens to programs the child starts:

JobMode Terminate / timeout / cancellation Releasing or re-running the IProcess
KillOnRelease (default) kills the job and the child's process tree: the child and everything it started kills whatever the child left running (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
KillOnTerminate kills the job and the child's process tree leftovers keep running (job without KILL_ON_JOB_CLOSE)
NoJob kills the child's process tree leftovers keep running

How Terminate, a timeout or cancellation ends the child (Windows):

  • Process tree. The child's process tree is killed even when there is a job, because a program the child starts with CREATE_BREAKAWAY_FROM_JOB isn't in the job.
  • Order. Every program in the tree is found and opened before anything is killed, so nothing links a program to the tree only through a parent that has already gone, and no process id can be reused meanwhile. Then the job is killed, then the tree (deepest first, the child last).
  • Waiting. The thread completing the run then waits, up to 5 s in all, for the child and every program it killed to have gone. So when Execute returns (or Status leaves Running) after a kill, what the child started has gone too.
  • Terminate(0). It only requests the kill; the waiting is done by the thread running the run.

If the child can't be assigned to the job (a host job that doesn't allow nesting on Windows 7, a Run-As child), Terminate / timeout / cancellation fall back to the process-tree kill, and the failure is logged to Errors. With KillOnRelease the library also keeps a handle to the child, which stops its process id being reused, and kills the child's process tree when the IProcess is released or run again, as closing the job would have done. That only reaches programs still linked to the child through running parents; a program whose parent has exited is no longer in the tree. With KillOnTerminate and NoJob leftovers keep running, as before. POSIX has no job objects: leftovers always survive the IProcess, and Terminate / timeout / cancellation signal the child's process group, whatever the mode. The exception is a child that inherits the host's terminal as its stdin: it stays in the host's process group, so only the child itself is signalled and programs it started keep running (see Platform Support). Redirect stdin (TPipeSource.FromString('') is enough) when the whole tree must stop.

6. Async execution and status

OnStarted and OnExit are method pointers (of object), so the handlers live on a class:

type
  TJobMonitor = class
  public
    procedure Started(const p : IProcess);
    procedure Exited(const p : IProcess; exitCode : Cardinal);
  end;

procedure TJobMonitor.Started(const p : IProcess);
begin
  Writeln('Started, PID = ', p.ProcessId);
end;

procedure TJobMonitor.Exited(const p : IProcess; exitCode : Cardinal);
begin
  Writeln('Exited with ', exitCode);
end;

...
proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('long-task.exe');
    end);

proc.OnStarted := monitor.Started;
proc.OnExit := monitor.Exited;

proc.ExecuteAsync;

while proc.Status = TProcessStatus.Running do
  Sleep(200);
// All output has been delivered by the time Status changes.

With property assignments, the TProcess.Create statement becomes:

proc := TProcess.Create;
proc.StartInfo.Executable := 'long-task.exe';

The ExecuteAsync worker holds a reference to the process, so dropping your local proc := nil while it is still running does NOT free the underlying object out from under the worker - it continues to completion safely.

Waiting on a handle (Windows only). Instead of polling Status, you can wait on proc.CompletionHandle together with your own handles, using WaitForMultipleObjects or MsgWaitForMultipleObjects. It is a manual-reset event that is signalled while no run is in progress. Execute / ExecuteAsync reset it before the child is created, so it is already reset when ExecuteAsync returns True. It is set once the run has fully completed: Status has left Running, all output has been delivered and ExitCode is final (OnExit fires after that). A run that fails validation or fails to launch leaves it signalled, and so does ExecuteDetached. The handle belongs to the IProcess: never close or signal it, and keep your IProcess reference until the wait is over. The property doesn't exist on POSIX, where TEvent has no handle.

This waits for the run on the main thread, keeps the UI responsive, and wakes whenever a worker has queued output lines for you to log:

var
  handles : array[0..1] of THandle;
  waitResult : DWORD;
begin
  if not proc.ExecuteAsync then
    raise Exception.Create(proc.Errors.Text);
  handles[0] := proc.CompletionHandle;
  handles[1] := FLinesQueued.Handle; // your own auto-reset TEvent, set when lines are queued
  repeat
    waitResult := MsgWaitForMultipleObjects(2, handles, False, INFINITE, QS_ALLINPUT);
    case waitResult of
      WAIT_OBJECT_0     : ;                            // the run has completed
      WAIT_OBJECT_0 + 1 : FlushQueuedLines;            // your event
      WAIT_OBJECT_0 + 2 : Application.ProcessMessages; // a message arrived
    else
      RaiseLastOSError;
    end;
  until waitResult = WAIT_OBJECT_0;
  FlushQueuedLines;
  // proc.Status, proc.ExitCode and all captured output are final here.
end;

Without a message loop, WaitForMultipleObjects(2, @handles, False, timeoutMs) works the same way.

7. Interactive stdin

var
  proc  : IProcess;
  stdin : IInteractivePipeSource;
begin
  stdin := TPipeSource.Interactive;

  proc := TProcess.Create
    .WithStartInfo(procedure(const info : IStartInfoBuilder)
      begin
        info.WithExecutable('interactive-tool.exe');
      end)
    .WithStdInPipe(stdin);

  proc.ExecuteAsync;

  stdin.Write('hello'#10);
  Sleep(50);
  stdin.Write('world'#10);
  stdin.Close;   // signal EOF so the child can finish

  while proc.Status = TProcessStatus.Running do
    Sleep(50);
end;

With property assignments, the TProcess.Create statement becomes:

  proc := TProcess.Create;
  proc.StartInfo.Executable := 'interactive-tool.exe';
  proc.WithStdInPipe(stdin);

The writer thread blocks on an event (IInteractivePipeSource.WaitForData), so an interactive source that is idle for long stretches does not burn CPU.

8. Cancellation tokens

uses
  VSoft.CancellationToken;

var
  proc  : IProcess;
  cts   : ICancellationTokenSource;
begin
  cts := TCancellationTokenSourceFactory.Create;

  proc := TProcess.Create
    .WithStartInfo(procedure(const info : IStartInfoBuilder)
      begin
        info.WithExecutable('long-task.exe');
      end);

  proc.ExecuteAsync(cts.Token);

  // ... some other code triggers cancellation ...
  cts.Cancel;

  while proc.Status = TProcessStatus.Running do
    Sleep(50);
end;

With property assignments, the TProcess.Create statement becomes:

  proc := TProcess.Create;
  proc.StartInfo.Executable := 'long-task.exe';

Execute and ExecuteAsync both accept either an ICancellationToken or a TEvent (handy when you already have an event-based wait somewhere).

9. Terminate

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('long-task.exe')
          .WithTerminateExitCode(1234);
    end);

proc.ExecuteAsync;
Sleep(2000);

if proc.Terminate then
  Writeln('Terminated, exit code = ', proc.ExitCode)  // 1234
else
  Writeln('Process was not running');

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'long-task.exe';
proc.StartInfo.TerminateExitCode := 1234;

proc.ExecuteAsync;

Terminate waits up to 5 seconds for the run to finish cleaning up and returns True when it has. That includes a child that happened to exit on its own first, in which case Status is Exited and ExitCode is the child's own code. It returns False when no run is in progress, when the run it was aimed at has already ended and another has started (which it never stops), or when cleanup takes longer than 5 seconds (termination still goes ahead). On the main thread the wait services CheckSynchronize (see Threading and lifetime contracts).

Terminate(waitMS) waits up to waitMS instead (Terminate is Terminate(5000)). Terminate(0) only requests termination: it returns True at once when a run is in progress, and the thread running the run (the caller of Execute, or the ExecuteAsync worker) kills the child and waits for it. Use it to stop several runs from one thread without paying for each one's cleanup:

for i := 0 to High(procs) do
  procs[i].Terminate(0);   // returns at once
// each run completes on its own thread; Status leaves Running when it has

10. Multi-target fan-out

var
  fileTarget, consoleTarget, combined : IPipeTarget;
begin
  fileTarget    := TPipeTarget.ToFile('build.log', False);
  consoleTarget := TPipeTarget.ToLineDelegate(
    procedure(const line : string)
    begin
      Writeln(line);
    end);

  combined := TPipeTarget.ToAll([fileTarget, consoleTarget]);

  proc := TProcess.Create
    .WithStartInfo(procedure(const info : IStartInfoBuilder)
      begin
        info.WithExecutable('build.exe');
      end)
    .WithStdOutPipe(combined);

  proc.Execute;
end;

With property assignments, the TProcess.Create statement becomes:

  proc := TProcess.Create;
  proc.StartInfo.Executable := 'build.exe';
  proc.WithStdOutPipe(combined);

Calling WithStdOutPipe once for each target has the same effect. The targets are independent either way: a target that raises gets no more data for that run while the others keep receiving. Inside ToAll its exception is raised from the group's Done (logged to Errors when the run completes) rather than logged at once.

11. Cloning a configured process

template := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('myapp.exe').WithTimeout(10000);
    end)
  .WithCommandLine(procedure(const cmd : ICommandLineBuilder)
    begin
      cmd.Add('--verbose');
    end);

job1 := template.Clone;
job2 := template.Clone;
job1.Execute;
job2.Execute;

With property assignments:

template := TProcess.Create;
template.StartInfo.Executable := 'myapp.exe';
template.StartInfo.TimeoutInMSec := 10000;
template.CommandLine.Add('--verbose');

Each clone gets its own deep copy of the four builders (start info, command line, environment and credentials), so per-invocation tweaks do not leak back into the template. Pipe targets, the stdin source and the OnStarted / OnExit handlers are not copied: attach them to each clone.

12. Windows: run as a different user

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('admin-task.exe');
    end)
  .WithCredentials(procedure(const creds : ICredentialsBuilder)
    begin
      creds.WithUser('Administrator')
           .WithDomain('CORP')
           .WithPassword(GetSecret)
           .WithUserProfile(True);
    end);

proc.Execute;

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'admin-task.exe';
proc.Credentials.UserName := 'Administrator';
proc.Credentials.Domain := 'CORP';
proc.Credentials.Password := GetSecret;
proc.Credentials.LoadUserProfile := True;

proc.Execute;

The credentials builder keeps the password in a private heap copy that it zeroes in place when the password is replaced and when the builder is destroyed. Reading Credentials.Password returns a new copy each time; that copy is the caller's to wipe with ScrubString (VSoft.Process.Credentials) once used. When no Domain is given and the user name is not in UPN form (user@domain), the local account database (.) is used. A launch with credentials doesn't hold up other launches: CreateProcessWithLogonW can take seconds (loading a user profile), and no launch waits for another (the library has no spawn lock). The child gets its redirected std handles from the Secondary Logon service, which duplicates them from this process, and inherits no other handle. CreateProcessWithLogonW can't be used from a service running as LocalSystem (a Windows restriction). CreateProcessWithLogonW limits the command line to 1024 characters; longer command lines are rejected at validation, as are a UserName without a Password, a Domain or Password without a UserName (which would otherwise run the child as the current user), a UPN user name combined with a Domain, and a UserName in DOMAIN\user form, with or without a Domain (set Domain and give the bare user name, or use the UPN form user@domain).

13. Detached launch

proc := TProcess.Create
  .WithStartInfo(procedure(const info : IStartInfoBuilder)
    begin
      info.WithExecutable('opener.exe');
    end);

proc.ExecuteDetached;
// proc.Status will read Running forever, proc.ExitCode will raise.
// On Windows you still get proc.ProcessId (the immediate child);
// on POSIX the grandchild PID is lost across the double-fork.

With property assignments:

proc := TProcess.Create;
proc.StartInfo.Executable := 'opener.exe';

proc.ExecuteDetached;

On Windows a detached child gets no console at all by default (DETACHED_PROCESS), so a console program has nowhere to write, and a window you asked for with HideWindow = False never appears. Set StartInfo.DetachedNewConsole (WithDetachedNewConsole(True)) to give it a console of its own instead (CREATE_NEW_CONSOLE), shown or hidden according to HideWindow:

proc.StartInfo
  .WithHideWindow(False)          // show the program's console window
  .WithDetachedNewConsole(True);
proc.ExecuteDetached;

The option only affects ExecuteDetached (Execute / ExecuteAsync give the child a console of its own, unless InheritHostStdHandles with ShareHostConsole hands it a console handle of the host - see Platform Support; launches with credentials always do) and is ignored on POSIX.

Platform Support

Platform Async I/O Process tree termination Credentials Notes
Windows Threadpool I/O completion on overlapped named pipes Job Object (the child is created suspended and assigned before it runs; KILL_ON_JOB_CLOSE with the default JobMode) plus a process-tree kill (which also reaches programs started outside the job with CREATE_BREAKAWAY_FROM_JOB), then a wait of up to 5 s for all of them; the tree kill alone with JobMode = NoJob or when the job can't be used CreateProcessWithLogonW UTF-16 environment block; only the child's own std handles are inherited (every launch passes an explicit handle list or inherits none, so concurrent launches are not serialised)
Windows ARM64EC (Delphi 13.2+) as Windows as Windows as Windows The Windows implementation, unchanged (WIN64, CPUARM64 and ARM64EC are defined, CPUX64 is not). The runtime package and test project compile and link; not yet run on ARM hardware, and not yet in the DPM package
Linux epoll (+ pidfd on kernel 5.3+; without one the exit is polled in 1, 2, 4 ... 100 ms steps) SIGTERM to the child's process group; SIGKILL 500 ms later, or at once when the child has exited and no other live member of the group remains n/a x86-64 only. UTF-8 environment + argv, signal(SIGPIPE, SIG_IGN) set for the whole host process at unit init (children get the default back)
macOS kqueue (+ EVFILT_PROC) as Linux n/a 64-bit only (x64 and arm64). UTF-8 environment + argv, signal(SIGPIPE, SIG_IGN) as on Linux

The supported POSIX targets are Linux x86-64 and 64-bit macOS (x64 and arm64); compiling the POSIX unit for anything else is a compile-time error.

ExecuteDetached uses a double-fork on POSIX so the grandchild reparents to init (with /dev/null as its stdio), and DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB on Windows (CREATE_NEW_CONSOLE instead of DETACHED_PROCESS with DetachedNewConsole) so the detached child survives the host's Job Object (CI runners etc.). When the host's job does not allow breakaway, the child is started inside it instead. CREATE_NEW_PROCESS_GROUP is not used, so the detached child and the programs it starts do not ignore Ctrl+C (the host's own Ctrl+C never reaches them, as they never share its console). On POSIX, child-init failures (chdir / dup2 / exec) come back through a self-pipe, so ExecuteDetached still returns False when the program can't be started; on Windows CreateProcess reports them directly.

On Windows the child gets its own console (CREATE_NEW_CONSOLE), hidden unless HideWindow is False, so it does not share the host's console (the one exception is ShareHostConsole, below): output from a stream you don't redirect goes to that console. When you redirect some streams but not others, the others are connected to NUL (attach only a stdout target and the child's stderr is discarded and its stdin is at end of file). On POSIX, streams you don't redirect are inherited from the host.

To get the POSIX behaviour on Windows, set StartInfo.InheritHostStdHandles (WithInheritHostStdHandles(True)): a stream you don't redirect then gets a copy of the host's own std handle (GetStdHandle).

  • Where output goes. With only stdout captured, the child's stderr goes wherever the host's stderr goes: the host's console, or the file or pipe a CI agent redirected it to. The child reads the host's stdin.
  • Console. A host std handle that is the host's console is not given to the child: a console handle only works in a process attached to that console, and the child has a console of its own. That stream is handled as if the host had no handle for it (NUL, or the child's own console when no stream got a handle), and nothing is logged. Handles that are files or pipes (host > log.txt, a CI agent) are still passed on.
  • ShareHostConsole. Set StartInfo.ShareHostConsole (WithShareHostConsole(True)) as well to give the child the console handle too. No new console is created for that launch: the child shares the host's console, writes to it and reads from it, and Ctrl+C / Ctrl+Break in that console reach the child (and what it starts) as well as the host. Leave it off when the host handles Ctrl+C itself and decides what to stop. It has no effect without InheritHostStdHandles.
  • Fallbacks. A host with no such handle (a GUI program) gives the child NUL for that stream, as without the option. A handle that can't be given to the child gives NUL and a message in Errors: either it can't be duplicated, or (with ShareHostConsole) it is a console pseudo-handle on Windows 7 or earlier, which handle inheritance lists reject.
  • Credentials and detached runs. It works the same with credentials (the Secondary Logon service duplicates the copy into the child), except for ShareHostConsole: a child started with credentials never shares the host's console, so a stream whose host handle is the console is NUL and Errors says so. ExecuteDetached ignores it.

On POSIX the child starts with an empty signal mask and SIGPIPE / SIGXFSZ reset to their defaults (any other signal the host ignores stays ignored), and every file descriptor of the host other than its stdio is kept out of the program: with close_range where the kernel has it, otherwise the child lists the descriptors that are actually open (/proc/self/fd on Linux, proc_pidinfo on macOS) and closes those, so no descriptor number is out of reach and nothing depends on RLIMIT_NOFILE. A loop up to that limit is only the last resort. A file without a #! line that execve rejects is run with /bin/sh, as execvp does. When the host's stdin is a terminal and the child's stdin is not redirected, the child stays in the host's process group (so it can read the terminal and receives Ctrl+C); in that case Terminate signals the child itself rather than its whole tree. Executable lookup works the same way on every platform - see Finding the executable.

Unless it inherits the host's terminal as its stdin, the POSIX child runs in a process group of its own:

  • Stopping the tree. Terminate, a timeout and cancellation send SIGTERM to the group. SIGKILL follows 500 ms later, or immediately once the child itself has exited and no other live member of the group remains. It reaches group members that ignore or outlive SIGTERM even after the child itself has died (the exited child is kept unreaped until then, so the group id cannot be reused). Terminate or a timeout can therefore take up to about 500 ms when such members exist.
  • Terminal access. For the terminal that group is a background group. If the child itself is stopped for using the controlling terminal (SIGTTIN / SIGTTOU), nothing would ever continue it, so the run is failed: an error goes into Errors and the child is terminated (Status = Terminated). A stopped grandchild is not detected, and a stop by SIGSTOP / SIGTSTP is left alone.

Background processes the child leaves behind are not killed on a normal exit. On Windows, with the default JobMode = KillOnRelease, they are ended when the IProcess is released or re-run (the job is closed); with KillOnTerminate or NoJob, and always on POSIX, they keep running.

IStartInfoBuilder per-platform behaviour

Property Windows Linux macOS
Executable, WorkingDirectory, TimeoutInMSec yes yes yes
HideWindow (default True) shows or hides the child's console window ignored ignored
DetachedNewConsole (default False) ExecuteDetached gives the child a console of its own (CREATE_NEW_CONSOLE) instead of none (DETACHED_PROCESS) ignored ignored
InheritHostStdHandles (default False) a stream that is not redirected gets a copy of the host's std handle instead of NUL; a handle that is the host's console is only given with ShareHostConsole ignored (always inherited) ignored (always inherited)
ShareHostConsole (default False) with InheritHostStdHandles, a host std handle that is the console is given too: the child shares the host's console, and its Ctrl+C / Ctrl+Break ignored ignored
DeliverOnRunThread (default False) pipe targets are called on the thread running the run instead of threadpool threads ignored (always the thread running the run) ignored (always the thread running the run)
Bare Executable name this process's PATH (never the child's), .exe appended when there is no extension, no PATHEXT, never the current directory this process's PATH (never the child's; /usr/bin:/bin when it has none), file must be executable same as Linux
TerminateOnTimeout (default True) False: child left running, our pipe ends and handles closed False: no signal, pipe fds closed, child reaped by a background thread when it exits same as Linux
JobMode (default KillOnRelease) job object flags / no job (see Job mode) no job objects: leftovers always survive, stop signals go to the process group (only to the child when it inherits a terminal stdin) same as Linux
TimedOutExitCode, TerminateExitCode yes yes yes
InputEncoding, OutputEncoding yes (auto-applied to line / TStringBuilder targets) yes yes
PipeBufferSize (0 = 64KB default) named-pipe buffers + read buffer read buffer only (the kernel's pipe size is unchanged) same as Linux
PipeDrainTimeoutMS (default 2000) yes yes yes
MarshallEventsToMainThread yes yes yes
ProcessorAffinity SetProcessAffinityMask sched_setaffinity (in the child, before exec) ignored (macOS has no equivalent)
Priority (TProcessPriority) priority class on the process setpriority(PRIO_PROCESS, 0, nice) setpriority(PRIO_PROCESS, 0, nice)
ArgvZero ignored (Windows command-line has no separate argv[0]) yes same as Linux
Credentials (ICredentialsBuilder / WithCredentials) CreateProcessWithLogonW rejected at validation - POSIX has no equivalent same as Linux

Maturity note: Windows is the primary target and is exercised heavily by the unit and end-to-end test suite. The Linux and macOS implementations share the POSIX code path. The library and the whole test suite compile for Linux64, macOS x64 and macOS arm64, and the suite (including its shell-script fixtures and POSIX-specific tests) passes on Linux x64 and macOS arm64, but the POSIX code has seen little real-world use so far. The most recent POSIX changes (closing inherited descriptors by listing them, the process-group handling on Terminate, exit polling without a pidfd, the terminal-stop check) have been run on Linux on a single kernel only, and their macOS-specific code paths have been compiled but not yet run. Treat POSIX support as a work in progress; bug reports and fixes are very welcome. Windows ARM64EC (Delphi 13.2 and later) uses the Windows code path and is compile-tested only so far.

Test Suite

The Tests/ directory contains a DUnitX test project (VSoft.Process.Tests.dproj) covering:

  • Unit tests for each builder (Tests/VSoft.Process.Tests.CommandLine.pas, Environment.pas, Credentials.pas, StartInfo.pas), every pipe source and target (PipeSource.pas, PipeTarget.pas), and the string-quoting utilities (Utils.pas).
  • End-to-end tests (Tests/VSoft.Process.Tests.Process.pas, Tests/VSoft.Process.Tests.ProcessE2E.pas and Tests/VSoft.Process.Tests.Lifecycle.pas) that spawn real child processes and assert on captured output, exit codes, environment propagation, cancellation, timeouts, process re-use, failing pipe targets, concurrent launches and pipe draining.
  • Process lifetime and lookup tests (Tests/VSoft.Process.Tests.Lifetime.pas, Tests/VSoft.Process.Tests.ExecutableSearch.pas) for JobMode, TerminateOnTimeout, handle leaks and the PATH search.
  • A helper child process: started as VSoft.Process.Tests.exe --vsp-helper=<mode>, the test exe runs one job instead of the tests (Tests/VSoft.Process.Tests.Helper.pas, Windows). The jobs are things a script can't do, such as starting a program with CREATE_BREAKAWAY_FROM_JOB or starting programs non-stop from several threads.
  • Credentials launch tests (Tests/VSoft.Process.Tests.Logon.pas, Windows). They never log on to a real account: the launcher is stubbed, or the logon is LOGON_NETCREDENTIALS_ONLY with a made-up user, which Windows doesn't check locally.
  • Deterministic script fixtures in Tests/scripts/, one .cmd and one .sh per scenario (echo-args, echo-stdin, print-env, print-cwd, print-large, print-stderr, print-both, print-then-sleep, print-invalid-utf8, exit-with, sleep, spawn-and-wait, spawn-background-and-exit). Each script has a fixed, known output so the E2E tests can compare against exact bytes rather than whatever the host's ping / find / etc. happens to print. Platform differences live in Tests/VSoft.Process.Tests.Common.pas.

To run the tests, open the test dproj in the IDE and build/run. The console runner runs unattended (it only waits for a key press when started under the debugger), writes NUnit XML for CI ingestion (dunitx-results.xml next to the exe, or the path given with --xml:<path>) and accepts --run:<fixture or test> to run a subset. Test dependencies (DUnitX, VSoft.CancellationToken) are pulled in via DPM; see the <DPMSearch> lines in the dproj for the package layout if you are not using DPM. The project file targets Win32, Win64 and Linux64; to run the suite on macOS, add the platform.

API Reference (high level)

IProcess - the central interface returned by TProcess.Create.

  • Execution: Execute(cancellationToken|cancellationEvent), ExecuteAsync(cancellationToken|cancellationEvent), ExecuteDetached, Terminate / Terminate(waitMS) (0 = only request), Clone.
  • Configuration: WithStartInfo, WithCommandLine, WithEnvironment, WithCredentials, WithStdOutPipe, WithStdErrPipe, WithStdInPipe, ClearStdOutPipes, ClearStdErrPipes, ClearPipes (stdout and stderr targets and the stdin source; see Threading and lifetime contracts).
  • State: Status, ExitCode, TimedOut, ProcessId, ResolvedExecutable, StartMode, LastError (the OS error code when the OS refused to start the child, otherwise 0; see Execution modes), Errors, CompletionHandle (Windows only; see Async execution and status).
  • Events: OnStarted, OnExit.

IStartInfoBuilder - Executable, ArgvZero (POSIX), WorkingDirectory, HideWindow (default True, Windows), TimeoutInMSec (default 0 = no timeout), TimedOutExitCode, TerminateExitCode, Priority, ProcessorAffinity, InputEncoding, OutputEncoding, PipeBufferSize (0 = 64KB), PipeDrainTimeoutMS (default 2000), JobMode (default KillOnRelease), TerminateOnTimeout (default True), DetachedNewConsole (default False, Windows), InheritHostStdHandles (default False, Windows), ShareHostConsole (default False, Windows), DeliverOnRunThread (default False, Windows), MarshallEventsToMainThread (queue events on the main thread), Clone, and With* fluent equivalents (named after the property, except WithTimeout for TimeoutInMSec and WithPipeDrainTimeout for PipeDrainTimeoutMS). See the Platform Support matrix for per-platform behaviour.

ICommandLineBuilder - Add / AddSensitive (name-only or name + value, qtDouble by default), AddUnsafeBatchRaw (trusted text passed verbatim, even to a batch file; see Quoting), Remove, Clear, Count, Arguments[], ArgumentsByName[], DefaultValueSeparator (initially a space) / WithDefaultValueSeparator, DefaultArgumentSeparator / WithDefaultArgumentSeparator, ToString, ToLogString, Clone. Arguments[] returns IArgument: Name, Value, Quoted, NameValueSeparator, QuoteName, UnsafeBatchRaw. DefaultArgumentSeparator, Quoted and QuoteName only affect the Windows command line; POSIX passes argv slots verbatim.

IEnvironmentBuilder - AddOrSet, AddOrSetValues, Remove, Clear, TryGetValue, Exists, Reset, Count, HasOverrides, ToEnvironmentBlock (Windows), ToEnvironArray (POSIX), ToString, Clone. Names are case-insensitive on Windows and case-sensitive on POSIX; empty names and names containing = raise EArgumentException.

ICredentialsBuilder (Windows-only) - UserName, Domain, Password, LoadUserProfile, NetCredentialsOnly, plus WithUser / WithDomain / WithPassword / WithUserProfile / WithNetCredentialsOnly, Validate, Clone. Reading Password returns a new copy each time; wipe it with ScrubString (VSoft.Process.Credentials) once used.

TPipeTarget factory - ToStream, ToFile(name, append), ToStringBuilder(sb, enc), ToDelegate, ToMethod, ToProcedure, ToLineDelegate, ToLineMethod, ToLineProcedure, ToAll([target, ...]) (an open array; the targets are copied). The line targets and ToStringBuilder drop a byte order mark that matches their encoding at the start of each stream; the byte targets don't.

TPipeSource factory - FromString(s, enc), FromBytes(b), FromStream(s) (streams from the current position; a re-run sends the same data again), FromFile(path) (streamed in chunks; a missing file raises EVSoftProcessError from Execute*), FileAsStdIn(path) (the child's stdin is the file itself, not a pipe: no writer thread, and a program that seeks on stdin or checks its size or type sees a real file; opened for each run, so a re-run reads it from the start; a missing file raises EVSoftProcessError from Execute*), Interactive. FromFile and FileAsStdIn open the file shared for reading and writing (fmShareDenyNone), so a file another program is still writing can be used and is not write-locked during the run; an open failure other than a missing file raises EFOpenError.

IInteractivePipeSource - Write(bytes|text), Close, IsClosed, WaitForData(timeoutMs).

Constants in VSoft.Process: EXIT_OK = 0, EXIT_TERMINATED = 9999, EXIT_TIMEDOUT = 8888 (defaults; override via the corresponding StartInfo properties), DEFAULT_PIPE_DRAIN_TIMEOUT_MS = 2000.

License

Licensed under the Apache License, Version 2.0. See LICENSE.txt for details.

Copyright (c) Vincent Parrett and contributors.

Contributing

Bug reports, fixes, and PRs are welcome. Please:

  • Keep code XE2-compatible: no inline var, no string helpers (s.Contains(...), s.ToLower, ...), no type inference.
  • Use camelCase for locals and parameters, lowercase result, and F-prefixed class fields.
  • Add or update tests under Tests/ for any behaviour change;
  • Use CRLF line endings.

About

A Delphi library for running processes and capturing out, interacting with it.

Topics

Resources

Stars

19 stars

Watchers

3 watching

Forks

Releases

Contributors

Languages