diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 9e2fa12..bb54538 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -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 diff --git a/README.md b/README.md index 4c24967..48eb40c 100644 --- a/README.md +++ b/README.md @@ -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 nothing to install first. -* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error - contexts. Every function that can fail returns one. +* [libakerror](https://source.starfort.tech/andrew/libakerror) 2.0.1 — TRY/CATCH-style error + 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 report through `libakerror`. String-to-number conversion goes straight to it, which is why `VAL("garbage")` is an error rather than a silent `0`. diff --git a/TODO.md b/TODO.md index d3fedb5..a2556bd 100644 --- a/TODO.md +++ b/TODO.md @@ -1706,10 +1706,34 @@ Dependency baseline: | 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/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 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 diff --git a/deps/libakerror b/deps/libakerror index 5ff8790..5eaa956 160000 --- a/deps/libakerror +++ b/deps/libakerror @@ -1 +1 @@ -Subproject commit 5ff87908e7b68ab2dc328b55ea89585272411ff9 +Subproject commit 5eaa956f504611a08204dcaf288e1ad1a571ee21 diff --git a/docs/10-embedding.md b/docs/10-embedding.md index 43c21f7..673ee18 100644 --- a/docs/10-embedding.md +++ b/docs/10-embedding.md @@ -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 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 Two complete hosts are checked in and built by every build, so neither can rot: diff --git a/include/akbasic/error.h b/include/akbasic/error.h index 54d3571..1b417f3 100644 --- a/include/akbasic/error.h +++ b/include/akbasic/error.h @@ -9,18 +9,44 @@ #include /* - * libakerror 1.0.0 is the floor. That release moved the status-name table into a - * private registry -- AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, the - * 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 2.0.0 is the floor, raised from 1.0.0 because 2.0.0 is an ABI break + * that a compile against the wrong header cannot survive quietly: * - * libakerror publishes no version macro, so this feature-tests on - * AKERR_FIRST_CONSUMER_STATUS, which that release introduced. Same guard - * libakstdlib and libakgl already carry. + * - `__akerr_last_ignored` became thread-local. `IGNORE` expands at *our* call + * site, so our objects reference that symbol under whichever storage model + * 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 -#error "libakbasic requires libakerror >= 1.0.0: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror." +#ifndef AKERR_THREAD_SAFE +#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 /* diff --git a/tests/version_check.c b/tests/version_check.c index 9029c1c..cd3a439 100644 --- a/tests/version_check.c +++ b/tests/version_check.c @@ -49,10 +49,29 @@ int main(void) /* * libakerror publishes no version macro at all, so there is nothing to - * compare. Its floor is the #error on AKERR_FIRST_CONSUMER_STATUS that - * akbasic/error.h carries -- if this file compiled, that guard passed. + * compare. Its floor is the two #errors akbasic/error.h carries -- if this + * 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_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; }