FreeBSD posix_spawn: Descriptor Actions, Signal State, and Child Completion Contracts
Launch FreeBSD child programs with explicit descriptor actions, signal attributes, environment policy, error handling, and verified completion semantics.
Launching a helper is an interface boundary, not just a call to an executable. The helper receives descriptors, an environment, signal state, credentials, and a process group. If any of those are inherited accidentally, a small utility can keep a socket open, ignore cancellation, or behave differently under a service supervisor than it does in an interactive terminal.
FreeBSD’s posix_spawn() describes a restricted child setup through attributes and file actions before executing a program. This article uses the FreeBSD 15.0 source-tree manuals. The portable API dates to FreeBSD 8.0; the FreeBSD addclosefrom_np, addchdir_np, and addfchdir_np extensions discussed here appeared in 13.1. Do not assume those extensions exist on every POSIX platform.
Choose executable identity before launching
posix_spawn() accepts an executable pathname. posix_spawnp() searches PATH when its file argument contains no slash. A service that intends to run one reviewed executable should prefer an absolute path and separately control who can replace that executable or its parent directories. An absolute path removes search ambiguity; it does not make a writable binary trustworthy.
Arguments are a null-terminated array of strings, not a shell command line. Passing "a; b" as one argument does not invoke two commands. That changes if the selected executable is /bin/sh with -c, because the shell then interprets the supplied text. Avoid adding a shell merely to assemble options from user input.
Supply an explicit, null-terminated environment for a narrowly scoped helper. Retaining the entire service environment may expose credentials or configuration that the child does not need. Conversely, an empty environment can break programs that legitimately require locale or other settings. Treat the allowlist as part of the helper’s documented interface, not as a universal recipe for all commands.
Model file actions as an ordered transformation
Without file actions, open descriptors remain open in the child unless their FD_CLOEXEC flag removes them during execution. With actions, the child starts with the parent’s descriptor set, applies its specified process attributes, performs file actions in insertion order, and finally closes remaining close-on-exec descriptors.
Order matters. If a pipe’s write end is duplicated onto standard output, close the original write-end descriptor afterward, not before. A close-from action placed before that duplication can remove the very descriptor needed for the later action. Similarly, a working-directory action affects relative opens inserted after it. Review actions as a short program with intermediate descriptor states.
FreeBSD’s posix_spawn_file_actions_adddup2() clears close-on-exec on the destination even when source and destination are the same descriptor. The file-action manual calls out this useful difference from an ordinary dup2() call. It lets the launcher deliberately retain a selected descriptor that would otherwise disappear at execution.
posix_spawn_file_actions_addclosefrom_np(&actions, 3) closes descriptors numbered three and above. It is appropriate only when the child’s contract preserves no descriptors above standard input, output, and error. A helper using an inherited control socket or readiness descriptor needs a different plan. This extension is descriptor hygiene, not a sandbox or a credential boundary.
Reset signals deliberately
A supervisor may block signals in its worker threads or ignore SIGINT. A spawned program can inherit those choices unless the attributes say otherwise. POSIX_SPAWN_SETSIGMASK applies the supplied signal mask. POSIX_SPAWN_SETSIGDEF restores the default action for the selected signals, including signals ignored by the caller.
Setting an attribute value without enabling its corresponding flag does not request that behavior. Build the mask and default-action set, store them in the attribute object, and set the flags together. Document which thread launches children, because process-wide dispositions and the launching thread’s mask are different kinds of state.
POSIX_SPAWN_SETPGROUP with a group value of zero creates a new process group whose ID equals the child’s PID. That gives a supervisor a potential cancellation group, but it does not automatically forward signals, reap descendants, or establish a controlling terminal. Do not mistake process-group separation for complete job-control integration.
A bounded launcher example
The following illustrative FreeBSD 13.1-or-newer program runs /usr/bin/true, supplies a minimal environment, replaces standard input with /dev/null, resets two termination-related dispositions, and retains only descriptors zero through two. It inherits stdout and stderr intentionally. It is not a generic daemon launcher, and no target-FreeBSD execution is implied by this example.
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <spawn.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
posix_spawn_file_actions_t actions;
posix_spawnattr_t attr;
sigset_t mask, defaults;
pid_t child;
int rc, status;
char *argv[] = { "true", NULL };
char *envp[] = { "PATH=/usr/bin:/bin", "LANG=C", NULL };
rc = posix_spawn_file_actions_init(&actions);
if (rc != 0) {
fprintf(stderr, "actions: %s\n", strerror(rc));
return 1;
}
rc = posix_spawnattr_init(&attr);
if (rc != 0) {
posix_spawn_file_actions_destroy(&actions);
fprintf(stderr, "attributes: %s\n", strerror(rc));
return 1;
}
#define SETUP(call) do { rc = (call); if (rc != 0) goto setup_failed; } while (0)
sigemptyset(&mask);
sigemptyset(&defaults);
sigaddset(&defaults, SIGINT);
sigaddset(&defaults, SIGTERM);
SETUP(posix_spawnattr_setsigmask(&attr, &mask));
SETUP(posix_spawnattr_setsigdefault(&attr, &defaults));
SETUP(posix_spawnattr_setpgroup(&attr, 0));
SETUP(posix_spawnattr_setflags(&attr,
POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF |
POSIX_SPAWN_SETPGROUP));
SETUP(posix_spawn_file_actions_addopen(&actions,
STDIN_FILENO, "/dev/null", O_RDONLY, 0));
SETUP(posix_spawn_file_actions_addclosefrom_np(&actions, 3));
rc = posix_spawn(&child, "/usr/bin/true", &actions, &attr,
argv, envp);
posix_spawnattr_destroy(&attr);
posix_spawn_file_actions_destroy(&actions);
if (rc != 0) {
fprintf(stderr, "spawn: %s\n", strerror(rc));
return 1;
}
while (waitpid(child, &status, 0) == -1) {
if (errno == EINTR)
continue;
perror("waitpid");
return 1;
}
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "child did not complete successfully\n");
return 1;
}
return 0;
setup_failed:
posix_spawnattr_destroy(&attr);
posix_spawn_file_actions_destroy(&actions);
fprintf(stderr, "setup: %s\n", strerror(rc));
return 1;
}
The spawn-family functions return an error number directly. Reporting only errno after a nonzero spawn return can diagnose the wrong failure. waitpid(), in contrast, uses its conventional return value and errno; retry the interrupted wait, and distinguish normal exit from signal termination. This launcher assumes the parent has not configured SIGCHLD with explicit ignoring or SA_NOCLDWAIT, which can discard child status and make a subsequent wait report ECHILD.
Separate launch, execution, and application success
A zero spawn result gives the caller a child PID. It does not establish that the child completed its intended work. The FreeBSD manual also describes failures reported through a child’s exit status 127 when they occur after a successful return. An application can itself exit with 127, so that status alone is not a complete execution-error protocol.
For a real helper, define application readiness or completion separately: a structured result on a dedicated channel, an expected output artifact with validation, or a documented exit-code contract. Destroying the actions and attributes after the spawn call releases launcher-side configuration; it does not stop or reap the child.
The FreeBSD implementation uses vfork() and does not run fork handlers for posix_spawn(). Do not rely on pthread_atfork() hooks to prepare this child. If the desired setup cannot be expressed through supported actions and attributes, redesign the boundary rather than assuming hidden post-fork callbacks will run.
Verify failure paths before integration
Replace true in a controlled test harness with a small probe that reports its descriptor set, mask, process group, and approved environment keys. Never print inherited secrets. Open an extra descriptor in the launcher and verify that it disappears; arrange ignored and blocked signals in the parent and verify the intended child state. Exercise a missing executable and an invalid file action, then test normal exit, nonzero exit, and signal termination.
A production supervisor also needs a deadline and a cancellation/reaping policy. This synchronous example deliberately has neither because true is bounded; copying it around an arbitrary long-running helper would be unsafe. Keep launch error records distinct from child failures, retain enough identity to diagnose the exact executable and configuration, and restore the known-good launcher contract if an integration changes signal or descriptor behavior unexpectedly.
Related:
- FreeBSD Process Descriptors: Track Child Lifecycles Without PID Races
- FreeBSD daemon(8) Operations: Supervise Foreground Programs Predictably
Sources: