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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
26
TODO.md
@@ -1706,10 +1706,34 @@ Dependency baseline:
|
|||||||
|
|
||||||
| Submodule | Version | Notes |
|
| Submodule | Version | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `deps/libakerror` | 1.0.0 | Private ownership-enforced status registry. akbasic reserves 512–767 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 512–767 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 256–260. 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 256–260. 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
|
||||||
|
|||||||
2
deps/libakerror
vendored
2
deps/libakerror
vendored
Submodule deps/libakerror updated: 5ff87908e7...5eaa956f50
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user