akerr_release_error() memsets the whole 37 KB context when eight scalar stores are what correctness needs #26

Closed
opened 2026-08-04 10:30:58 -04:00 by tachikoma · 5 comments
Collaborator

The claim

akerr_release_error() wipes the entire context on final release — memset(err, 0x00, sizeof(akerr_ErrorContext)) at src/error.c:396, under the global pool lock. sizeof(akerr_ErrorContext) is 37,296 bytes on this platform (message 12,384 + fname 4,096 + function 4,096 + stacktracebuf 16,688 + scalars). Correctness on recycle needs about eight scalar stores. The other ~37 KB of zeroes is work the next user of the slot immediately overwrites or never reads.

The documented cost model prices construction: "error construction is serialized across threads: raising an error is the exceptional path, and correctness there is worth more than throughput" (README §Threading, UPGRADING §Cost). Release is priced nowhere, and it is the single most expensive operation in the library — more than the four snprintfs of FAIL combined. A program that raises and handles errors — the pattern every HANDLE block exists for — pays it on every one.

Measured

Found while profiling the akbasic interpreter (andrew/akbasic#38). Before akbasic stopped using thrown contexts for expected lookup misses, its benchmark took and released 3,217,256 contexts in a ~21 s run: ~25% of all cycles were this one memset — ~120 GB of zeroes written to release 3.2M errors. That consumer was abusing the library and has its own fix, but the per-release price it exposed is libakerror's: ~37 KB wiped and a global lock cycle for every handled error, in a library whose stated philosophy is that the caller should not fear using it.

Why the wipe is almost all waste

What each field actually needs at recycle time, checked against every reader:

Field Needs on recycle Why
handled clear — the one real correctness reset Set by PROCESS/HANDLE; FAIL never clears it. A recycled slot with stale handled=true would make FINISH_LOGIC silently not propagate the next error raised in it. Today only the wipe prevents that.
status clear (one store) FAIL and SUCCEED both write it before any read, but a stale nonzero here is cheap to rule out.
stacktracebufptr reset to &stacktracebuf Already done explicitly after the memset (src/error.c:397).
refcount nothing The decrement that triggered the release already made it 0; akerr_next_error() sets 1 on take.
arrayid nothing Already preserved across the wipe.
message, fname, function, lineno nothing FAIL overwrites all four via self-terminating snprintf before anything reads them (include/akerror.tmpl.h:360-366). Stale bytes past the terminator are unreachable.
stacktracebuf nothing Append-only through stacktracebufptr (AKERR_STACKTRACE_APPEND, include/akerror.tmpl.h:293-306). With the cursor reset, the first append snprintfs from offset 0 and terminates; readers never look past the terminator. This buffer is 16,688 of the 37,296 bytes, and zeroing it buys nothing.
reported clear (one store) Currently written and read by nothing — a dead field — but clearing it costs one store and keeps it honest if it comes back to life.

So the wipe's entire functional load is: handled = false, status = 0, reported = false, cursor reset — plus, if belt-and-suspenders is wanted, one '\0' at the head of each of the four string buffers. Call it eight stores.

The fix

Replace the memset with those targeted stores, in the same place, under the same lock. The comment above the function — the wipe and the decrement must be one operation under the lock, or a releasing thread races the taker (src/error.c:374-379) — is a constraint on atomicity, not on volume: eight stores under the lock hold it exactly as well as 37 KB of them did, and hold the lock for less time while doing it.

Two things should land in the same commit:

  1. A test that pins the handled trap. Release a slot after a HANDLEd error, drain the pool so the same slot is re-taken, raise a fresh error in it, and assert it propagates. That is the behavior the memset was silently load-bearing for, and it is precisely the kind of thing the mutation-testing gate exists to catch — today, mutating the memset away would only fail a test if this test exists.
  2. A sentence in UPGRADING §Cost saying what release costs now, so the cost model covers both ends of the lifecycle.

One tradeoff to decide, stated rather than smuggled: the full wipe also scrubs stale error text out of the pool. Inside one process that is hygiene, not a security boundary — anything that can read the pool can read live contexts too. If the scrubbing is wanted anyway, it belongs behind an explicit build flag or an akerr_scrub() a paranoid consumer can call, not in every release on every path.

Reproducing

The numbers come from andrew/akbasic#38's benchmark (akbasic_example_galaga_interop at the galaga-tutorial branch); the perf and uprobe recipe is in that issue's "Reproducing the numbers" section. For this defect in isolation: any loop that FAILs into a HANDLE and releases, under perf record, shows the memset immediately.

— Tachikoma (Claude Code, claude-fable-5, 200k)

## The claim `akerr_release_error()` wipes the entire context on final release — `memset(err, 0x00, sizeof(akerr_ErrorContext))` at `src/error.c:396`, under the global pool lock. `sizeof(akerr_ErrorContext)` is **37,296 bytes** on this platform (`message` 12,384 + `fname` 4,096 + `function` 4,096 + `stacktracebuf` 16,688 + scalars). Correctness on recycle needs about eight scalar stores. The other ~37 KB of zeroes is work the next user of the slot immediately overwrites or never reads. The documented cost model prices *construction*: *"error construction is serialized across threads: raising an error is the exceptional path, and correctness there is worth more than throughput"* (README §Threading, UPGRADING §Cost). Release is priced nowhere, and it is the single most expensive operation in the library — more than the four `snprintf`s of `FAIL` combined. A program that raises **and handles** errors — the pattern every `HANDLE` block exists for — pays it on every one. ## Measured Found while profiling the akbasic interpreter (andrew/akbasic#38). Before akbasic stopped using thrown contexts for expected lookup misses, its benchmark took and released 3,217,256 contexts in a ~21 s run: **~25% of all cycles were this one `memset`** — ~120 GB of zeroes written to release 3.2M errors. That consumer was abusing the library and has its own fix, but the per-release price it exposed is libakerror's: ~37 KB wiped and a global lock cycle for every handled error, in a library whose stated philosophy is that the caller should not fear using it. ## Why the wipe is almost all waste What each field actually needs at recycle time, checked against every reader: | Field | Needs on recycle | Why | |---|---|---| | `handled` | **clear — the one real correctness reset** | Set by `PROCESS`/`HANDLE`; `FAIL` never clears it. A recycled slot with stale `handled=true` would make `FINISH_LOGIC` silently not propagate the next error raised in it. Today only the wipe prevents that. | | `status` | clear (one store) | `FAIL` and `SUCCEED` both write it before any read, but a stale nonzero here is cheap to rule out. | | `stacktracebufptr` | reset to `&stacktracebuf` | Already done explicitly after the memset (`src/error.c:397`). | | `refcount` | nothing | The decrement that triggered the release already made it 0; `akerr_next_error()` sets 1 on take. | | `arrayid` | nothing | Already preserved across the wipe. | | `message`, `fname`, `function`, `lineno` | nothing | `FAIL` overwrites all four via self-terminating `snprintf` before anything reads them (`include/akerror.tmpl.h:360-366`). Stale bytes past the terminator are unreachable. | | `stacktracebuf` | nothing | Append-only through `stacktracebufptr` (`AKERR_STACKTRACE_APPEND`, `include/akerror.tmpl.h:293-306`). With the cursor reset, the first append `snprintf`s from offset 0 and terminates; readers never look past the terminator. This buffer is 16,688 of the 37,296 bytes, and zeroing it buys nothing. | | `reported` | clear (one store) | Currently written and read by nothing — a dead field — but clearing it costs one store and keeps it honest if it comes back to life. | So the wipe's entire functional load is: `handled = false`, `status = 0`, `reported = false`, cursor reset — plus, if belt-and-suspenders is wanted, one `'\0'` at the head of each of the four string buffers. Call it eight stores. ## The fix Replace the `memset` with those targeted stores, in the same place, under the same lock. The comment above the function — the wipe and the decrement must be one operation under the lock, or a releasing thread races the taker (`src/error.c:374-379`) — is a constraint on *atomicity*, not on *volume*: eight stores under the lock hold it exactly as well as 37 KB of them did, and hold the lock for less time while doing it. Two things should land in the same commit: 1. **A test that pins the `handled` trap.** Release a slot after a `HANDLE`d error, drain the pool so the same slot is re-taken, raise a fresh error in it, and assert it propagates. That is the behavior the memset was silently load-bearing for, and it is precisely the kind of thing the mutation-testing gate exists to catch — today, mutating the memset away would only fail a test if this test exists. 2. **A sentence in UPGRADING §Cost** saying what release costs now, so the cost model covers both ends of the lifecycle. One tradeoff to decide, stated rather than smuggled: the full wipe also scrubs stale error text out of the pool. Inside one process that is hygiene, not a security boundary — anything that can read the pool can read live contexts too. If the scrubbing is wanted anyway, it belongs behind an explicit build flag or an `akerr_scrub()` a paranoid consumer can call, not in every release on every path. ## Reproducing The numbers come from andrew/akbasic#38's benchmark (`akbasic_example_galaga_interop` at the `galaga-tutorial` branch); the perf and uprobe recipe is in that issue's "Reproducing the numbers" section. For this defect in isolation: any loop that `FAIL`s into a `HANDLE` and releases, under `perf record`, shows the memset immediately. — Tachikoma (Claude Code, claude-fable-5, 200k)
logikoma was assigned by andrew 2026-08-04 10:42:25 -04:00
andrew added the status::readyblast-radius:lowdefectperformance labels 2026-08-04 10:42:57 -04:00
andrew added this to the 2.0.x milestone 2026-08-04 10:43:03 -04:00
Collaborator

Beginning work on this issue. I will inspect the plan, implement the fix on branch 26, verify it, and open a pull request.

Beginning work on this issue. I will inspect the plan, implement the fix on branch 26, verify it, and open a pull request.
Collaborator

Plan confirmed. I found an existing clean checkout, will base branch 26 on main, preserve the pool-lock invariant, add the handled-state recycle regression, and update UPGRADING cost documentation.

Plan confirmed. I found an existing clean checkout, will base branch 26 on main, preserve the pool-lock invariant, add the handled-state recycle regression, and update UPGRADING cost documentation.
Collaborator

Beginning work on issue #26: I’ll replace the full context memset in akerr_release_error() with the required scalar resets, then test and submit a PR.

Beginning work on issue #26: I’ll replace the full context memset in akerr_release_error() with the required scalar resets, then test and submit a PR.
Collaborator

Progress: targeted recycle resets are implemented, the handled-state reuse regression is passing, and all 37 CTest tests pass. The signed change is pushed on branch 26 as d00e0cf.

Progress: targeted recycle resets are implemented, the handled-state reuse regression is passing, and all 37 CTest tests pass. The signed change is pushed on branch 26 as d00e0cf.
Collaborator

Done: PR #27 is open and requests Andrew's review: #27

Done: PR #27 is open and requests Andrew's review: https://source.starfort.tech/andrew/libakerror/pulls/27
Sign in to join this conversation.