Stop an unhandled error from exiting zero
Some checks failed
libakerror CI Build / cmake_build (push) Successful in 2m47s
libakerror CI Build / coverage (push) Successful in 2m48s
libakerror CI Build / thread_sanitizer (push) Failing after 2m49s
libakerror CI Build / mutation_test (push) Successful in 39m15s

An unhandled error could kill the process and still report success. The
default handler ended in exit(errctx->status), and an exit status is one
byte wide: the kernel keeps the low 8 bits of the argument and discards
the rest. Consumer statuses start at AKERR_FIRST_CONSUMER_STATUS (256),
so the first status any consumer can reserve exited 0 and a shell saw a
clean run. Status 300 exited 44, an unrelated error's code.

There is no wider exit() to reach for. _exit(), _Exit(), quick_exit()
and the raw exit_group syscall all truncate identically, and even
waitid(), whose si_status is a full int, reports the truncated value --
the truncation happened before the parent looked.

akerr_exit() now owns that mapping and the default handler calls it: 0
exits 0, 1 through 255 exit the status, and anything else exits
AKERR_EXIT_STATUS_UNREPRESENTABLE (125) rather than a low byte that is
either a lie or a claim of success. Only values that were already being
delivered wrong behave differently. Call it instead of exit() anywhere
you leave the process on a status; it is declared AKERR_NORETURN.

akerr_exit(0) exits 0, because 0 is this library's success status. That
is not a hole in the rule: PROCESS opens with case 0, which marks a zero
status handled, so a successful context never reaches FINISH_NORETURN's
call to the handler at all.

tests/err_exit_status.c drives one table through akerr_exit() and
through the default handler in forked children and requires identical
exit codes, so the handler cannot grow a mapping of its own. With the
clamp removed it fails with "akerr_exit(256) exited 0, want 125". The
full-width status was already reaching the log and still does, which the
same test asserts against the captured stack trace.

2.0.1. No ABI break: the soname stays libakerror.so.2 and nothing that
already existed changed shape. akerr_exit() is a new exported symbol, so
a consumer that starts calling it needs 2.0.1 at link time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 07:25:37 -04:00
parent 756933c600
commit 5eaa956f50
8 changed files with 435 additions and 10 deletions

View File

@@ -1,3 +1,45 @@
# Bug fix: unhandled-error exit status (2.0.1)
An unhandled error could kill the process and still report success.
`akerr_default_handler_unhandled_error()` ended in `exit(errctx->status)`, and a
process exit status is one byte wide — the kernel keeps the low 8 bits of the
argument and discards the rest. Consumer statuses start at
`AKERR_FIRST_CONSUMER_STATUS` (256), so **the first status any consumer can
reserve exited 0**, and a shell or supervisor watching `$?` saw a clean run.
Status 300 exited 44, which is some unrelated error's code. No status a consumer
owns could ever come out of `$?` intact, and there is no wider `exit()` to reach
for: `_exit()`, `_Exit()`, `quick_exit()` and the raw `exit_group` syscall all
truncate identically, and even `waitid()`, whose `si_status` is a full `int`,
reports the truncated value.
The mapping now lives in one place, `akerr_exit()`, which the default handler
calls:
```
status exit code
0 0 (success)
1 .. 255 the status
negative, or > 255 AKERR_EXIT_STATUS_UNREPRESENTABLE (125)
```
Statuses 0 through 255 are unchanged, which covers every `errno` and every
`AKERR_*` code. Only the values that were already being delivered wrong behave
differently, and they now exit 125 instead of a truncated byte.
**What you should change.** Call `akerr_exit()` instead of `exit()` anywhere you
leave the process on an akerr status — your own unhandled-error handler, a
top-level `HANDLE` block, an init routine that cannot continue — so one mapping
covers every exit. It is declared `AKERR_NORETURN`. If you were reading a
consumer status out of `$?`, you were never getting it: read the stack trace,
which carries the status at full width along with its registered name, or
install a handler that maps your own statuses into a byte. See "Exit status" in
[README.md](README.md).
No ABI break. The soname stays `libakerror.so.2` and nothing you already call
changed shape. `akerr_exit()` is a new exported symbol, so a consumer that
starts calling it needs 2.0.1 or later at link time.
# Upgrade notice: thread safety (2.0.0)
2.0.0 makes the library thread safe. Every entry point may be called from any