Take libakerror 2.0.1, and guard the exit status it fixes

2.0.0 makes the error pool and the status registry thread safe, and it is an ABI
break carrying the soname to libakerror.so.2. The break is a quiet one:
__akerr_last_ignored became thread-local and akerr_next_error() now returns a
context that already holds a reference, so objects compiled against a 1.x header
and linked against 2.x count every reference twice and never give a slot back.
Nothing about that fails to link, which is exactly what a guard is for --
include/akbasic/error.h feature-tests AKERR_THREAD_SAFE instead of
AKERR_FIRST_CONSUMER_STATUS, which 2.0.0 also still defines and which therefore
no longer distinguishes anything.

2.0.1 is the release this band needed most. The default unhandled-error handler
ended in exit(errctx->status), and a process exit status is one byte:
AKBASIC_ERR_BASE is 512, and 512 truncates to 0, so an unhandled
AKBASIC_ERR_SYNTAX reported success to anything watching $?. Every other code in
the band came out as some unrelated error's number. akerr_exit() substitutes 125
for anything a byte cannot carry, and a probe raising AKBASIC_ERR_DEVICE through
FINISH_NORETURN now exits 125 rather than 7.

It was latent here rather than live -- src/main.c handles the context and returns
EXIT_FAILURE, and every test with a top-level ATTEMPT carries a HANDLE_DEFAULT --
but "no caller relies on it today" is not a property a header can keep true.
tests/version_check.c asserts the mapping and fails if AKBASIC_ERR_BASE ever
stops truncating to zero, because that is the day this stops being about our base.

Chapter 10 gains a threading section: libakerror is safe from any thread now, and
this interpreter is not and has no lock anywhere in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 08:00:24 -04:00
parent 16b38c1138
commit 8077806598
7 changed files with 123 additions and 23 deletions

View File

@@ -153,16 +153,18 @@ guards every dependency with `if(NOT TARGET ...)`, so a top-level build must def
`akerror::akerror` and `akstdlib::akstdlib` from `deps/libakerror` and `deps/libakstdlib`
**before** `add_subdirectory(deps/libakgl)`, or the targets are declared twice.
That order is load-bearing for a second reason: `deps/libakerror` is at **1.0.0**, a source
and ABI break carrying an soname (`libakerror.so.1`). `libakstdlib` and `libakgl` must be
compiled against that header, not a pre-1.0.0 one, and an installed `libakerror.so.0` must not
be picked up.
That order is load-bearing for a second reason: `deps/libakerror` is at **2.0.1**, whose 2.0.0
was a source and ABI break carrying an soname (`libakerror.so.2`). `libakstdlib` and `libakgl`
must be compiled against that header, not a 1.x one, and an installed `libakerror.so.1` must
not be picked up. The break is quiet if you get it wrong: `__akerr_last_ignored` became
thread-local and `akerr_next_error()` now returns a context that already holds a reference, so
a mixed build leaks pool slots or frees one twice rather than failing to link.
### Dependency versions and what they promise
| Submodule | Version | soname | ABI rule | Version API |
|---|---|---|---|---|
| `deps/libakerror` | 1.0.0 | `libakerror.so.1` | major only | **none** — no version macro, no `ConfigVersion` file |
| `deps/libakerror` | 2.0.1 | `libakerror.so.2` | major only | **none** — no version macro; `include/akbasic/error.h` feature-tests `AKERR_THREAD_SAFE` and `AKERR_EXIT_STATUS_UNREPRESENTABLE` instead |
| `deps/libakstdlib` | 0.2.0 | `libakstdlib.so.0.2` | **`MAJOR.MINOR` while major is 0** | `AKSL_VERSION_*`, `aksl_version()`, `AKSL_VERSION_CHECK()` |
| `deps/libakgl` | 0.4.0 | `libakgl.so.0.4` | **`MAJOR.MINOR` while major is 0** | `AKGL_VERSION*`, `akgl_version()`, `AKGL_VERSION_AT_LEAST()` |
@@ -482,8 +484,18 @@ so the table above is the only tooling there is. Two rules follow:
Capacity is not a concern: the name registry holds 3072 entries and `akerr_init()` consumes
about 150; reservations cap at 64 ranges. Both are `PRIVATE` to the `libakerror` target, so
raising them is a `libakerror` configure-time decision, not something `akbasic` sets.
Registration is **not thread safe** — do it during single-threaded init, before the host game
spawns anything.
**Registration became thread safe in libakerror 2.0.0**, and the rule barely moves.
`akerr_reserve_status_range()` and `akerr_register_status_name()` are serialized now — two
threads reserving overlapping ranges cannot both succeed, one gets `AKERR_STATUS_RANGE_OVERLAP`
naming the winner. What is still yours to coordinate is *re*-registering a name for a status
another thread may be looking up: `akerr_name_for_status()` returns a pointer into the registry
rather than a copy, which is what makes it usable from a stack trace, and a second registration
overwrites that buffer in place. The reader is outside the lock by the time it reads the
string, so no lock can fix it.
So: still do it during single-threaded init, before the host game spawns anything — not
because the call would race, but because there is one operation in the registry that cannot be
made safe and this is the discipline that avoids needing it.
### Nothing calls malloc