Exiting
The snippet writes two lines and registers two defers. One line comes out.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [1024]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout_writer.interface;
// Registered now, and every one of them is skipped below.
defer std.debug.print("this defer never runs\n", .{});
defer out.flush() catch {};
try out.writeAll("first line, flushed by hand\n");
try out.flush();
try out.writeAll("second line, still sitting in the buffer\n");
// The process stops here. Not a return: no defer runs, no buffer is
// drained, and the second line above is lost with the memory holding it.
std.process.exit(0);
}What exit skips
defer std.debug.print("this defer never runs\n", .{});
defer out.flush() catch {};
try out.writeAll("second line, still sitting in the buffer\n");
std.process.exit(0);
std.process.exit is noreturn in the strongest sense available: it does not
return to the caller, so nothing between the call and the end of main ever
happens. No defer at any depth of the stack. No errdefer. No flush.
The lost line is the part worth internalising. Those bytes were in a buffer in
your process’s memory, the operating system never heard about them, and the
process ended. This is the most common way output goes missing from a program
that “definitely printed it”, and it is why the second defer above, the one
that would have flushed, is a trap: it looks like the safety net and it is not
attached to anything.
Returning from main normally does run every defer, which is why main
returning an error and exit(1) are not interchangeable. The first unwinds and
cleans up; the second stops the process where it stands.
The status you leave
exit takes a u8. Zero means success and every other value is yours to
define, which is the same one-bit protocol the parent sees as Term in
Spawning a Process.
Returning an error from main sets a nonzero status for you and prints the
error name with a stack trace in debug builds. That is the right default for a
tool. Choosing a specific code is for programs whose callers branch on it,
like grep returning 1 for “no match” and 2 for “something went wrong”, a
distinction a shell script can act on and an error message cannot.
cleanExit, and why it does nothing in Debug
pub fn cleanExit(io: Io) void {
if (builtin.mode == .debug) return;
_ = io.lockStderr(&.{}, .no_color) catch {};
exit(0);
}
A program about to exit does not need to free anything. The kernel reclaims the whole address space, and walking a tree of allocations to release memory that is about to vanish is work that only makes the program slower to quit.
std.process.cleanExit is that shortcut, with the one condition that makes it
safe to use: in Debug it returns and lets your teardown run, so the leak
detector still sees a program that freed everything and can still tell you
about the leak that matters. In release builds it exits immediately. The Zig
compiler itself calls it.
That mode dependence is why this page describes the call rather than running
it. A snippet whose output changed between -Doptimize=Debug and the
ReleaseSmall this site ships would be a snippet that passes CI and lies to
half its readers.
abort is not an exit
std.process.abort ends the process abnormally: it raises SIGABRT, which
runs a handler if you installed one, and then makes sure the process dies
anyway. No status code, no cleanup, and typically a core dump.
That is the correct response to a broken invariant, where continuing would do
damage and there is nothing sensible to report. It is the wrong response to a
file that was not found. The distinction the reader should carry: exit is a
program finishing, abort is a program admitting it should not have got here.