std.process.Child for spawning and managing child processes in Zig;Learn Zig Series):Exercise 1: AutoRebuilder with --filter flag for excluded directories
const std = @import("std");
const linux = std.os.linux;
const FilteredRebuilder = struct {
allocator: std.mem.Allocator,
inotify_fd: i32,
wd_map: std.AutoHashMap(i32, []const u8),
exclude_dirs: std.ArrayList([]const u8),
build_command: []const []const u8,
watch_extensions: []const []const u8,
last_build_time: i64,
fn init(
allocator: std.mem.Allocator,
build_cmd: []const []const u8,
extensions: []const []const u8,
) !FilteredRebuilder {
const fd = linux.inotify_init1(linux.IN.NONBLOCK | linux.IN.CLOEXEC);
if (@as(isize, @bitCast(@as(usize, fd))) < 0)
return error.InotifyInitFailed;
return .{
.allocator = allocator,
.inotify_fd = @intCast(fd),
.wd_map = std.AutoHashMap(i32, []const u8).init(allocator),
.exclude_dirs = std.ArrayList([]const u8).init(allocator),
.build_command = build_cmd,
.watch_extensions = extensions,
.last_build_time = 0,
};
}
fn deinit(self: *FilteredRebuilder) void {
var it = self.wd_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
self.wd_map.deinit();
self.exclude_dirs.deinit();
std.posix.close(@intCast(self.inotify_fd));
}
fn addExclude(self: *FilteredRebuilder, dir_name: []const u8) !void {
try self.exclude_dirs.append(dir_name);
}
fn isExcluded(self: *FilteredRebuilder, name: []const u8) bool {
if (name.len > 0 and name[0] == '.') return true;
if (std.mem.eql(u8, name, "zig-cache")) return true;
if (std.mem.eql(u8, name, "zig-out")) return true;
for (self.exclude_dirs.items) |ex| {
if (std.mem.eql(u8, name, ex)) return true;
}
return false;
}
fn watchRecursive(self: *FilteredRebuilder, path: []const u8) !void {
const c_path = try self.allocator.dupeZ(u8, path);
defer self.allocator.free(c_path);
const mask = linux.IN.MODIFY | linux.IN.CREATE |
linux.IN.DELETE | linux.IN.MOVED_FROM | linux.IN.MOVED_TO;
const wd = linux.inotify_add_watch(self.inotify_fd, c_path, mask);
if (@as(isize, @bitCast(@as(usize, wd))) >= 0) {
const copy = try self.allocator.dupe(u8, path);
try self.wd_map.put(@intCast(wd), copy);
}
var dir = std.fs.cwd().openDir(path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (try iter.next()) |entry| {
if (entry.kind != .directory) continue;
if (self.isExcluded(entry.name)) continue;
const sub = try std.fs.path.join(self.allocator, &.{ path, entry.name });
defer self.allocator.free(sub);
try self.watchRecursive(sub);
}
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
const build_cmd = &[_][]const u8{ "zig", "build" };
const extensions = &[_][]const u8{ ".zig", ".zon" };
var rebuilder = try FilteredRebuilder.init(allocator, build_cmd, extensions);
defer rebuilder.deinit();
// parse --filter args
var i: usize = 1;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--filter") and i + 1 < args.len) {
i += 1;
try rebuilder.addExclude(args[i]);
}
}
try rebuilder.watchRecursive("src");
}
The key addition is the exclude_dirs list populated from --filter flags on the command line. isExcluded checks against both the hardcoded defaults and user-supplied exclusions during recursive traversal.
Exercise 2: JSON event logger using AtomicWriter
const std = @import("std");
const EventLogger = struct {
allocator: std.mem.Allocator,
log_path: []const u8,
entries: std.ArrayList(LogEntry),
const LogEntry = struct {
timestamp: i64,
event_type: []const u8,
file_path: []const u8,
file_size: ?u64,
};
fn init(allocator: std.mem.Allocator, path: []const u8) EventLogger {
return .{
.allocator = allocator,
.log_path = path,
.entries = std.ArrayList(LogEntry).init(allocator),
};
}
fn deinit(self: *EventLogger) void {
self.entries.deinit();
}
fn logEvent(self: *EventLogger, event_type: []const u8, path: []const u8, size: ?u64) !void {
try self.entries.append(.{
.timestamp = std.time.milliTimestamp(),
.event_type = event_type,
.file_path = path,
.file_size = size,
});
try self.flush();
}
fn flush(self: *EventLogger) !void {
// atomic write: write to .tmp, then rename
const tmp_path = try std.fmt.allocPrint(self.allocator, "{s}.tmp", .{self.log_path});
defer self.allocator.free(tmp_path);
const file = try std.fs.cwd().createFile(tmp_path, .{});
const writer = file.writer();
try writer.writeAll("[\n");
for (self.entries.items, 0..) |entry, i| {
try writer.print(" {{\"timestamp\":{d},\"type\":\"{s}\",\"path\":\"{s}\"", .{
entry.timestamp, entry.event_type, entry.file_path,
});
if (entry.file_size) |sz| {
try writer.print(",\"size\":{d}", .{sz});
}
try writer.writeAll("}");
if (i < self.entries.items.len - 1) try writer.writeAll(",");
try writer.writeAll("\n");
}
try writer.writeAll("]\n");
file.close();
try std.fs.cwd().rename(tmp_path, self.log_path);
}
};
test "event logger writes valid JSON" {
const allocator = std.testing.allocator;
var logger = EventLogger.init(allocator, "/tmp/test_events.json");
defer logger.deinit();
try logger.logEvent("create", "/tmp/foo.txt", 42);
try logger.logEvent("modify", "/tmp/foo.txt", 100);
try logger.logEvent("delete", "/tmp/foo.txt", null);
const content = try std.fs.cwd().readFileAlloc(allocator, "/tmp/test_events.json", 8192);
defer allocator.free(content);
// should start with [ and contain our entries
try std.testing.expect(content.len > 10);
try std.testing.expect(std.mem.indexOf(u8, content, "create") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "delete") != null);
std.fs.cwd().deleteFile("/tmp/test_events.json") catch {};
}
Each call to logEvent appends to the in-memory list, then flush writes the entire array to a .tmp file and renames it atomically. Even if the watcher crashes mid-flush, the previous version of the log file is intact.
Exercise 3: File sync tool that mirrors changes to a destination
const std = @import("std");
const FileSyncer = struct {
allocator: std.mem.Allocator,
src_root: []const u8,
dst_root: []const u8,
fn init(allocator: std.mem.Allocator, src: []const u8, dst: []const u8) FileSyncer {
return .{
.allocator = allocator,
.src_root = src,
.dst_root = dst,
};
}
fn syncFile(self: *FileSyncer, rel_path: []const u8) !void {
const src_full = try std.fs.path.join(self.allocator, &.{ self.src_root, rel_path });
defer self.allocator.free(src_full);
const dst_full = try std.fs.path.join(self.allocator, &.{ self.dst_root, rel_path });
defer self.allocator.free(dst_full);
// ensure destination directory exists
if (std.fs.path.dirname(dst_full)) |dir| {
std.fs.cwd().makePath(dir) catch {};
}
// read source
const data = std.fs.cwd().readFileAlloc(self.allocator, src_full, 10 * 1024 * 1024) catch |err| {
// if source doesn't exist, it was probably a delete
if (err == error.FileNotFound) return;
return err;
};
defer self.allocator.free(data);
// write to destination
const file = try std.fs.cwd().createFile(dst_full, .{});
defer file.close();
try file.writeAll(data);
}
fn deleteFile(self: *FileSyncer, rel_path: []const u8) !void {
const dst_full = try std.fs.path.join(self.allocator, &.{ self.dst_root, rel_path });
defer self.allocator.free(dst_full);
std.fs.cwd().deleteFile(dst_full) catch {};
}
};
test "syncer copies and deletes" {
const allocator = std.testing.allocator;
var src = std.testing.tmpDir(.{});
defer src.cleanup();
var dst = std.testing.tmpDir(.{});
defer dst.cleanup();
// create a file in source
try src.dir.makePath("sub");
var sub = try src.dir.openDir("sub", .{});
defer sub.close();
try sub.writeFile(.{ .sub_path = "test.txt", .data = "hello sync" });
const src_path = try src.dir.realpathAlloc(allocator, ".");
defer allocator.free(src_path);
const dst_path = try dst.dir.realpathAlloc(allocator, ".");
defer allocator.free(dst_path);
var syncer = FileSyncer.init(allocator, src_path, dst_path);
try syncer.syncFile("sub/test.txt");
// verify copy
const content = try dst.dir.readFileAlloc(allocator, "sub/test.txt", 1024);
defer allocator.free(content);
try std.testing.expectEqualStrings("hello sync", content);
// test delete
try syncer.deleteFile("sub/test.txt");
dst.dir.access("sub/test.txt", .{}) catch |err| {
try std.testing.expectEqual(error.FileNotFound, err);
};
}
The syncFile function reads the source, ensures the destination directory tree exists with makePath, and writes the content. deleteFile removes the mirror copy. In a full implementation you'd hook these into the inotify event loop from the episode.
Last episode we built a file watcher -- monitoring the filesystem for changes in real time. We used inotify on Linux and kqueue on macOS, built a cross-platform abstraction, handled event debouncing, and finished with a practical auto-rebuild tool. All of that was about reacting to the world around our process. Today we go in the other direction: our process creates and manages OTHER processes. This is how shells work, how init systems work, how supervisors like systemd and PM2 work. The Unix process model -- fork, exec, wait -- is one of those foundational concepts that every systems programmer needs to understand, and Zig gives us direct access to the syscalls that make it happen.
If you've been following the shell project (episodes 47-50), you already used std.process.Child to spawn commands. Today we're going deeper -- understanding the Unix primitives underneath, and building a process supervisor from scratch.
Here we go!
Every process on a Unix system starts the same way. The kernel has exactly one mechanism for creating processes: the fork syscall. When a process calls fork, the kernel creates an exact copy of that process -- same memory, same open files, same program counter. The only difference is the return value: the parent gets the child's PID (process ID), the child gets 0. That's how each side knows which one it is.
But a copy of yourself isn't very useful by itself. That's where exec comes in. The exec family of syscalls replaces the current process's program with a new one. The PID stays the same, open file descriptors stay the same (unless marked close-on-exec), but the code, data, and stack are completely replaced by the new program. So the pattern is: fork to create a new process, then exec in the child to run a different program.
Finally, wait (or waitpid) lets the parent collect the child's exit status after it finishes. Without calling wait, the child becomes a "zombie" -- the kernel keeps its exit status around because it assumes the parent will eventually ask for it. We'll get into zombies later.
In Zig, you can call fork directly through std.posix.fork:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const pid = try std.posix.fork();
if (pid == 0) {
// child process
try stdout.print("I'm the child, my PID is {d}\n", .{std.os.linux.getpid()});
try stdout.print("My parent PID is {d}\n", .{std.os.linux.getppid()});
std.process.exit(42);
} else {
// parent process
try stdout.print("I'm the parent, my PID is {d}\n", .{std.os.linux.getpid()});
try stdout.print("I forked child PID {d}\n", .{pid});
// wait for the child to finish
const result = std.posix.waitpid(pid, 0);
if (std.posix.W.IFEXITED(result.status)) {
const exit_code = std.posix.W.EXITSTATUS(result.status);
try stdout.print("Child exited with code {d}\n", .{exit_code});
}
}
}
When you run this, you'll see both the parent and child print their messages. The output might be interleaved -- both processes are running concurrently, and the OS scheduler decides who gets CPU time. The parent calls waitpid which blocks until the child exits, then extracts the exit code using the W.EXITSTATUS macro. That 42 the child passed to std.process.exit comes back to the parent through the wait mechanism.
This fork+exec+wait triple is the foundation of everything. Shells do it for every command. Web servers do it to handle requests in isolated processes. Even the init system (PID 1) does it to start your daemons. The model is over 50 years old and still going strong.
Calling fork and exec directly works, but it's a lot of boilerplate for the common case of "run this command and give me the output." Zig's std.process.Child wraps the fork/exec/wait pattern into a cleaner API. We used it in the shell episodes, but lets really dig into what it offers:
const std = @import("std");
pub fn runCommand(allocator: std.mem.Allocator) !void {
const stdout = std.io.getStdOut().writer();
// basic usage: run a command and wait for it
var child = std.process.Child.init(
&.{ "ls", "-la", "/tmp" },
allocator,
);
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;
try child.spawn();
// read stdout from the child
const child_stdout = child.stdout.?.reader();
const output = try child_stdout.readAllAlloc(allocator, 64 * 1024);
defer allocator.free(output);
// wait for it to finish
const result = try child.wait();
const exit_code = result.Exited;
try stdout.print("Command exited with {d}, output length: {d} bytes\n", .{
exit_code, output.len,
});
}
pub fn captureOutput(allocator: std.mem.Allocator) !struct { stdout: []u8, stderr: []u8 } {
var child = std.process.Child.init(
&.{ "uname", "-a" },
allocator,
);
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;
try child.spawn();
var stdout_buf = std.ArrayList(u8).init(allocator);
var stderr_buf = std.ArrayList(u8).init(allocator);
// read both streams
const child_stdout = child.stdout.?.reader();
const child_stderr = child.stderr.?.reader();
while (true) {
const byte = child_stdout.readByte() catch break;
try stdout_buf.append(byte);
}
while (true) {
const byte = child_stderr.readByte() catch break;
try stderr_buf.append(byte);
}
_ = try child.wait();
return .{
.stdout = try stdout_buf.toOwnedSlice(),
.stderr = try stderr_buf.toOwnedSlice(),
};
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
try runCommand(allocator);
const result = try captureOutput(allocator);
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
const stdout = std.io.getStdOut().writer();
try stdout.print("uname output: {s}\n", .{result.stdout});
}
The stdout_behavior and stderr_behavior fields control how the child's output streams are handled. .Pipe creates a pipe that the parent can read from. .Inherit passes the parent's stdout/stderr directly to the child (useful when you want the child's output to appear on the terminal). .Ignore discards the output entirely. Under the hood, .Pipe sets up a Unix pipe via the pipe2 syscall, the child's stdout file descriptor gets redirected to the write end, and the parent reads from the read end.
The captureOutput function shows how to read both stdout and stderr. There's a subtlety here though -- if you read stdout to completion before reading stderr, and the child writes a lot to stderr, the pipe buffer can fill up and the child will block waiting for someone to read stderr. In production code you'd want to read both streams concurrently (using threads or poll/epoll). For small outputs it doesn't matter, but for processes that produce megabytes on both streams, you need to be careful.
Having said that, for most practical uses where you just want to run a command and grab its output, std.process.Child does exactly what you need without having to manually fork, exec, set up pipes, and waitpid.
Every Unix process inherits a copy of its parent's environment variables. These are key-value string pairs that configure behavior without command-line arguments. Zig's standard library provides std.posix.getenv for reading and std.process.Child has an env_map field for passing a custom environment to child processes:
const std = @import("std");
pub fn readEnvironment() !void {
const stdout = std.io.getStdOut().writer();
// read specific variables
if (std.posix.getenv("HOME")) |home| {
try stdout.print("HOME = {s}\n", .{home});
}
if (std.posix.getenv("PATH")) |path| {
// PATH is colon-separated on Unix
var it = std.mem.splitScalar(u8, path, ':');
try stdout.print("PATH entries:\n", .{});
var count: usize = 0;
while (it.next()) |entry| {
try stdout.print(" {s}\n", .{entry});
count += 1;
if (count >= 5) {
try stdout.print(" ... ({d} more)\n", .{path.len});
break;
}
}
}
// check if a variable exists
const editor = std.posix.getenv("EDITOR") orelse "not set";
try stdout.print("EDITOR = {s}\n", .{editor});
}
pub fn runWithCustomEnv(allocator: std.mem.Allocator) !void {
const stdout = std.io.getStdOut().writer();
// create a custom environment for the child
var env_map = std.process.EnvMap.init(allocator);
defer env_map.deinit();
try env_map.put("MY_APP_MODE", "production");
try env_map.put("MY_APP_PORT", "8080");
try env_map.put("PATH", std.posix.getenv("PATH") orelse "/usr/bin");
var child = std.process.Child.init(
&.{ "env" },
allocator,
);
child.stdout_behavior = .Pipe;
child.env_map = &env_map;
try child.spawn();
const reader = child.stdout.?.reader();
const output = try reader.readAllAlloc(allocator, 64 * 1024);
defer allocator.free(output);
_ = try child.wait();
// count how many env vars the child sees
var line_count: usize = 0;
var line_it = std.mem.splitScalar(u8, output, '\n');
while (line_it.next()) |_| line_count += 1;
try stdout.print("Child saw {d} environment variables\n", .{line_count});
try stdout.print("Output starts with:\n{s}\n", .{output[0..@min(200, output.len)]});
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
try readEnvironment();
try runWithCustomEnv(gpa.allocator());
}
The env_map field on std.process.Child is powerful. When set, the child process gets ONLY the variables you put in the map -- it does NOT inherit the parent's environment. This is a security feature. If you're running untrusted code as a child process, you might not want it to see your HOME, SSH_AUTH_SOCK, or other sensitive environment variables. By constructing the environment explicitly, you control exactly what the child can see.
If you leave env_map as null (the default), the child inherits the parent's complete environment. This is what you want for most cases -- running ls or grep or zig build should see the same PATH and locale settings as the parent.
One gotcha: std.posix.getenv returns a pointer into the process's environment block. This memory is managed by the OS, not by your allocator. Don't free it. And if another thread calls setenv (C function) while you're reading, the pointer might become invalid. In pure Zig programs this isn't a problem because the standard library doesn't expose setenv, but if you're linking C libraries that modify the environment, be aware.
When a process finishes, it produces a status value that the parent collects via waitpid. This status encodes two diffrent things: normal exit (the process called exit() with a code) or signal death (the process was killed by a signal). The macros WIFEXITED, WEXITSTATUS, WIFSIGNALED, and WTERMSIG decode the status:
const std = @import("std");
const ChildResult = struct {
pid: std.posix.pid_t,
normal_exit: bool,
exit_code: ?u8,
signal: ?u32,
core_dumped: bool,
fn fromWaitStatus(pid: std.posix.pid_t, status: u32) ChildResult {
if (std.posix.W.IFEXITED(status)) {
return .{
.pid = pid,
.normal_exit = true,
.exit_code = std.posix.W.EXITSTATUS(status),
.signal = null,
.core_dumped = false,
};
}
if (std.posix.W.IFSIGNALED(status)) {
return .{
.pid = pid,
.normal_exit = false,
.exit_code = null,
.signal = std.posix.W.TERMSIG(status),
.core_dumped = std.posix.W.COREDUMP(status),
};
}
return .{
.pid = pid,
.normal_exit = false,
.exit_code = null,
.signal = null,
.core_dumped = false,
};
}
fn describe(self: ChildResult, writer: anytype) !void {
try writer.print("PID {d}: ", .{self.pid});
if (self.normal_exit) {
try writer.print("exited normally with code {d}\n", .{self.exit_code.?});
} else if (self.signal) |sig| {
const name = signalName(sig);
try writer.print("killed by signal {d} ({s})", .{ sig, name });
if (self.core_dumped) {
try writer.print(" (core dumped)");
}
try writer.print("\n", .{});
} else {
try writer.print("unknown termination\n", .{});
}
}
};
fn signalName(sig: u32) []const u8 {
return switch (sig) {
1 => "SIGHUP",
2 => "SIGINT",
6 => "SIGABRT",
9 => "SIGKILL",
11 => "SIGSEGV",
13 => "SIGPIPE",
14 => "SIGALRM",
15 => "SIGTERM",
else => "unknown",
};
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const stdout = std.io.getStdOut().writer();
// test 1: normal exit
{
var child = std.process.Child.init(&.{ "true" }, allocator);
try child.spawn();
const result = try child.wait();
const cr = ChildResult.fromWaitStatus(child.id, @bitCast(result));
try cr.describe(stdout);
}
// test 2: non-zero exit
{
var child = std.process.Child.init(&.{ "false" }, allocator);
try child.spawn();
const result = try child.wait();
const cr = ChildResult.fromWaitStatus(child.id, @bitCast(result));
try cr.describe(stdout);
}
// test 3: killed by signal (SIGKILL)
{
var child = std.process.Child.init(&.{ "sleep", "60" }, allocator);
try child.spawn();
// send SIGKILL to the child
try std.posix.kill(child.id, std.posix.SIG.KILL);
const result = try child.wait();
const cr = ChildResult.fromWaitStatus(child.id, @bitCast(result));
try cr.describe(stdout);
}
}
Exit codes are 8-bit values (0-255). By convention, 0 means success and anything else means failure. Some programs use specific codes to indicate specific errors -- grep returns 1 for "no match" and 2 for "error". The shell uses exit code 128+N to indicate a process killed by signal N (so SIGKILL/9 becomes exit code 137). But these are conventions, not enforced by the kernel.
Signal-based termination is fundamentally different from normal exit. SIGTERM (15) is the polite "please stop" signal -- processes can catch it and do cleanup. SIGKILL (9) is the unconditional "you're dead now" signal -- the kernel terminates the process immediately, no cleanup possible. SIGSEGV (11) means the process tried to access invalid memory. SIGPIPE (13) means it wrote to a broken pipe. Understanding what signal killed your child tells you a lot about what went wrong.
The WCOREDUMP flag tells you whether the killed process produced a core dump file. Core dumps are memory snapshots that you can load into a debugger to examine what the process was doing when it crashed. On most modern Linux systems, core dumps are handled by systemd-coredump rather than being written as files directly.
Processes don't exist in isolation -- they're organized into process groups and sessions. A process group is a collection of related processes (like a pipeline cat foo | grep bar | wc -l). A session is a collection of process groups tied to a controlling terminal. This hierarchy is how the kernel knows which processes to signal when you press Ctrl+C or close a terminal:
const std = @import("std");
const linux = std.os.linux;
const ProcessInfo = struct {
pid: std.posix.pid_t,
ppid: std.posix.pid_t,
pgid: std.posix.pid_t,
sid: std.posix.pid_t,
fn current() ProcessInfo {
const pid = linux.getpid();
const ppid = linux.getppid();
// getpgid(0) returns the group of the calling process
const pgid = linux.getpgid(0);
// getsid(0) returns the session of the calling process
const sid = linux.getsid(0);
return .{
.pid = pid,
.ppid = ppid,
.pgid = @intCast(pgid),
.sid = @intCast(sid),
};
}
fn print(self: ProcessInfo, label: []const u8, writer: anytype) !void {
try writer.print("{s}:\n", .{label});
try writer.print(" PID: {d}\n", .{self.pid});
try writer.print(" PPID: {d}\n", .{self.ppid});
try writer.print(" PGID: {d}\n", .{self.pgid});
try writer.print(" SID: {d}\n", .{self.sid});
}
};
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const info = ProcessInfo.current();
try info.print("Current process", stdout);
// fork a child and inspect its group/session
const pid = try std.posix.fork();
if (pid == 0) {
// child inherits parent's process group and session
const child_info = ProcessInfo.current();
try child_info.print("Child process", stdout);
// create a new process group with this child as leader
const my_pid = linux.getpid();
_ = linux.setpgid(0, @intCast(my_pid));
const new_info = ProcessInfo.current();
try new_info.print("Child after setpgid", stdout);
std.process.exit(0);
} else {
_ = std.posix.waitpid(pid, 0);
}
}
When you press Ctrl+C in a terminal, the kernel sends SIGINT to every process in the foreground process group. That's why cat file | grep foo -- two separate processes -- both get killed by Ctrl+C: they're in the same process group. The setpgid syscall moves a process to a different group. This is how shells implement job control: background jobs get their own process group so Ctrl+C doesn't kill them.
Sessions are one level above process groups. When you log in, your login shell starts a new session. All processes spawned from that shell belong to the same session. When the session leader (your login shell) exits, the kernel sends SIGHUP to all processes in the session. That's why background processes die when you close your SSH connection -- unless you use nohup, tmux, or screen to detach them from the session.
This hierarchy is critical for the daemonization trick we'll build later. A proper daemon needs to detach from its controlling terminal, which means creating a new session with setsid.
A zombie process is a child that has exited but whose parent hasn't called waitpid yet. The process is dead -- it's not running, it's not using CPU or memory (beyond its process table entry) -- but the kernel keeps its exit status around because the parent might want it. In ps output, zombies show up as state Z:
const std = @import("std");
const linux = std.os.linux;
fn createZombie() !void {
const stdout = std.io.getStdOut().writer();
const pid = try std.posix.fork();
if (pid == 0) {
// child exits immediately
std.process.exit(0);
}
// parent does NOT call waitpid -- zombie created
try stdout.print("Child {d} has exited but we haven't waited\n", .{pid});
try stdout.print("Check with: ps aux | grep defunct\n", .{});
// sleep to demonstrate the zombie exists
std.time.sleep(2 * std.time.ns_per_s);
// now reap it
const result = std.posix.waitpid(pid, 0);
if (std.posix.W.IFEXITED(result.status)) {
try stdout.print("Reaped zombie {d}, exit code {d}\n", .{
pid, std.posix.W.EXITSTATUS(result.status),
});
}
}
fn reapAllChildren() !void {
const stdout = std.io.getStdOut().writer();
// fork multiple children
var children: [5]std.posix.pid_t = undefined;
for (&children, 0..) |*slot, i| {
const pid = try std.posix.fork();
if (pid == 0) {
// each child sleeps a different amount then exits
std.time.sleep(@as(u64, i) * 200 * std.time.ns_per_ms);
std.process.exit(@intCast(i));
}
slot.* = pid;
}
try stdout.print("Spawned {d} children, waiting for all...\n", .{children.len});
// reap children as they exit (non-blocking poll)
var reaped: usize = 0;
while (reaped < children.len) {
// WNOHANG means don't block if no child has exited yet
const result = std.posix.waitpid(-1, std.posix.W.NOHANG);
if (result.pid > 0) {
if (std.posix.W.IFEXITED(result.status)) {
try stdout.print(" reaped PID {d}, exit code {d}\n", .{
result.pid, std.posix.W.EXITSTATUS(result.status),
});
}
reaped += 1;
} else {
// no child ready yet, sleep briefly
std.time.sleep(50 * std.time.ns_per_ms);
}
}
try stdout.print("All children reaped\n", .{});
}
pub fn main() !void {
try createZombie();
try reapAllChildren();
}
The WNOHANG flag on waitpid is crucial for non-blocking reaping. Without it, waitpid(-1, 0) blocks until ANY child exits. With WNOHANG, it returns immediately -- either with a pid (a child was available to reap) or with 0 (no child has exited yet). The -1 as the first argument means "wait for any child", not a specific PID.
Zombies aren't dangerous in small numbers, but they can accumulate. Each zombie takes up a slot in the kernel's process table. If you hit the system-wide process limit (typically 32768 or higher on Linux), no new processes can be created. Server programs that fork workers need to reap children promptly. The common patterns are: a SIGCHLD signal handler that calls waitpid, a dedicated reaper thread, or periodic non-blocking polls like the code above.
There's a neat trick on Linux: setting the SIGCHLD handler to SIG_IGN tells the kernel to automatically reap children without creating zombies at all. But then you can't get their exit status, which defeats the purpose if you care about whether the child succeeded.
A daemon is a background process that runs independently of any terminal. Creating one properly requires a specific sequence of fork, setsid, and fork again. The double-fork trick ensures the daemon can't accidentally reacquire a controlling terminal:
const std = @import("std");
const linux = std.os.linux;
const DaemonResult = union(enum) {
parent: struct { daemon_pid: std.posix.pid_t },
daemon: void,
};
fn daemonize() !DaemonResult {
// first fork: parent exits, child continues
const pid1 = try std.posix.fork();
if (pid1 != 0) {
// original parent -- the child will handle things from here
return .{ .parent = .{ .daemon_pid = pid1 } };
}
// first child: create a new session
// setsid makes us session leader with no controlling terminal
const sid = linux.setsid();
if (@as(isize, @bitCast(@as(usize, sid))) < 0) {
std.process.exit(1);
}
// second fork: the session leader exits, grandchild continues
// the grandchild is NOT a session leader, so it can never
// accidentally acquire a controlling terminal
const pid2 = try std.posix.fork();
if (pid2 != 0) {
// first child (session leader) exits
std.process.exit(0);
}
// we are now the daemon (grandchild)
// change working directory to root so we dont hold mounts
std.posix.chdir("/") catch {};
// close stdin/stdout/stderr
std.posix.close(0);
std.posix.close(1);
std.posix.close(2);
// redirect stdin/stdout/stderr to /dev/null
_ = std.posix.open("/dev/null", .{ .ACCMODE = .RDWR }, 0) catch {};
_ = std.posix.open("/dev/null", .{ .ACCMODE = .RDWR }, 0) catch {};
_ = std.posix.open("/dev/null", .{ .ACCMODE = .RDWR }, 0) catch {};
return .daemon;
}
fn writePidFile(path: []const u8) !void {
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
const pid = linux.getpid();
var buf: [20]u8 = undefined;
const len = std.fmt.formatIntBuf(&buf, pid, 10, .lower, .{});
try file.writeAll(buf[0..len]);
try file.writeAll("\n");
}
pub fn main() !void {
const result = try daemonize();
switch (result) {
.parent => |info| {
// print to the terminal before exiting
const stdout = std.io.getStdOut().writer();
try stdout.print("Daemon started with PID {d}\n", .{info.daemon_pid});
// parent exits, daemon continues in background
},
.daemon => {
// we're the daemon now -- no terminal, no stdout
writePidFile("/tmp/my_daemon.pid") catch {};
// daemon work loop
var counter: u64 = 0;
while (counter < 10) : (counter += 1) {
// simulate work -- write to a log file
const file = std.fs.cwd().createFile("/tmp/my_daemon.log", .{
.truncate = false,
}) catch continue;
defer file.close();
file.seekFromEnd(0) catch {};
const writer = file.writer();
writer.print("daemon tick {d}\n", .{counter}) catch {};
std.time.sleep(1 * std.time.ns_per_s);
}
// cleanup
std.fs.cwd().deleteFile("/tmp/my_daemon.pid") catch {};
},
}
}
Why the double fork? The first fork lets us call setsid, which creates a new session. But setsid only works if the calling process is NOT already a session leader. Since the parent might be, we fork first. After setsid, we're the session leader with no controlling terminal. But here's the catch: a session leader CAN acquire a controlling terminal by opening a terminal device. The second fork fixes this -- the grandchild is not the session leader, so it can never accidentally get a controlling terminal. The session leader (first child) exits immediately.
After the double fork, the daemon changes its working directory to / so it doesn't hold any filesystem mounts busy, and redirects stdin/stdout/stderr to /dev/null. Writing to a closed stdout would cause SIGPIPE, which would kill the daemon. The PID file at /tmp/my_daemon.pid lets other programs find and signal the daemon.
Modern init systems like systemd handle most of this for you -- you just write a normal program and systemd does the forking, logging, and PID tracking. But understanding the double-fork trick is important because (a) not everything runs under systemd, and (b) knowing what systemd does for you helps you appreciate why it exists ;-)
Let's put everything together into a process supervisor that manages a set of child processes and restarts them if they crash. This is a simplified version of what PM2, supervisord, and systemd do:
const std = @import("std");
const linux = std.os.linux;
const SupervisedProcess = struct {
name: []const u8,
command: []const []const u8,
pid: ?std.posix.pid_t,
restart_count: u32,
max_restarts: u32,
last_exit_code: ?u8,
last_start_time: i64,
fn isRunning(self: *const SupervisedProcess) bool {
return self.pid != null;
}
};
const Supervisor = struct {
allocator: std.mem.Allocator,
processes: std.ArrayList(SupervisedProcess),
running: bool,
fn init(allocator: std.mem.Allocator) Supervisor {
return .{
.allocator = allocator,
.processes = std.ArrayList(SupervisedProcess).init(allocator),
.running = true,
};
}
fn deinit(self: *Supervisor) void {
self.processes.deinit();
}
fn addProcess(
self: *Supervisor,
name: []const u8,
command: []const []const u8,
max_restarts: u32,
) !void {
try self.processes.append(.{
.name = name,
.command = command,
.pid = null,
.restart_count = 0,
.max_restarts = max_restarts,
.last_exit_code = null,
.last_start_time = 0,
});
}
fn startProcess(self: *Supervisor, proc: *SupervisedProcess) !void {
_ = self;
var child = std.process.Child.init(proc.command, std.heap.page_allocator);
child.stdout_behavior = .Inherit;
child.stderr_behavior = .Inherit;
try child.spawn();
proc.pid = child.id;
proc.last_start_time = std.time.milliTimestamp();
}
fn startAll(self: *Supervisor) !void {
const stdout = std.io.getStdOut().writer();
for (self.processes.items) |*proc| {
if (!proc.isRunning() and proc.restart_count <= proc.max_restarts) {
try self.startProcess(proc);
try stdout.print("[supervisor] started '{s}' (PID {d})\n", .{
proc.name, proc.pid.?,
});
}
}
}
fn checkChildren(self: *Supervisor) !void {
const stdout = std.io.getStdOut().writer();
// non-blocking check for any exited child
while (true) {
const result = std.posix.waitpid(-1, std.posix.W.NOHANG);
if (result.pid <= 0) break; // no more exited children
// find which supervised process this was
for (self.processes.items) |*proc| {
if (proc.pid == result.pid) {
if (std.posix.W.IFEXITED(result.status)) {
const code = std.posix.W.EXITSTATUS(result.status);
proc.last_exit_code = code;
try stdout.print("[supervisor] '{s}' (PID {d}) exited with code {d}\n", .{
proc.name, result.pid, code,
});
} else if (std.posix.W.IFSIGNALED(result.status)) {
const sig = std.posix.W.TERMSIG(result.status);
try stdout.print("[supervisor] '{s}' (PID {d}) killed by signal {d}\n", .{
proc.name, result.pid, sig,
});
}
proc.pid = null;
proc.restart_count += 1;
// restart if under the limit
if (proc.restart_count <= proc.max_restarts) {
// brief cooldown to avoid rapid restart loops
const elapsed = std.time.milliTimestamp() - proc.last_start_time;
if (elapsed < 1000) {
try stdout.print("[supervisor] '{s}' crashed too fast, waiting 1s...\n", .{
proc.name,
});
std.time.sleep(1 * std.time.ns_per_s);
}
try self.startProcess(proc);
try stdout.print("[supervisor] restarted '{s}' (PID {d}, attempt {d}/{d})\n", .{
proc.name, proc.pid.?, proc.restart_count, proc.max_restarts,
});
} else {
try stdout.print("[supervisor] '{s}' exceeded max restarts ({d}), giving up\n", .{
proc.name, proc.max_restarts,
});
}
break;
}
}
}
}
fn stopAll(self: *Supervisor) !void {
const stdout = std.io.getStdOut().writer();
for (self.processes.items) |*proc| {
if (proc.pid) |pid| {
try stdout.print("[supervisor] sending SIGTERM to '{s}' (PID {d})\n", .{
proc.name, pid,
});
std.posix.kill(pid, std.posix.SIG.TERM) catch {};
}
}
// wait for them to exit (with timeout)
var remaining: usize = 0;
for (self.processes.items) |proc| {
if (proc.pid != null) remaining += 1;
}
var waited: usize = 0;
while (remaining > 0 and waited < 50) : (waited += 1) {
const result = std.posix.waitpid(-1, std.posix.W.NOHANG);
if (result.pid > 0) {
for (self.processes.items) |*proc| {
if (proc.pid == result.pid) {
proc.pid = null;
remaining -= 1;
break;
}
}
}
std.time.sleep(100 * std.time.ns_per_ms);
}
// SIGKILL any stragglers
for (self.processes.items) |*proc| {
if (proc.pid) |pid| {
try stdout.print("[supervisor] SIGKILL '{s}' (PID {d})\n", .{
proc.name, pid,
});
std.posix.kill(pid, std.posix.SIG.KILL) catch {};
_ = std.posix.waitpid(pid, 0);
proc.pid = null;
}
}
}
fn run(self: *Supervisor) !void {
try self.startAll();
while (self.running) {
try self.checkChildren();
// check if any process still needs to be running
var any_active = false;
for (self.processes.items) |proc| {
if (proc.isRunning() or proc.restart_count <= proc.max_restarts) {
any_active = true;
break;
}
}
if (!any_active) {
self.running = false;
break;
}
std.time.sleep(100 * std.time.ns_per_ms);
}
try self.stopAll();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var supervisor = Supervisor.init(allocator);
defer supervisor.deinit();
// add some processes to supervise
try supervisor.addProcess("echo-worker", &.{ "sh", "-c", "echo 'working...' && sleep 2" }, 3);
try supervisor.addProcess("quick-fail", &.{ "sh", "-c", "echo 'i will fail' && exit 1" }, 2);
const stdout = std.io.getStdOut().writer();
try stdout.print("[supervisor] starting with {d} processes\n", .{supervisor.processes.items.len});
try supervisor.run();
try stdout.print("[supervisor] all processes finished\n", .{});
}
The supervisor manages a list of SupervisedProcess entries. For each one, it tracks the PID (if running), restart count, and maximum allowed restarts. The main loop calls checkChildren which does non-blocking waitpid to detect exited children. When a child exits, the supervisor automatically restarts it unless it's exceeded the restart limit.
The restart cooldown is important -- without it, a process that crashes immediately (bad binary, missing config file, port already in use) would restart thousands of times per second, eating CPU and filling logs. The code checks how long the process ran and forces a 1-second delay if it crashed in under a second. Real supervisors like systemd have configurable backoff strategies (exponential backoff, max restart frequency windows, etc.).
The stopAll function demonstrates graceful shutdown: first send SIGTERM (polite), wait up to 5 seconds, then SIGKILL (forceful) any processes that didn't exit. This two-phase approach is standard practice -- it gives well-behaved processes time to flush buffers, close connections, and clean up, while still guaranteeing that misbehaving processes eventually die.
Add a health check to the Supervisor. Each SupervisedProcess should have an optional health_check_command (another command string). Every 5 seconds, the supervisor should run the health check command for each running process. If the health check exits with a non-zero code, the supervisor should consider the process unhealthy and restart it (even though the main process hasn't exited). Test with a process that runs but becomes "unhealthy" (e.g., a shell script that creates a file on start and the health check verifies the file exists -- delete the file manually to trigger the restart).
Write a process pool that maintains exactly N worker processes. When any worker exits, a new one is spawned immediately to keep the count at N. Each worker should receive a unique worker ID via environment variable (WORKER_ID=0, WORKER_ID=1, etc.). Write a test that verifies the pool maintains the correct count after killing workers with SIGKILL.
Implement a simple pipe executor that connects two processes via a Unix pipe -- the stdout of process A becomes the stdin of process B (like cmd1 | cmd2 in a shell). Use std.posix.pipe to create the pipe, fork twice, and set up file descriptor redirection with std.posix.dup2 in each child before exec. Test with echo "hello world" | wc -w and verify the output is 2.
fork copies the current process, exec replaces the copy with a new program, wait collects the result -- every shell command, every server worker, every daemon uses this patternstd.process.Child wraps fork/exec/wait into a clean API with configurable stdout/stderr piping, but understanding the syscalls underneath matters when you need fine-grained controlenv_map -- an important security boundary when running untrusted child processesW.IFEXITED/W.IFSIGNALED macros decode which type of termination occuredwaitpidProcess management is one of those areas where you're directly interfacing with the kernel. Every concept here -- fork, exec, waitpid, signals, process groups, sessions -- maps directly to syscalls. No abstraction layers, no runtime overhead. Zig makes this natural because it doesn't try to hide the OS from you.
The concepts from today feed directly into what's coming next. We've been dealing with processes as separate entities that communicate through exit codes and signals. But processes can also talk to each other through pipes, shared memory, and other IPC mechanisms -- and that's where things get really interesting.
De groeten!