Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

Foundation Process on macOS: Subprocess Ownership, Pipes, and Exit State

Run macOS subprocesses with executable allowlists, argument arrays, concurrent pipe draining, cancellation, exit handling, and sandbox boundaries.

Foundation’s Process launches a child executable and exposes its arguments, environment, working directory, standard streams, and termination state. Pipe connects those streams to the parent. This is useful for a Mac app that invokes a narrowly defined helper or command-line tool, but it is not a substitute for XPC, a general service manager, or a safe way to pass untrusted shell text to an interpreter.

A production subprocess feature needs an explicit executable policy, input and output limits, cancellation behavior, and a lifecycle owner. A launched process has its own address space but inherits relevant environment and sandbox constraints from the parent. Launching a child does not automatically give a sandboxed app broader privileges or make an arbitrary helper a trusted extension.

Use an executable URL and argument vector

Set executableURL to a specific executable and pass each argument as one array element. Do not build a shell command string from user input or use /bin/sh -c unless a shell is intentionally part of the design and every parameter is handled as untrusted data. An argument array avoids one class of shell metacharacter interpretation, but the invoked program still has its own parsing and security semantics.

import Foundation

func runVersionProbe() throws -> (Int32, Data) {
    let process = Process()
    let output = Pipe()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/sw_vers")
    process.arguments = ["-productVersion"]
    process.standardOutput = output
    process.standardError = output

    try process.run()
    // Run this blocking read on a worker, not the AppKit main thread.
    let data = output.fileHandleForReading.readDataToEndOfFile()
    process.waitUntilExit()
    return (process.terminationStatus, data)
}

This bounded probe merges stdout and stderr into one pipe and drains it while the child runs. It is appropriate only when the output is expected to be small and the caller is already on a worker thread. Never call readDataToEndOfFile() on the UI thread for an operation whose duration depends on an external program.

For production, set currentDirectoryURL and environment explicitly when behavior depends on them. By default, the subprocess inherits its environment; GUI apps may not receive the same PATH or locale as an interactive shell. Prefer an absolute executable path and a minimal, intentional environment. Do not change the environment after launch and expect it to affect the running process.

Drain pipes without deadlocks or unbounded memory

A child blocks when a pipe buffer fills and the parent is not reading. If stdout and stderr use separate pipes, reading one to completion before the other can deadlock when the child fills the unread stream. Read both concurrently or redirect output to a file or bounded sink. For a short bounded command, merging streams simplifies draining but loses the distinction between diagnostic and result output.

readDataToEndOfFile() blocks until the writer closes. Use it only off the main thread and only when the producer is expected to terminate. For interactive protocols, install asynchronous readability handlers or use a structured I/O layer that drains continuously, frames messages, and applies backpressure. Cap retained output bytes; truncation should be recorded explicitly rather than silently allocating until memory is exhausted.

If the child expects standard input, close or feed the input pipe according to the protocol. Leaving a pipe open can make the child wait forever for end-of-file. Also close file handles on every success, launch failure, and cancellation path. A Pipe is a pair of file handles with descriptor ownership, not a durable queue.

Completion, cancellation, and exit status

Configure termination handling before launching. Check both termination reason and status; an exit code of zero is the executable’s convention for success, not a guarantee that its output is valid or that a requested application-level operation committed. Parse output, validate expected format, and use a timeout policy for work that can hang.

waitUntilExit() blocks the calling thread. A termination handler is the better primitive for asynchronous UI work, but Apple’s documentation notes that the handler is not guaranteed to have fully executed before waitUntilExit() returns. Choose one completion path and synchronize it; do not both block and assume the handler already updated app state.

Cancellation should send a deliberate signal, allow a grace period if the helper can clean up, and then report whether it exited. terminate() is not a transactional rollback. If the child may write files or change external state, use temporary outputs and an app-owned commit step. A partially written output should not be presented as complete.

Build a timeout around the operation owner rather than sleeping on the UI thread. Record when launch begins, schedule a cancellation action, and cancel that timer when termination arrives. If the child ignores termination, escalate only according to the feature’s documented policy and communicate that cancellation is still in progress. A timeout means the app stopped waiting or requested termination; it does not prove that every side effect was undone.

Separate “process exited” from “operation accepted.” A helper may exit successfully while producing malformed or incomplete output, or fail after creating a valid temporary artifact. Parse a bounded result, validate the schema and file size, and commit into canonical app state only after those checks. Include a correlation identifier in app-side logs so a launch, output stream, parser result, and user action can be joined without storing sensitive arguments.

Process termination and pipe EOF are related but distinct. A descendant process may inherit a pipe’s write descriptor and keep it open after the direct child exits. Avoid launching background descendants in workflows that expect immediate EOF. If the process tree is part of the design, own and supervise it explicitly rather than relying on a single Process object to represent every child.

Sandbox, helper choice, and trust boundaries

Apple documents that child processes created by Process inherit the parent app’s sandbox. If a helper requires a different entitlement boundary or an independently managed lifecycle, use the supported XPC service architecture rather than trying to grant a child arbitrary powers. Do not disable sandboxing or ask users to run elevated shell commands to work around a missing capability.

Never construct an executable path or argument list from untrusted text without validation. An absolute path can still point to a replaceable or unexpected executable if the app’s threat model includes writable locations. Restrict commands to a documented allowlist and avoid passing secrets in arguments or environment variables that may be observable in diagnostics. Use a purpose-built API instead of subprocess invocation when one exists.

Do not launch an interactive shell command just to locate a binary through PATH unless shell lookup is an explicit requirement. GUI applications can have different environment variables from a terminal, and a user’s shell startup files are not necessarily applied. Resolve supported tools through an app bundle or a documented system location, verify that the expected file exists, and show an actionable error if it does not. Never execute a downloaded script as a convenience fallback.

For long-running helpers, define what happens when the parent app quits. A child process may outlive a window and can continue work after the user believes the operation ended. Terminate or intentionally hand off ownership, persist enough task identity to explain remaining work, and reap temporary files after success or cancellation. If the task must survive app termination, use an operating-system service model designed for that lifecycle rather than a detached Process that the app no longer supervises.

Diagnostics and acceptance tests

Test executable missing, launch permission denied, nonzero exit, signal termination, empty output, large output, large stderr, child that waits for stdin EOF, timeout, cancellation, child that spawns a descendant, and launch failure after pipe allocation. Assert no main-thread blocking, no pipe leak, bounded retained output, and one completion result per run.

Log executable identity from the allowlist, argument categories with secrets redacted, start/end time, exit reason/status, bytes drained, timeout/cancel path, and parsing outcome. Do not log full command arguments if they can contain private document paths or credentials. Measure launch latency, runtime, output volume, cancellation latency, and peak memory. A robust Process integration treats pipes as backpressured streams and a subprocess as a failure-prone child with limited inherited authority.

Related:

Sources:

Comments