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

@@ -6,12 +6,17 @@ This library provides a TRY/CATCH style exception handling mechanism for C.
## Upgrading
2.0.1 fixes an unhandled error killing the process and still reporting success:
the exit code was the status truncated to a byte, and every consumer status
starts at 256. Use `akerr_exit()` instead of `exit()` — see
[Exit status](#exit-status). No ABI break.
2.0.0 makes the library thread safe. That is an ABI break — `__akerr_last_ignored`
became thread-local storage and the pool now takes its own reference — so
everything built against a 1.x header must be rebuilt. 1.0.0 replaced the
consumer-sized status-name array with a private, ownership-enforced registry.
See [UPGRADING.md](UPGRADING.md) for both, what was removed, how to migrate, the
capacity limits and how to raise them, and the thread-safety rules.
See [UPGRADING.md](UPGRADING.md) for all three, what was removed, how to migrate,
the capacity limits and how to raise them, and the thread-safety rules.
# Why?
@@ -78,7 +83,8 @@ You can define additional error types as integer constants. Values 0 through 255
are reserved by libakerror (the host's errno values plus the `AKERR_*` codes);
consumers allocate codes starting at `AKERR_FIRST_CONSUMER_STATUS` (256). Status
names are stored sparsely, so any `int` is a legal status and no compile
definition is needed to use large values.
definition is needed to use large values. Note that no consumer status can be a
process exit code — see [Exit status](#exit-status).
Every library that may coexist in one process must reserve its range during
initialization. `akerr_reserve_status_range()` and
@@ -151,7 +157,7 @@ What it does not cover, and cannot:
Registering a *new* status concurrently is fine.
* **Which unhandled error terminates the process.** An error that reaches
`FINISH_NORETURN` unhandled prints its stack trace and calls
`akerr_handler_unhandled_error`, which by default calls `exit()`. Each
`akerr_handler_unhandled_error`, which by default calls `akerr_exit()`. Each
thread's trace is whole — the buffer belongs to its context, and each line is
one call to `akerr_log_method` — but if two threads get there at the same
instant, both traces print and the exit status is whichever one won.
@@ -567,3 +573,80 @@ From bottom to top, we have:
* Above that, the `FINISH()` macro in `func2()` which detected an unhandled error and passed it out of the function
* Above that, a reference to the line where the `FAIL()` macro set the error code and provided the message which is printed here
## Exit status
**Never call `exit()` with an akerr status. Call `akerr_exit()`.**
```c
void akerr_exit(int status);
```
This applies everywhere you are leaving the process on account of a status, not
just in an unhandled-error handler: a CLI's top-level `HANDLE` block, an
initialization routine that cannot continue, a `main()` that ends by reporting
the status it finished with. One function owns the mapping so that a given
status produces the same exit code no matter which of your exits it left by.
The mapping exists because a process exit status is one byte wide. `exit()`
accepts an `int` and the kernel keeps the low 8 bits of it — `_exit()`,
`_Exit()`, `quick_exit()` and the raw `exit_group` syscall all behave
identically, and even `waitid()`, whose `si_status` is a full `int`, sees the
truncated value because the truncation happened before the parent looked. There
is no wider `exit()` to reach for.
That leaves 0 through 255 as the only statuses an exit code can carry, and
consumer statuses start at `AKERR_FIRST_CONSUMER_STATUS` (256) — so *no* consumer
status can be an exit code:
```
status exit code
0 0 (success)
1 .. 255 the status
negative, or > 255 AKERR_EXIT_STATUS_UNREPRESENTABLE (125)
```
Passing the low byte instead would have made status 256 exit 0 and report
success to the shell, and status 300 exit 44 — an unrelated error's code. 125 is
the conventional "the tool itself failed" status; 126, 127 and 128+*n* already
belong to the shell.
Status 0 exits 0, because 0 is this library's success status. That is not a hole
in the rule that an unhandled error never exits 0: `PROCESS` opens with `case
0`, which marks a zero status handled, so a successful context cannot reach
`FINISH_NORETURN`'s call to the handler at all.
Above 255, the exit code tells you the process died of an error, not which one.
125 is inside the library's reserved band and so is also some host's `errno`, and
every status above 255 collapses onto it. **The stack trace is what identifies
the error** — it is printed before the handler runs, carrying the status at full
width along with its registered name.
### Replacing the handler
After the trace is printed, `FINISH_NORETURN` calls
`akerr_handler_unhandled_error`. The default implementation,
`akerr_default_handler_unhandled_error()`, hands `errctx->status` to
`akerr_exit()` (a NULL context exits 1). Replace it if you need something else
to happen first — a core dump, a crash reporter, a flush — and finish by calling
`akerr_exit()` so the exit code still means what it means everywhere else:
```c
static void mylib_handler(akerr_ErrorContext *e)
{
mylib_flush_telemetry();
if ( e == NULL ) {
akerr_exit(AKERR_API);
}
akerr_exit(e->status);
}
akerr_handler_unhandled_error = &mylib_handler;
```
`akerr_exit()` is declared `AKERR_NORETURN`, so the compiler knows a handler
ending in one of those calls is complete rather than falling off the end.
Set the handler once, before you start any threads. `tests/err_custom_handler.c`
installs one that does not exit at all, which is how the test suite asserts on
unhandled errors without dying.