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>
207 lines
6.9 KiB
C
207 lines
6.9 KiB
C
#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;
|
|
}
|