Files
libakgl/docs/04-errors.md
Andrew Kesterson 4e32328681
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 21s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
Remove the corner helpers akgl_collide_rectangles no longer uses
akgl_rectangle_points, akgl_collide_point_rectangle, akgl_Point and
akgl_RectanglePoints go. They were the intermediate form of an implementation
that changed: akgl_collide_rectangles was eight corner-containment tests built on
them, and it has been four span comparisons since the cross-case fix. Nothing
outside tests/ called either function, and a point-in-rectangle test is four
comparisons a caller can write without a struct conversion in front of them.

akgl_collide_rectangles stays. It has two correct callers in the sidescroller
asking a game-level overlap question -- a coin, a hazard, from an updatefunc --
where a bool is the whole answer and a proxy plus a narrowphase call would be
computing a normal nothing reads. TODO.md records the split rather than leaving
it to be rediscovered.

Public API removal, so 194 exported akgl_ symbols against 196, and the manual's
counts move with them. The perf suite loses its rectangle_points row; the
all-pairs sweep stays as the control it is now labelled, and PERFORMANCE.md says
what 0.8.0 measured against it -- 188.5 us for 32,640 pairs at 256 actors, where
a whole step with collision attached is 54.1 us doing strictly more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:09:42 -04:00

17 KiB
Raw Blame History

04. Errors and status codes

Every one of libakgl's exported functions returns akerr_ErrorContext AKERR_NOIGNORE *. You cannot read a single example in this manual until you can read that return value, which is why this chapter comes before any subsystem.

The protocol is libakerror's

ATTEMPT / CLEANUP / PROCESS / HANDLE / HANDLE_GROUP / FINISH, and the PASS, CATCH, IGNORE, FAIL_* and SUCCEED_RETURN macros, all belong to libakerror. They are documented by the project that owns them, in deps/libakerror/README.md — "Library Architecture", "Lifecycle of an error in the AKError library", and "Exit status". This manual does not restate them, because a copy here would be wrong the day libakerror changes and nothing in this repository would notice.

What is genuinely libakgl's, and is written down nowhere else, is which statuses these 193 functions raise and what each one means here. That is the three tables below.

The house rules for writing code against the protocol — never a *_RETURN inside an ATTEMPT, never a bare return out of a HANDLE, CLEANUP before PROCESS, an ATTEMPT inside the loop rather than a CATCH inside one — are in AGENTS.md under "Error-Handling Protocol", and two of them are enforced by the error_protocol test.

Table 1 — libakgl's own status codes

libakerror reserves statuses 0255 for the host's errno values and its own AKERR_* codes; consumers allocate upward from AKERR_FIRST_CONSUMER_STATUS, which is 256. libakgl claims a band of six starting there, under the owner string "libakgl":

#define AKGL_ERR_OWNER             "libakgl"
#define AKGL_ERR_BASE              AKERR_FIRST_CONSUMER_STATUS

#define AKGL_ERR_SDL               (AKGL_ERR_BASE + 0)   /**< An SDL call failed; the message carries SDL_GetError() */
#define AKGL_ERR_REGISTRY          (AKGL_ERR_BASE + 1)   /**< A registry property or lookup operation failed */
#define AKGL_ERR_HEAP              (AKGL_ERR_BASE + 2)   /**< A heap pool has no free object left to hand out */
#define AKGL_ERR_BEHAVIOR          (AKGL_ERR_BASE + 3)   /**< A component did not behave the way its contract requires */
#define AKGL_ERR_LOGICINTERRUPT    (AKGL_ERR_BASE + 4)   /**< Actor logic is telling the physics simulator to skip it */
#define AKGL_ERR_COLLISION         (AKGL_ERR_BASE + 5)   /**< A collision query could not be answered; the message says why */

AKGL_ERR_LIMIT is not a status code. It is AKGL_ERR_BASE + 6, one past the last one, and exists only so AKGL_ERR_COUNT can be computed from it. Nothing in src/ raises it and akgl_error_init does not name it. Do not write a HANDLE(e, AKGL_ERR_LIMIT) arm — it would catch nothing, and if a seventh real code is ever added it would silently start catching that instead.

Code Value Means Raised by What the caller does
AKGL_ERR_SDL 256 An SDL call failed; the message carries SDL_GetError() Around forty sites — anything touching a window, texture, renderer, mixer or the state mutex Usually fatal at startup. Check the driver and the asset path
AKGL_ERR_REGISTRY 257 A name lookup against a registry failed One site only: akgl_controller_default, when actorname is not in AKGL_REGISTRY_ACTOR Bind the control map after the actor is initialized
AKGL_ERR_HEAP 258 A pool has no free slot every akgl_heap_next_* Normally a missing release, not a small pool. See Chapter 5 before raising AKGL_MAX_HEAP_*
AKGL_ERR_BEHAVIOR 259 A component did not behave the way its contract requires Nothing in the library. Only tests/ raises it, through testutil.h Available to you for the same purpose: asserting a contract in your own code
AKGL_ERR_LOGICINTERRUPT 260 Not a failure — a control signal. "Skip the rest of this step for this actor" your own movementlogicfunc Raise it deliberately. See the note below
AKGL_ERR_COLLISION 261 A collision query could not be answered One cause only: akgl_collision_test, when the ccd arena is exhausted. The message carries the high-water mark Raise AKGL_CCD_ARENA_BYTES to the reported figure. See Chapter 15

akgl_error_init reserves the band all-or-nothing and registers a name for each of the six: "SDL Error", "Registry Error", "Heap Error", "Behavior Error", "Logic Interrupt", "Collision Error". Those names are what a stack trace prints.

Two rows deserve more than a cell.

AKGL_ERR_REGISTRY is rarer than its name suggests. The obvious candidates do not raise it. akgl_actor_initialize raises AKERR_KEY when the registry write fails, and akgl_actor_set_character raises AKERR_NULLPOINTER when the named character is not in AKGL_REGISTRY_CHARACTER. If you are writing a HANDLE arm for "that name was not registered", AKERR_NULLPOINTER and AKERR_KEY are the statuses you will actually see.

AKGL_ERR_LOGICINTERRUPT only works from one place. akgl_physics_simulate wraps each actor's step in an ATTEMPT and calls actor->movementlogicfunc through CATCH, with a HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT) arm that does nothing — so a raise from your movement logic skips that actor's gravity, drag and move for this step and the loop carries on to the next actor. The backend's own gravity and move are called through PASS in the same block, and PASS returns out of the function. A backend hook that raises AKGL_ERR_LOGICINTERRUPT aborts the whole physics step, leaving every remaining actor unsimulated and gravity_time unadvanced. Raise it from movementlogicfunc and nowhere else.

Table 2 — libakerror statuses libakgl raises, and what they mean here

The statuses are libakerror's; their libakgl meaning is not. This table was built by grepping every FAIL_* and HANDLE* site in src/, not by reading header prose.

Status What it means when a libakgl function raises it
AKERR_NULLPOINTER By a wide margin the most common — roughly six of every seven raise sites in src/. A required pointer argument was NULLor a required field was empty (akgl_game.name/.version/.uri), or a name was looked up and not found. akgl_actor_set_character and akgl_get_property both use it for "absent", and registry_create uses it for "SDL could not allocate the property set"
AKERR_KEY A key is absent, or a registry write was refused. A missing JSON object member (every akgl_get_json_*_value), a character with no sprite for a state, a physics backend name that is neither null nor arcade, a failed SDL_SetPointerProperty in akgl_actor_initialize. Frequently a HANDLE arm rather than a failure
AKERR_TYPE A JSON value was present but the wrong type. All of src/json_helpers.c and akgl_get_json_tilemap_property. This is the status that separates "you did not write that key" (AKERR_KEY) from "you wrote it as a string and it wants a number"
AKERR_OUTOFBOUNDS A value did not fit its destination, or an index ran off the end. aksl_strncpy truncation, an array index past the end of a JSON array (akgl_get_json_array_index_*), a ninth child on an actor, a tile past AKGL_TILEMAP_MAX_*, a control map id out of range
AKERR_VALUE A value parsed but was not usable: a version string that is not semver (akgl_game_load_versioncmp), two surfaces that differ (akgl_compare_sdl_surfaces), a zero-sized destination or an overlapping copy from the aksl_ wrappers
AKERR_RELATIONSHIP One site: akgl_actor_add_child, when the child already has a parent. Detach it first
AKERR_API Two distinct meanings, so read the message. (1) The function is not implementedakgl_render_2d_draw_mesh does FAIL_RETURN(..., AKERR_API, "Not implemented"), and it is reached by an ordinary-looking call. (2) A savegame does not match this buildakgl_game_load and akgl_game_load_versioncmp
AKERR_IO A read or write failed, or a savegame had trailing data after its name tables. Distinct from AKERR_EOF — that separation is the whole reason aksl_fgetc exists
AKERR_EOF End of input. Sometimes the desired outcome: require_at_eof in src/game.c handles it and treats anything else as a failure
AKERR_INDEX libakgl never raises this. It appears once, as a HANDLE_GROUP arm in akgl_get_json_with_default, so that a caller whose own code raises it still gets a default. An out-of-range JSON array index arrives as AKERR_OUTOFBOUNDS

Statuses raised by the libraries underneath also reach you unchanged. aksl_fopen reports ENOENT and EACCES as themselves; aksl_fclose reports ENOSPC and EDQUOT. akgl_error_init can raise libakerror's AKERR_STATUS_RANGE_OVERLAP, AKERR_STATUS_RANGE_FULL or AKERR_STATUS_NAME_FULL.

Chapter 22 has the per-function cross-reference.

Table 3 — the exit status trap

This is arithmetic, and it is silent.

You write Wait status the shell sees Why
exit(AKGL_ERR_SDL) 0 — a clean run An exit status is one byte. AKGL_ERR_SDL is 256, and 256 & 0xFF is 0
akerr_exit(status) 0 → 0, 1255 → itself, anything else → 125 125 is AKERR_EXIT_STATUS_UNREPRESENTABLE

Every suite in tests/ once reported success on the single most common failure a library built on SDL can have — a renderer that would not come up. tests/character.c aborted at its second of four tests and was green until 0.5.0.

You do not normally need to do anything about this. libakerror's default unhandled-error handler already calls akerr_exit(), so a main that ends in FINISH_NORETURN exits correctly. The trap is only live if you replace libakerror's akerr_handler_unhandled_error pointer with your own handler, or call exit(3) on a status yourself. If you do either, call akerr_exit().

Four traps that are libakgl's, not libakerror's

akgl_error_init() must run before anything that can raise

akgl_error_init claims the status band and registers the six names. A code raised before it runs has no name in the registry, so every stack trace carrying it prints "Unknown Error" instead of "Heap Error" — and the status number, 258, means nothing to a reader.

akgl_game_init calls it first, before anything else that can fail — that is why the mutex creation and the akgl_game.name checks sit after it. A host with its own startup path, such as one binding an existing renderer with akgl_render_2d_bind instead of calling akgl_game_init, must call akgl_error_init itself. Repeat calls are a no-op, so a program that cannot order its initialization precisely may simply call it more than once.

The SDL callbacks end in FINISH_NORETURN, and that exits the process

SDL_EnumerateProperties takes a void-returning callback, so a failure inside one has nowhere to go. All six of libakgl's therefore end in FINISH_NORETURN, which logs the trace and hands the context to libakerror's unhandled-error handler — and the default handler exits the process. They are akgl_registry_iterate_actor (src/actor.c), akgl_character_state_sprites_iterate (src/character.c), and the four savegame name iterators in src/game.c.

A reader meets this the first time a sprite name is wrong: an actor's renderfunc raises AKERR_KEY, the callback cannot return it, and the game exits mid-frame with a stack trace instead of returning an error you could have handled.

Two consequences worth planning for:

  • A write error part-way through akgl_game_save terminates the game rather than reaching akgl_game_save's return value. game.h says so on akgl_game_save_actors.
  • If you want a different outcome, point libakerror's akerr_handler_unhandled_error function pointer at your own handler — and call akerr_exit() from it, per Table 3.

Note that akgl_game_update does not go through the callback. It walks akgl_heap_actors directly and propagates through PASS, so an error from an actor's updatefunc does reach you. The callback path is the render sweep and the savegame writer.

akgl_get_json_with_default hands back the context you gave it

This is the one libakgl function whose ownership rule you have to know before you write your first loader. It takes an incoming akerr_ErrorContext *, and when it does not handle that status it returns the same pointer, because its FINISH(e, true) passes e up.

So the context has exactly one owner at a time, and it is never both yours and its. A CLEANUP block that releases your original pointer as well as releasing what the call returned releases the same slot twice, and a double-released context corrupts the failure instead of reporting it. That is not hypothetical: it is how the first draft of the AKERR_OUTOFBOUNDS test in tests/json_helpers.c passed against the unfixed library. AGENTS.md documents the shape under "Testing Guidelines"; TODO.md item 18 records the history.

The correct shape: take the result into a local, NULL your own pointer immediately, and decide from the local.

It defaults on three statuses — AKERR_KEY, AKERR_OUTOFBOUNDS and AKERR_INDEX — so both a missing object member and a short array give you the default. It does not default on AKERR_TYPE: a key that is present with the wrong type is a mistake in the document, not an absent value, and it propagates.

libakerror 2.0.1 is a hard floor, checked at compile time

include/akgl/error.h carries two #error feature tests, because libakerror publishes no version macro and each probes the narrowest thing that a required release introduced:

  • AKERR_FIRST_CONSUMER_STATUS — added in 1.0.0 with the private status registry. Without it, AKGL_ERR_BASE does not exist and the failure lands much later, inside src/heap.c.
  • AKERR_EXIT_STATUS_UNREPRESENTABLE — added in 2.0.1 with akerr_exit().

Both failures are an installed header being stale, not a build-tree problem. 2.0.0 is an ABI break — akerr_next_error() returns a context that already holds its reference, and __akerr_last_ignored is thread-local — and both expand at libakgl's own call sites through IGNORE and the FAIL_* macros. The soname is libakerror.so.2, so the libraries cannot be mixed by accident; the header can. Rebuild and reinstall libakerror.

One worked example

A loader reading an optional integer out of a JSON document. It is the smallest piece of real libakgl code that exercises both halves of the chapter: a status that means "absent and that is fine", and the ownership rule on the function that turns it into a default.

#include <jansson.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/json_helpers.h>

/* Read an optional integer out of a loaded document, defaulting to 4. */
akerr_ErrorContext AKERR_NOIGNORE *read_speed(json_t *doc, int *dest)
{
    akerr_ErrorContext *keyerr = NULL;
    akerr_ErrorContext *unhandled = NULL;
    int def = 4;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, doc, AKERR_NULLPOINTER, "NULL document");
    FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");

    keyerr = akgl_get_json_integer_value(doc, "speed", dest);
    unhandled = akgl_get_json_with_default(keyerr, &def, dest, sizeof(int));
    keyerr = NULL;
    if ( unhandled != NULL ) {
	return unhandled;
    }
    SUCCEED_RETURN(errctx);
}

int main(void)
{
    json_t *doc = NULL;
    int speed = 0;

    PREPARE_ERROR(errctx);
    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	doc = json_pack("{s:i}", "width", 32);
	CATCH(errctx, read_speed(doc, &speed));
    } CLEANUP {
	if ( doc != NULL ) {
	    json_decref(doc);
	}
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);
}

Four things in there are libakgl-specific and worth naming:

  1. akgl_error_init() is the first call, before anything that can raise.
  2. keyerr = NULL; on the line after the call. Ownership passed either way — handled or not — and the local is the only surviving reference. Nothing else in the function may touch keyerr again.
  3. A speed key that is absent is not an error. akgl_get_json_integer_value raises AKERR_KEY, akgl_get_json_with_default handles it and writes 4. A speed key that is present as a string is an error: AKERR_TYPE is not defaulted and comes back as unhandled.
  4. main ends in FINISH_NORETURN. That is what gets you akerr_exit() and a truthful exit status without writing the trap from Table 3.

Where to go next

  • Chapter 5AKGL_ERR_HEAP, and why it is almost always a missing release.
  • Chapter 6 — the name lookups behind AKERR_KEY and AKERR_NULLPOINTER.
  • Chapter 22 — which function raises what.
  • deps/libakerror/README.md — the protocol itself, and the exit-status discussion.
  • The generated Doxygen reference — every function's own @throws list.