From 5eaa956f504611a08204dcaf288e1ad1a571ee21 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sat, 1 Aug 2026 07:25:37 -0400 Subject: [PATCH] Stop an unhandled error from exiting zero 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) --- AGENTS.md | 10 ++ CMakeLists.txt | 6 +- README.md | 91 +++++++++++++++- UPGRADING.md | 42 ++++++++ include/akerror.tmpl.h | 42 ++++++++ src/error.c | 43 +++++++- tests/err_exit_status.c | 206 +++++++++++++++++++++++++++++++++++++ tests/err_unhandled_null.c | 5 +- 8 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 tests/err_exit_status.c diff --git a/AGENTS.md b/AGENTS.md index 369e3a9..5cd0189 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,16 @@ locked body does the work, and a thin wrapper takes the lock, calls it, and releases it on the single return path. Do not call consumer code (`akerr_log_method`, `akerr_handler_unhandled_error`) while holding it. +**Exiting.** Never call `exit()` with a status value. Call `akerr_exit()`, which +owns the one mapping from an akerr status to an exit code: an exit status is a +byte, and every consumer status starts at 256, so passing a status through +`exit()` silently truncates it — status 256 exits 0 and reports success. The +only exits that bypass it are the two that have no status to map, both in +`src/error.c`: `ENSURE_ERROR_READY`'s pool-exhaustion abort and the NULL-context +case in `akerr_default_handler_unhandled_error()`. This rule applies to test +programs too, except where the test's whole point is to observe the raw +truncation. + ## Testing Guidelines Add tests as `tests/err_.c`. Register each new test in the diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fafa76..2f73410 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,10 @@ cmake_minimum_required(VERSION 3.10) # now takes for it. Consumer code compiled against a 1.x header would # double-count every reference. Hence the major bump and the SOVERSION, so a # stale installed libakerror.so cannot be silently paired with new headers. -project(akerror VERSION 2.0.0 LANGUAGES C) +# 2.0.1 fixes the unhandled-error exit code, which reported success for any +# status whose low byte was zero. It adds akerr_exit() but breaks nothing: the +# soname is unchanged and no existing entry point changed shape. +project(akerror VERSION 2.0.1 LANGUAGES C) include(GNUInstallDirs) include(CMakePackageConfigHelpers) @@ -217,6 +220,7 @@ set(AKERR_TESTS err_name_bounds err_format_string err_unhandled_null + err_exit_status err_release_null err_release_refcount ) diff --git a/README.md b/README.md index ca490ce..2c55890 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/UPGRADING.md b/UPGRADING.md index d1f31e9..5e6874d 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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 diff --git a/include/akerror.tmpl.h b/include/akerror.tmpl.h index 2df9f8b..7f4e5a5 100644 --- a/include/akerror.tmpl.h +++ b/include/akerror.tmpl.h @@ -113,6 +113,26 @@ */ typedef char akerr_assert_codes_within_reserved_band[(AKERR_LAST_LIBRARY_STATUS < 256) ? 1 : -1]; +/* + * A process exit status is one byte wide. exit() takes an int, but the kernel + * keeps only the low 8 bits of it and throws the rest away, so 0 through 255 + * are the only statuses that can also be exit codes -- and every consumer status + * begins at AKERR_FIRST_CONSUMER_STATUS (256). No wider variant exists to reach + * for: _exit(), _Exit(), quick_exit() and the raw exit_group syscall all + * truncate identically, and even waitid()'s int-wide si_status reports the + * truncated value, because the truncation happened before the parent looked. + * + * akerr_exit() therefore substitutes AKERR_EXIT_STATUS_UNREPRESENTABLE for any + * status it cannot deliver intact, rather than passing the low byte -- status + * 256 would exit 0 and report success. 125 is the conventional "the tool itself + * failed" code (126, 127 and 128+n belong to the shell). It is inside the + * library's reserved band, so it is also some host's errno: the exit code says + * only that the process died of an error, and the stack trace carries the real + * status. + */ +#define AKERR_EXIT_STATUS_MAX 255 +#define AKERR_EXIT_STATUS_UNREPRESENTABLE 125 + #define AKERR_MAX_ARRAY_ERROR 128 @@ -132,6 +152,9 @@ typedef struct } akerr_ErrorContext; #define AKERR_NOIGNORE __attribute__((warn_unused_result)) +/* akerr_exit() does not come back, and the compiler should know it: a handler + * whose last statement is a call to it is complete, not falling off the end. */ +#define AKERR_NORETURN __attribute__((noreturn)) typedef void (*akerr_ErrorUnhandledErrorHandler)(akerr_ErrorContext *errctx); typedef void (*akerr_ErrorLogFunction)(const char *f, ...); @@ -190,6 +213,25 @@ akerr_ErrorContext AKERR_NOIGNORE *akerr_register_status_name(const char *owner, */ akerr_ErrorContext AKERR_NOIGNORE *akerr_reserve_status_range(int first_status, int count, const char *owner); void akerr_init(); +/* + * Terminate the process, reporting `status`. Use this instead of exit() + * anywhere you are leaving on account of an akerr status -- an unhandled-error + * handler of your own, a CLI's top-level HANDLE block, an init routine that + * cannot continue -- so that every exit out of the library's status space maps + * the same way. + * + * Exits with `status` when 0 <= status <= AKERR_EXIT_STATUS_MAX, and with + * AKERR_EXIT_STATUS_UNREPRESENTABLE otherwise (see above). Status 0 exits 0: + * zero is this library's success status, and passing it here says the program + * finished, not that it failed with a code that got lost. + */ +void AKERR_NORETURN akerr_exit(int status); +/* + * The default akerr_handler_unhandled_error: logs nothing further -- the stack + * trace has already been printed by the time it runs -- and hands `ptr->status` + * to akerr_exit(), or exits 1 when `ptr` is NULL. Replace it if you need a + * different mapping, and call akerr_exit() from your replacement. + */ void akerr_default_handler_unhandled_error(akerr_ErrorContext *ptr); void akerr_default_logger(const char *f, ...); int akerr_valid_error_address(akerr_ErrorContext *ptr); diff --git a/src/error.c b/src/error.c index 2e5d311..a3ae18e 100644 --- a/src/error.c +++ b/src/error.c @@ -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); } /* diff --git a/tests/err_exit_status.c b/tests/err_exit_status.c new file mode 100644 index 0000000..2e7b929 --- /dev/null +++ b/tests/err_exit_status.c @@ -0,0 +1,206 @@ +#include "akerror.h" +#include "err_capture.h" +#include +#include + +/* + * 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; +} diff --git a/tests/err_unhandled_null.c b/tests/err_unhandled_null.c index f5464ed..0d72174 100644 --- a/tests/err_unhandled_null.c +++ b/tests/err_unhandled_null.c @@ -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