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

206
tests/err_exit_status.c Normal file
View File

@@ -0,0 +1,206 @@
#include "akerror.h"
#include "err_capture.h"
#include <unistd.h>
#include <sys/wait.h>
/*
* An unhandled error must never leave the process looking like a success.
*
* The default handler used to exit(errctx->status) unconditionally, and an exit
* status is one byte wide: status 256 -- AKERR_FIRST_CONSUMER_STATUS, the very
* first code any consumer can reserve -- exited 0 and told the shell the
* program succeeded. Status 300 exited 44, which is some unrelated error's code.
*
* akerr_exit() now owns that mapping, and the default handler is one of its
* callers. The same table therefore drives both: a status must produce the same
* exit code whether a consumer calls akerr_exit() from their own handler or
* lets the library's handler run.
*
* akerr_exit(0) exits 0 -- zero is the library's success status. The thing that
* keeps an unhandled error off that path is PROCESS's `case 0`, which marks a
* zero status handled before FINISH_NORETURN can reach the handler, and the
* last three cases assert that, plus that the status an exit code could not
* carry is still recoverable from the stack trace.
*
* Neither exit path returns, so those cases run in forked children.
*/
#define TEST_OWNER "err_exit_status"
#define TEST_STATUS AKERR_FIRST_CONSUMER_STATUS
#define TEST_STATUS_NAME "Consumer Status Two Fifty Six"
static const struct {
int status;
int expect;
} exit_cases[] = {
/* status expected exit */
{ 0, 0 }, /* Zero is the library's success status, and exit code 0 is what that is called out here */
{ 1, 1 }, /* Lowest status an exit code can carry */
{ AKERR_VALUE, AKERR_VALUE }, /* An ordinary library status, delivered intact */
{ AKERR_EXIT_STATUS_MAX, AKERR_EXIT_STATUS_MAX }, /* Highest status an exit code can carry */
{ AKERR_FIRST_CONSUMER_STATUS, AKERR_EXIT_STATUS_UNREPRESENTABLE }, /* 256: low byte 0, the case that used to exit success */
{ 300, AKERR_EXIT_STATUS_UNREPRESENTABLE }, /* Low byte 44 would alias an unrelated status */
{ 65536, AKERR_EXIT_STATUS_UNREPRESENTABLE }, /* Low byte 0 again, further out */
{ -1, AKERR_EXIT_STATUS_UNREPRESENTABLE }, /* Negative: low byte 255 */
};
/*
* Run body in a child and return its exit status, or -1 if it did not exit
* normally. The 99 sentinel catches a body that returns instead of terminating.
*/
static int child_exit_status(void (*body)(void))
{
pid_t pid = fork();
if ( pid == 0 ) {
body();
_exit(99);
}
int status = 0;
if ( pid < 0 || waitpid(pid, &status, 0) != pid || !WIFEXITED(status) ) {
return -1;
}
return WEXITSTATUS(status);
}
/* The status the next child leaves with. Set before each fork. */
static int pending_status;
/* The way a consumer's own handler is expected to leave. */
static void run_akerr_exit(void)
{
akerr_exit(pending_status);
}
/* The way the library leaves when nothing handled the error. */
static void run_default_handler(void)
{
akerr_ErrorContext *slot = akerr_next_error();
if ( slot == NULL ) {
_exit(98);
}
slot->status = pending_status;
akerr_default_handler_unhandled_error(slot);
}
static akerr_ErrorContext AKERR_NOIGNORE *raise_consumer_error(void)
{
PREPARE_ERROR(errctx);
FAIL_RETURN(errctx, TEST_STATUS, "consumer status, deliberately unhandled");
}
static akerr_ErrorContext AKERR_NOIGNORE *raise_nothing(void)
{
PREPARE_ERROR(errctx);
SUCCEED_RETURN(errctx);
}
/* A full propagation to the top of the stack, with nothing handling it. */
static void unhandled_consumer_error(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, raise_consumer_error());
} CLEANUP {
} PROCESS(errctx) {
/* no HANDLE for TEST_STATUS -> stays unhandled */
} FINISH_NORETURN(errctx);
}
/*
* Reached through FINISH_NORETURN rather than by calling the handler directly,
* so the child exercises the path a consumer actually takes. The handler is set
* explicitly because an earlier case in this process may have replaced it.
*/
static void unhandled_consumer_error_fatal(void)
{
akerr_handler_unhandled_error = &akerr_default_handler_unhandled_error;
unhandled_consumer_error();
}
static int trace_fired = -2;
static void nonfatal_handler(akerr_ErrorContext *e)
{
trace_fired = (e != NULL) ? e->status : -1;
}
/*
* The same shape as unhandled_consumer_error(), except nothing fails. PROCESS
* opens with `case 0`, so a zero status is handled and the handler must not
* run -- which is what keeps akerr_exit(0) exiting 0 from being a hole in the
* "an unhandled error never exits 0" rule.
*/
static void successful_operation(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, raise_nothing());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}
int main(void)
{
akerr_capture_install();
akerr_init();
/* The three outcomes have to stay distinguishable from each other. */
AKERR_CHECK(AKERR_EXIT_STATUS_UNREPRESENTABLE != 0);
AKERR_CHECK(AKERR_EXIT_STATUS_UNREPRESENTABLE != 1);
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(TEST_STATUS, 1, TEST_OWNER));
AKERR_CHECK_SUCCEEDS(akerr_register_status_name(TEST_OWNER, TEST_STATUS,
TEST_STATUS_NAME));
for ( size_t i = 0; i < sizeof(exit_cases) / sizeof(exit_cases[0]); i++ ) {
pending_status = exit_cases[i].status;
int direct = child_exit_status(&run_akerr_exit);
if ( direct != exit_cases[i].expect ) {
fprintf(stderr, "akerr_exit(%d) exited %d, want %d\n",
exit_cases[i].status, direct, exit_cases[i].expect);
return 1;
}
/* The handler must not carry a mapping of its own. */
int handled = child_exit_status(&run_default_handler);
if ( handled != direct ) {
fprintf(stderr, "default handler on status %d exited %d,"
" but akerr_exit(%d) exited %d\n",
exit_cases[i].status, handled, exit_cases[i].status, direct);
return 1;
}
}
/* The NULL-context exit is asserted by tests/err_unhandled_null.c. */
/* End to end: an unhandled consumer error kills the process non-zero. */
AKERR_CHECK(child_exit_status(&unhandled_consumer_error_fatal)
== AKERR_EXIT_STATUS_UNREPRESENTABLE);
/*
* And the status the exit code could not carry is in the trace. Run with a
* handler that returns so the assertions happen in this process, where the
* captured log lives.
*/
akerr_handler_unhandled_error = &nonfatal_handler;
akerr_capture_reset();
unhandled_consumer_error();
AKERR_CHECK(trace_fired == TEST_STATUS);
AKERR_CHECK_CONTAINS("Unhandled Error");
AKERR_CHECK_CONTAINS("256");
AKERR_CHECK_CONTAINS(TEST_STATUS_NAME);
/* A zero status is handled by PROCESS and never reaches the handler. */
trace_fired = -2;
akerr_capture_reset();
successful_operation();
AKERR_CHECK(trace_fired == -2);
AKERR_CHECK_NOT_CONTAINS("Unhandled Error");
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_exit_status ok\n");
return 0;
}

View File

@@ -6,7 +6,10 @@
/*
* The default unhandled-error handler is the library's last stop: it exits the
* process. Both of its exits were untested -- exit(1) for a NULL context (a
* handler invoked with no error at all) and exit(errctx->status) for a real one.
* handler invoked with no error at all) and, for a real one, the status handed
* to akerr_exit(). tests/err_exit_status.c covers what akerr_exit() does with a
* status; this covers that the handler reaches it, and the NULL case, which
* never gets that far.
*
* The handler never returns, so each case runs in a forked child and the test
* asserts the exact exit status. That is stricter than a WILL_FAIL test, which