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` `akerror::akerror` and `akstdlib::akstdlib` from `deps/libakerror` and `deps/libakstdlib`
**before** `add_subdirectory(deps/libakgl)`, or the targets are declared twice. **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 That order is load-bearing for a second reason: `deps/libakerror` is at **2.0.1**, whose 2.0.0
and ABI break carrying an soname (`libakerror.so.1`). `libakstdlib` and `libakgl` must be was a source and ABI break carrying an soname (`libakerror.so.2`). `libakstdlib` and `libakgl`
compiled against that header, not a pre-1.0.0 one, and an installed `libakerror.so.0` must not must be compiled against that header, not a 1.x one, and an installed `libakerror.so.1` must
be picked up. 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 ### Dependency versions and what they promise
| Submodule | Version | soname | ABI rule | Version API | | 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/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()` | | `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 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 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. 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 **Registration became thread safe in libakerror 2.0.0**, and the rule barely moves.
spawns anything. `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 ### Nothing calls malloc

View File

@@ -138,8 +138,9 @@ API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.
Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is
nothing to install first. nothing to install first.
* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error * [libakerror](https://source.starfort.tech/andrew/libakerror) 2.0.1 — TRY/CATCH-style error
contexts. Every function that can fail returns one. contexts. Every function that can fail returns one. 2.0.0 made it thread safe and broke the
ABI; anything built against a 1.x header must be rebuilt rather than relinked.
* [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.2.0 — libc wrappers that * [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.2.0 — libc wrappers that
report through `libakerror`. String-to-number conversion goes straight to it, which is why report through `libakerror`. String-to-number conversion goes straight to it, which is why
`VAL("garbage")` is an error rather than a silent `0`. `VAL("garbage")` is an error rather than a silent `0`.

26
TODO.md
View File

@@ -1706,10 +1706,34 @@ Dependency baseline:
| Submodule | Version | Notes | | Submodule | Version | Notes |
|---|---|---| |---|---|---|
| `deps/libakerror` | 1.0.0 | Private ownership-enforced status registry. akbasic reserves 512767 in `akbasic_error_register()`. Does not namespace its `coverage` target when embedded — worked around in our `CMakeLists.txt`. | | `deps/libakerror` | 2.0.1 | Private ownership-enforced status registry. akbasic reserves 512767 in `akbasic_error_register()`. Does not namespace its `coverage` target when embedded — worked around in our `CMakeLists.txt`. **2.0.0 is thread safe and an ABI break** (`libakerror.so.2`): `__akerr_last_ignored` is thread-local and `akerr_next_error()` returns an owned reference, neither of which fails to link when mismatched. **2.0.1 fixes an exit status that mattered more to this band than to any other** — see below. |
| `deps/libakstdlib` | 0.2.0 | soname `libakstdlib.so.0.2`. `AKSL_VERSION_CHECK()` asserted in `tests/version_check.c`. This release fixed all six confirmed defects the port was working around — see §1.9, where the bans are now lifted. | | `deps/libakstdlib` | 0.2.0 | soname `libakstdlib.so.0.2`. `AKSL_VERSION_CHECK()` asserted in `tests/version_check.c`. This release fixed all six confirmed defects the port was working around — see §1.9, where the bans are now lifted. |
| `deps/libakgl` | 0.4.0 | soname `libakgl.so.0.4`. Owns status codes 256260. Linked and tested under `-DAKBASIC_WITH_AKGL=ON`, which still defaults OFF so the core library and its whole suite build on a machine with no SDL. **0.3.0 closed every API gap this port had filed** — see §7 — so the four workarounds §3 used to list are gone. 0.4.0 is a leak-and-overread release that changed no public struct, but the soname carries `MAJOR.MINOR` while the major is 0, so rebuild rather than relink. | | `deps/libakgl` | 0.4.0 | soname `libakgl.so.0.4`. Owns status codes 256260. Linked and tested under `-DAKBASIC_WITH_AKGL=ON`, which still defaults OFF so the core library and its whole suite build on a machine with no SDL. **0.3.0 closed every API gap this port had filed** — see §7 — so the four workarounds §3 used to list are gone. 0.4.0 is a leak-and-overread release that changed no public struct, but the soname carries `MAJOR.MINOR` while the major is 0, so rebuild rather than relink. |
**An unhandled error in this band used to exit zero, and 512 is the worst possible base for
that.** `libakerror`'s default unhandled-error handler ended in `exit(errctx->status)`, and a
process exit status is one byte — the kernel keeps the low 8 bits and discards the rest.
`AKBASIC_ERR_BASE` is 512, and **512 truncates to 0**, so an unhandled `AKBASIC_ERR_SYNTAX`
reported *success* to a shell, a CI job or a supervisor watching `$?`. Every other code in the
band came out as some unrelated error's number: 515 as 3, 519 as 7.
libakerror 2.0.1 routes the handler through `akerr_exit()`, which substitutes
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125) for anything a byte cannot carry. Verified here
rather than taken on trust: a probe raising `AKBASIC_ERR_DEVICE` through `FINISH_NORETURN`
exits 125.
**It was latent here, not live**, and that is worth stating precisely rather than claiming a
narrow escape. `src/main.c` handles the context itself and returns `EXIT_FAILURE`, and every
test with a top-level `ATTEMPT` carries a `HANDLE_DEFAULT` — so nothing in this repository ever
reached the defaulted handler. `libakgl` was not so lucky and found it the hard way: its
`tests/character.c` had been passing while running one of its four tests, because `AKGL_ERR_SDL`
is exactly 256 and exited 0.
The guard is now two `#error`s in `include/akbasic/error.h` and two assertions in
`tests/version_check.c`, including one that fails if `AKBASIC_ERR_BASE` stops truncating to
zero — because the day the band moves is the day this note stops being about *our* base, and a
silent change of subject is how a comment becomes a lie.
**The `libakgl` requirement used to be pinned by commit, and no longer is.** 42b60f7 added 22 **The `libakgl` requirement used to be pinned by commit, and no longer is.** 42b60f7 added 22
public symbols across four headers and left `project(akgl VERSION 0.1.0)` and the public symbols across four headers and left `project(akgl VERSION 0.1.0)` and the
`libakgl.so.0.1` soname unchanged, so nothing here could feature-test for the new API and the `libakgl.so.0.1` soname unchanged, so nothing here could feature-test for the new API and the

View File

@@ -134,6 +134,24 @@ pool exhaustion, a NULL argument — which is yours to handle.
That is the split to hold on to: a script's mistakes are the script's problem, and your That is the split to hold on to: a script's mistakes are the script's problem, and your
program keeps running. program keeps running.
## Threads
**One runtime belongs to one thread, and there is no lock anywhere in this interpreter.**
An `akbasic_Runtime` is a large struct of fixed pools mutated in place by every step, so
two threads calling `akbasic_runtime_step()` on the same runtime will corrupt it. If your
game is threaded, drive the script from whichever thread owns it and hand results across
yourself.
Two runtimes on two threads are fine — they share no state. What they *do* share is
`libakerror`'s error pool and status registry, and those became thread safe in 2.0.0, so
raising, handling and releasing errors from either thread is safe with no coordination
from you. One error context still belongs to the thread that raised it; passing one to
another thread is your synchronization.
Call `akbasic_error_register()` once, during single-threaded startup, before you spawn
anything. It is idempotent and safe to repeat, but registering a status *name* while
another thread looks one up is the single registry operation no lock can make safe.
## Reading it all ## Reading it all
Two complete hosts are checked in and built by every build, so neither can rot: Two complete hosts are checked in and built by every build, so neither can rot:

View File

@@ -9,18 +9,44 @@
#include <akerror.h> #include <akerror.h>
/* /*
* libakerror 1.0.0 is the floor. That release moved the status-name table into a * libakerror 2.0.0 is the floor, raised from 1.0.0 because 2.0.0 is an ABI break
* private registry -- AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, the * that a compile against the wrong header cannot survive quietly:
* registry entry points raise akerr_ErrorContext * instead of returning int, and
* the library gained an soname -- so a translation unit that pairs this header
* with a pre-1.0.0 akerror.h is an ABI mismatch, not just a compile problem.
* *
* libakerror publishes no version macro, so this feature-tests on * - `__akerr_last_ignored` became thread-local. `IGNORE` expands at *our* call
* AKERR_FIRST_CONSUMER_STATUS, which that release introduced. Same guard * site, so our objects reference that symbol under whichever storage model
* libakstdlib and libakgl already carry. * the header on the include path declared.
* - `akerr_next_error()` now returns a context that already holds a reference,
* and `ENSURE_ERROR_READY` no longer increments. Objects compiled against a
* 1.x header count every reference twice and never give a slot back.
*
* Neither is a compile error. Both are a pool that leaks or a use-after-free,
* which is exactly the class of mismatch a guard is for.
*
* libakerror still publishes no version macro, so this feature-tests on
* AKERR_THREAD_SAFE, which 2.0.0 introduced and writes into the generated header
* as 1 or 0 -- so `#ifndef` is the right test and `#if` is not. It replaces the
* AKERR_FIRST_CONSUMER_STATUS test this carried for 1.0.0, which 2.0.0 also
* still defines and which therefore no longer distinguishes anything.
*/ */
#ifndef AKERR_FIRST_CONSUMER_STATUS #ifndef AKERR_THREAD_SAFE
#error "libakbasic requires libakerror >= 1.0.0: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror." #error "libakbasic requires libakerror >= 2.0.0: the akerror.h on the include path predates the thread-safe error pool. Rebuild and reinstall libakerror."
#endif
/*
* 2.0.1 additionally fixes an exit status this band made worse than most.
* `akerr_default_handler_unhandled_error()` used to end in `exit(errctx->status)`
* and a process exit status is one byte, so a consumer status came out truncated
* -- and **AKBASIC_ERR_BASE is 512, which truncates to 0**. An unhandled
* `AKBASIC_ERR_SYNTAX` reported success to whatever was watching `$?`. It exits
* AKERR_EXIT_STATUS_UNREPRESENTABLE (125) now.
*
* Nothing here calls `exit()` on a status -- `src/main.c` handles the context and
* returns EXIT_FAILURE, and every test installs a HANDLE_DEFAULT -- so the hazard
* was latent rather than live. It is guarded anyway, because "no caller relies on
* it today" is not a property a header can keep true.
*/
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
#error "libakbasic requires libakerror >= 2.0.1: an unhandled status in akbasic's band would exit 0. Rebuild and reinstall libakerror."
#endif #endif
/* /*

View File

@@ -49,10 +49,29 @@ int main(void)
/* /*
* libakerror publishes no version macro at all, so there is nothing to * libakerror publishes no version macro at all, so there is nothing to
* compare. Its floor is the #error on AKERR_FIRST_CONSUMER_STATUS that * compare. Its floor is the two #errors akbasic/error.h carries -- if this
* akbasic/error.h carries -- if this file compiled, that guard passed. * file compiled, both guards passed. What is asserted here is the *value* of
* what they test, because a guard that only checks a macro is defined passes
* against a header that defines it to something else entirely.
*/ */
TEST_REQUIRE_INT(AKERR_FIRST_CONSUMER_STATUS, 256); TEST_REQUIRE_INT(AKERR_FIRST_CONSUMER_STATUS, 256);
TEST_REQUIRE_INT(AKERR_THREAD_SAFE, 1);
/*
* The one that matters most to this band. AKBASIC_ERR_BASE is 512, and a
* process exit status is one byte: 512 truncates to 0, so before libakerror
* 2.0.1 an unhandled AKBASIC_ERR_SYNTAX exited *success*. akerr_exit()
* substitutes 125 for anything a byte cannot carry.
*
* Asserted as a number rather than trusted as a macro because this is the
* value a supervisor watching $? actually sees, and 125 is chosen to sit
* clear of the 1-124 range a program is likely to use for itself.
*/
TEST_REQUIRE_INT(AKERR_EXIT_STATUS_UNREPRESENTABLE, 125);
TEST_REQUIRE((AKBASIC_ERR_BASE & 0xff) == 0,
"AKBASIC_ERR_BASE %d no longer truncates to zero -- if the band moved, "
"the exit-status hazard this guards moved with it",
AKBASIC_ERR_BASE);
return akbasic_test_failures; return akbasic_test_failures;
} }