Errors, three ways
One error's journey from a failing write(2) into std::expected, a wrapped Go error, and a Rust Result — copyx establishes the book's error taxonomy, the strerror(3) message contract, and the EINTR retry policy every later chapter reuses.
Chapter 4 walked a syscall down to the boundary and back, and ended on the
detail this chapter is built from: when a syscall fails, the kernel does not
throw, log, or explain — it puts a small negative integer in a register and
returns. Everything your language calls an “error” is constructed, in user
space, from that one number. This chapter takes a single program — copyx, a
file copier — and writes it three times so you can watch the same kernel
failure become a std::expected, a wrapped Go error, and a Rust Result,
and still come out the other side as byte-identical output and the same exit
code. Along the way it fixes the policies the rest of the book never
renegotiates: what EINTR means, what a short write demands, and which side of
a failure owns the exit code.
The code is in examples/05-errors-three-ways/. ./demo.sh there builds all
three implementations and runs a walkthrough of the taxonomy; its README.md
states the behavior contract each one must satisfy.
Tools used —
strace,head,cmp,ls,python3(host);bpftrace(lab VM, exercised in Part 8). Everything here is checked byscripts/check-host.sh, ships with Fedora, or is preinstalled in the lab VMs.
errno is an ABI, not a variable
On x86-64 Linux, a failing syscall returns a value between -1 and -4095 in
rax; the number is the negated error code. ENOSPC is 28 — not “usually
28”, but 28 by kernel ABI, stable for decades, the same for every language
and libc on the platform. What differs is the unwrapping. glibc’s syscall
stubs check the return, store the positive code into the thread-local
errno, and hand C a -1. Go’s runtime makes the syscall itself and returns
the code as an ordinary value — unix.Errno is just uintptr with an
Error() method; there is no global to race on. Rust’s rustix does the
same trick: Errno is a wrapper around the raw code, delivered in the Err
arm of a Result, never through ambient state.
That shared integer is why a cross-language error policy is even possible.
Whatever type each language dresses the failure in, the bottom of the stack
is the same number with the same strerror(3) text — and copyx treats that
text as part of its interface. The behavior contract, identical in all three
implementations:
| Outcome | Output | Exit |
|---|---|---|
| Success | stdout: copied <N> bytes |
0 |
| Bad usage | stderr: usage: copyx SRC DST |
2 |
| Source-side failure (open/read SRC) | stderr: copyx: <reason> |
2 |
| Destination-side failure (open/write/close DST) | stderr: copyx: <reason> |
3 |
The split is deliberate: a caller (or a shell script) can distinguish “your
input was bad” from “the place you told me to write is broken” without
parsing prose. Exit codes are the oldest structured error channel on Unix,
and copyx uses them as one.
Two syscall-level policies are also part of the contract, because they will
be true of every program in this book. First, EINTR is retried. A
read(2) or write(2) interrupted by a signal before transferring anything
fails with EINTR; nothing happened, so the only correct response is to
reissue the call — treating it as an error turns every signal into data
corruption at a distance. Second, short writes are resumed. write(2) is
allowed to accept fewer bytes than you offered; the loop must advance past
what the kernel took and continue. (The third sibling, EAGAIN, means “would
block” on a non-blocking descriptor — not an error either, but a scheduling
fact; it becomes central when epoll arrives in the Files and I/O part.) One
more subtlety the contract encodes: the write side must observe
close(2), because deferred I/O errors can surface there; the read side’s
close result carries nothing actionable and is released by RAII or defer.
How the code works
Each implementation has the same skeleton: a phase-tagged error type, a
retrying read_some, a resuming write_all, and a copy_file that maps
each failure onto the taxonomy. The error types come first, because they are
where the three type systems diverge most visibly. C++
(examples/05-errors-three-ways/cpp/src/main.cpp) uses a plain struct —
struct Failure {
std::string message; // printed after the "copyx: " prefix
int exit_code;
};
— because by the time copy_file fails, classification is done: the
message is rendered and the exit code chosen. Go
(examples/05-errors-three-ways/go/main.go) cannot stop there, since Go
errors are expected to stay inspectable:
type opError struct {
op string // "read", "write", "close"; "" means open(2)
path string
err unix.Errno // reachable via errors.Is / errors.As through Unwrap
exit int
}
opError keeps the raw errno and implements Unwrap() error, so
errors.Is(err, unix.ENOENT) still answers truthfully through the wrapper.
Rust (examples/05-errors-three-ways/rust/src/main.rs) puts the phase into
the type itself, one enum variant per failure site, with thiserror
generating the Display impl from the #[error("...")] attributes:
#[derive(Debug, Error)]
enum CopyError {
#[error("{path}: {reason}")]
SrcOpen { path: String, reason: String },
#[error("read {path}: {reason}")]
Read { path: String, reason: String },
#[error("{path}: {reason}")]
DstOpen { path: String, reason: String },
#[error("write {path}: {reason}")]
Write { path: String, reason: String },
#[error("close {path}: {reason}")]
Close { path: String, reason: String },
}
Now the core loops. The three excerpts below are the real write_all and
copy_file from each implementation — read one, then diff it mentally
against the next:
// write(2) until the whole span is written: EINTR restarts the call, a short
// write resumes from where the kernel stopped.
[[nodiscard]] std::expected<void, std::error_code>
write_all(const Fd& fd, std::span<const char> buf) {
while (!buf.empty()) {
const ssize_t n = ::write(fd.get(), buf.data(), buf.size());
if (n > 0) {
buf = buf.subspan(static_cast<std::size_t>(n));
continue;
}
if (n < 0 && errno == EINTR) {
continue; // interrupted: reissue the same span
}
return std::unexpected(last_error());
}
return {};
}
struct Failure {
std::string message; // printed after the "copyx: " prefix
int exit_code;
};
[[nodiscard]] std::expected<std::uint64_t, Failure>
copy_file(const char* src_path, const char* dst_path) {
auto src = open_fd(src_path, O_RDONLY | O_CLOEXEC);
if (!src) {
return std::unexpected(
Failure{std::format("{}: {}", src_path, src.error().message()), 2});
}
auto dst = open_fd(dst_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
if (!dst) {
return std::unexpected(
Failure{std::format("{}: {}", dst_path, dst.error().message()), 3});
}
std::uint64_t total = 0;
std::array<char, 64 * 1024> buf{};
for (;;) {
const auto n = read_some(*src, buf);
if (!n) {
return std::unexpected(
Failure{std::format("read {}: {}", src_path, n.error().message()), 2});
}
if (*n == 0) {
break; // EOF
}
if (auto w = write_all(*dst, std::span<const char>{buf}.first(*n)); !w) {
return std::unexpected(
Failure{std::format("write {}: {}", dst_path, w.error().message()), 3});
}
total += *n;
}
// The write side must observe close(2): deferred IO errors land here.
if (auto c = dst->close(); !c) {
return std::unexpected(
Failure{std::format("close {}: {}", dst_path, c.error().message()), 3});
}
return total;
}
// writeAll pushes the whole buffer through write(2): EINTR restarts the
// call, a short write resumes from where the kernel stopped.
func writeAll(fd int, buf []byte) error {
for len(buf) > 0 {
n, err := unix.Write(fd, buf)
if errors.Is(err, unix.EINTR) {
continue // interrupted: reissue the same span
}
if err != nil {
return err
}
buf = buf[n:]
}
return nil
}
func copyFile(srcPath, dstPath string) (int64, error) {
src, err := unix.Open(srcPath, unix.O_RDONLY|unix.O_CLOEXEC, 0)
if err != nil {
return 0, newOpError("", srcPath, fmt.Errorf("open %s: %w", srcPath, err), 2)
}
defer unix.Close(src) // read side: nothing actionable in the close result
dst, err := unix.Open(dstPath, unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_CLOEXEC, 0o644)
if err != nil {
return 0, newOpError("", dstPath, fmt.Errorf("open %s: %w", dstPath, err), 3)
}
var total int64
buf := make([]byte, 64*1024)
for {
n, err := readSome(src, buf)
if err != nil {
unix.Close(dst)
return 0, newOpError("read", srcPath, err, 2)
}
if n == 0 {
break // EOF
}
if err := writeAll(dst, buf[:n]); err != nil {
unix.Close(dst)
return 0, newOpError("write", dstPath, err, 3)
}
total += int64(n)
}
// The write side must observe close(2): deferred IO errors land here.
if err := unix.Close(dst); err != nil {
return 0, newOpError("close", dstPath, err, 3)
}
return total, nil
}
/// write(2) until the whole slice is written: EINTR restarts the call, a
/// short write resumes from where the kernel stopped.
fn write_all(fd: impl AsFd, mut buf: &[u8]) -> Result<(), Errno> {
while !buf.is_empty() {
match write(&fd, buf) {
Ok(n) => buf = &buf[n..],
Err(Errno::INTR) => continue, // interrupted: reissue the same span
Err(e) => return Err(e),
}
}
Ok(())
}
/// close(2) with the result observed: on the write side this is where
/// deferred IO errors surface. `into_raw_fd` transfers ownership out of the
/// `OwnedFd`, so the fd is closed exactly once.
fn close_checked(fd: OwnedFd) -> Result<(), Errno> {
unsafe { try_close(fd.into_raw_fd()) }
}
fn copy_file(src_path: &str, dst_path: &str) -> Result<u64, CopyError> {
let src = open(src_path, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()).map_err(|e| {
CopyError::SrcOpen { path: src_path.to_owned(), reason: strerror(e) }
})?; // OwnedFd: dropped (and closed) on every path below
let dst = open(
dst_path,
OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o644),
)
.map_err(|e| CopyError::DstOpen { path: dst_path.to_owned(), reason: strerror(e) })?;
let mut total: u64 = 0;
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = read_some(&src, &mut buf).map_err(|e| CopyError::Read {
path: src_path.to_owned(),
reason: strerror(e),
})?;
if n == 0 {
break; // EOF
}
write_all(&dst, &buf[..n]).map_err(|e| CopyError::Write {
path: dst_path.to_owned(),
reason: strerror(e),
})?;
total += n as u64;
}
close_checked(dst).map_err(|e| CopyError::Close {
path: dst_path.to_owned(),
reason: strerror(e),
})?;
Ok(total)
}
The loops are structurally identical — a 64 KiB buffer, read until a zero
return means EOF, write_all per chunk, then a checked close — but each
language spends its effort differently. C++ builds the discipline out of
std::expected: every fallible helper returns
std::expected<T, std::error_code>, last_error() snapshots errno into
std::generic_category() (the category for POSIX errno values; Chapter 1’s
uname wrapper used system_category — for Linux errno they render the
same, and the book standardizes on generic_category from here on), and the
move-only RAII Fd guarantees no descriptor outlives its owner. The
asymmetry between dst->close() — explicit, [[nodiscard]], result mapped
to exit 3 — and src being closed silently by the destructor is the close
policy from the contract, written in types. Go threads one error value
through everything: readSome detects EINTR with
errors.Is(err, unix.EINTR) rather than err == unix.EINTR, so the check
survives any future wrapping; newOpError recovers the raw unix.Errno
from whatever it is handed using errors.As, and main gets the exit code
back the same way. Note also what Go must do that the others get free: its
errno strings are lowercase (“no such file or directory”), so strerror
capitalizes the first rune to match glibc. Rust lets ? do the
propagation and map_err do the classification — each call site names its
variant, so forgetting to classify a failure is a compile error, and
exit_code() is an exhaustive match the compiler re-checks whenever a
variant is added. Its strerror has the inverse problem to Go’s: Errno
displays through std::io::Error, which appends “ (os error N)”, so the
suffix is stripped to keep the three outputs byte-identical.
Errors, three ways
copyx is the policy statement for the thread that runs through every
chapter, so state the doctrine plainly. In C++, errors that are part of
normal operation — a missing file, a full disk — travel as values
(std::expected), and exceptions are reserved for subsystem boundaries:
the places where a whole component (a parser, a plugin, a request handler)
fails as a unit and someone several frames up owns the recovery. Inside
syscall-adjacent code, exceptions obscure control flow exactly where you need
to see it. In Go, the equivalent line is drawn between error and
panic: an error is a result, a panic is a bug (index out of range, nil
map write) — copyx returns errors for everything the kernel can say and
would panic only on programmer mistakes. In Rust, the same line separates
Result from panic!; the book’s examples are written to be correct under
panic = "abort", which means a panic is never part of the error path — it
is a crash you fix. On the library-versus-application split: thiserror
(used here) is for code whose callers match on error variants; anyhow is
for binaries that only report. copyx is a binary, but its exit codes make
error identity part of the interface, which is exactly the case where the
typed enum earns its boilerplate.
Concurrency lens
copyx is deliberately single-threaded — one loop, one buffer, no shared
state — but the error machinery is already concurrency-load-bearing. C’s
errno is thread-local precisely so two threads failing simultaneously do
not corrupt each other’s error state, and last_error() copies it into a
value immediately because the very next libc call on the same thread may
overwrite it. Go and Rust sidestep the global entirely: the errno arrives as
a return value, so there is nothing thread-local to protect. And the EINTR
policy is really a concurrency policy in disguise — signals are asynchronous
interruptions, and a program whose I/O loops retry EINTR is one whose
correctness does not depend on when a signal lands. That property gets
load-tested in the Processes, Signals, Privilege part, when pmon starts
handling real signals around these same loops.
Build, run, observe
[host]$ cd examples/05-errors-three-ways && ./demo.sh
The walkthrough runs the taxonomy per language. Reproduce it by hand against any of the three binaries — shown here with a 200 000-byte source (sized so the final read comes up short of the 64 KiB buffer):
[host]$ head -c 200000 /dev/urandom > src.bin
[host]$ ./cpp/build/release/app src.bin dst.bin; echo exit=$?
copied 200000 bytes
exit=0
[host]$ ./cpp/build/release/app nope.bin out.bin; echo exit=$?
copyx: nope.bin: No such file or directory
exit=2
[host]$ ./cpp/build/release/app src.bin /dev/full; echo exit=$?
copyx: write /dev/full: No space left on device
exit=3
Then run the same three cases with ./go/bin/app and
./rust/target/release/app: on this host all three produced the identical
three lines and the identical exit codes 0, 2, 3, and cmp src.bin dst.bin
exited 0 for each copy — byte-identical output from byte-identical input,
which is the whole claim of the chapter. /dev/full is the tool that makes
the ENOSPC case repeatable: ls -li /dev/full shows a character device with
major:minor 1, 7, a kernel-provided device whose write handler always
returns -ENOSPC — a full disk on tap, no root required. The book’s runner
(python3 scripts/test-all-examples.py --only 05-errors-three-ways) drives
eleven behavioral checks per language through verify.lua — including the
empty-source edge case (copied 0 bytes, exit 0) — and reported
PASS PASS PASS on this host.
Cross-check: watch the error being born
Everything above shows the error after each runtime dressed it up.
strace -e trace=write shows the moment it exists — the failing write(2)
itself. On the C++ binary:
[host]$ strace -e trace=write ./cpp/build/release/app src.bin /dev/full
write(4, "\343\275\2436'\325\377\3074N0\262\2248O\303U}\267R\327|\241?1\205M\265\303\320\f\2"..., 65536) = -1 ENOSPC (No space left on device)
write(2, "copyx: write /dev/full: No space"..., 47copyx: write /dev/full: No space left on device) = 47
write(2, "\n", 1
) = 1
+++ exited with 3 +++
Read it bottom-up from the failure: one 65 536-byte write to descriptor 4
(the destination; 3 is the source) fails with ENOSPC, and the next writes
are to descriptor 2 — the program printing its own error message. The entire
journey in Figure 5.1 happened between those two lines, in user space,
invisible to the kernel. The Rust binary (strace -e trace=write
./rust/target/release/app src.bin /dev/full) fails identically but emits the
message as six separate little writes ("copyx: ", "write ",
"/dev/full", …) — eprintln! writing each format fragment straight to
unbuffered stderr. The Go binary (add -f; the runtime has threads) shows
the failing 65 536-byte write on descriptor 5, not 4 — this trace shows
why: the runtime is already holding
/sys/fs/cgroup/.../cpu.max open on descriptor 3 for its container-aware
scheduling, so the source and destination land one higher. Three runtimes,
three descriptor layouts and stderr habits — and one identical failing
syscall, one identical message, one identical +++ exited with 3 +++.
On the lab VM — unverified: the aggregate view of this chapter — which errnos your whole system’s syscalls are failing with, live — belongs to eBPF tooling in the class of bpftrace one-liners counting negative syscall returns by errno. That is not runnable on this host without privileges; Chapter 30 (Debugging part) exercises it in the
systems-targetVM against these same binaries.
What you learned
- Error codes are kernel ABI: a failing syscall returns
-errnoin a register,ENOSPCis 28 everywhere on the platform, and glibc’s thread-localerrno, Go’sunix.Errnovalue, and rustix’sErrnoare three deliveries of the same integer. copyx’s taxonomy — exit 2 for the source side, 3 for the destination side, reasons instrerror(3)form — is expressible in all three type systems:std::expectedmapped into aFailure, an*opErrorwithUnwrapkeepingerrors.Is/Astruthful, and athiserrorenum whoseexit_code()the compiler keeps exhaustive.- The book’s retry policy: EINTR means nothing transferred, so reissue;
short writes resume from where the kernel stopped; the write side observes
close(2); EAGAIN is a scheduling fact deferred to the epoll chapters. - Exceptions (C++) and panics (Go, Rust) are for bugs and subsystem
boundaries, never for outcomes the kernel can report; and
stracecan catch the exact syscall where an error is born, before any runtime touches it.
Next, the second thread that never stops: concurrency, three ways — the
same worker pool as std::jthreads and atomics, goroutines and channels,
and scoped threads under Send/Sync.
verified — all evidence in this chapter was produced on the Fedora 44 host this session: each of the three binaries printed copied 200000 bytes (exit 0) with cmp exiting 0, copyx: nope.bin: No such file or directory (exit 2), and copyx: write /dev/full: No space left on device (exit 3); the strace excerpt is real output showing write(4, …, 65536) = -1 ENOSPC before the exit-3; the Go trace showed the failing write on descriptor 5 with cpu.max held on 3, and the Rust trace showed the fragmented stderr writes; ls -li /dev/full showed the 1, 7 character device; and scripts/test-all-examples.py --only 05-errors-three-ways reported PASS for cpp, go, and rust (3 passed, 0 failed, 0 skipped). The lab-VM eBPF callout above is the one unverified item, deferred to Chapter 30.