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

@@ -305,12 +305,47 @@ void akerr_init()
akerr_once(&akerr_state_once, &akerr_init_state);
}
/*
* Every way out of the library's status space goes through here, so that a
* status becomes an exit code exactly one way no matter who is leaving.
*
* Only 0 through AKERR_EXIT_STATUS_MAX survive the trip -- see the note on
* AKERR_EXIT_STATUS_UNREPRESENTABLE in the header for why there is no wider
* exit() to reach for. Everything else exits with that sentinel instead of its
* low byte, because the low byte is either a lie (status 300 exiting 44, which
* is some other error's code) or a disaster (status 256, the first status a
* consumer can own, exiting 0 and telling the shell the program succeeded).
*
* Status 0 exits 0, because 0 is this library's success status and an exit code
* of 0 is what success is called out here. What keeps an *unhandled* error from
* exiting 0 is not this function: PROCESS opens with `case 0`, which marks a
* zero status handled, so a successful context can never reach
* FINISH_NORETURN's call to the handler in the first place.
*
* Nothing is logged here. Callers arrive from a position that has already
* reported -- FINISH_NORETURN logs the stack trace, carrying the status at full
* width, before it calls the handler -- and a second line naming a number the
* trace already gave would only invite the reader to trust the exit code.
*/
void akerr_exit(int status)
{
if ( status < 0 || status > AKERR_EXIT_STATUS_MAX ) {
exit(AKERR_EXIT_STATUS_UNREPRESENTABLE);
}
exit(status);
}
/*
* The last stop for an unhandled error. A handler invoked with no error at all
* has no status to report and nothing akerr_exit() could map, so it exits 1
* directly: a plain generic failure.
*/
void akerr_default_handler_unhandled_error(akerr_ErrorContext *errctx)
{
if ( errctx == NULL ) {
exit(1);
}
exit(errctx->status);
if ( errctx == NULL ) {
exit(1);
}
akerr_exit(errctx->status);
}
/*