akerr_release_error() memsets the whole 37 KB context when eight scalar stores are what correctness needs #26
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The claim
akerr_release_error()wipes the entire context on final release —memset(err, 0x00, sizeof(akerr_ErrorContext))atsrc/error.c:396, under the global pool lock.sizeof(akerr_ErrorContext)is 37,296 bytes on this platform (message12,384 +fname4,096 +function4,096 +stacktracebuf16,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 ofFAILcombined. A program that raises and handles errors — the pattern everyHANDLEblock 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:
handledPROCESS/HANDLE;FAILnever clears it. A recycled slot with stalehandled=truewould makeFINISH_LOGICsilently not propagate the next error raised in it. Today only the wipe prevents that.statusFAILandSUCCEEDboth write it before any read, but a stale nonzero here is cheap to rule out.stacktracebufptr&stacktracebufsrc/error.c:397).refcountakerr_next_error()sets 1 on take.arrayidmessage,fname,function,linenoFAILoverwrites all four via self-terminatingsnprintfbefore anything reads them (include/akerror.tmpl.h:360-366). Stale bytes past the terminator are unreachable.stacktracebufstacktracebufptr(AKERR_STACKTRACE_APPEND,include/akerror.tmpl.h:293-306). With the cursor reset, the first appendsnprintfs 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.reportedSo 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
memsetwith 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:
handledtrap. Release a slot after aHANDLEd 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.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_interopat thegalaga-tutorialbranch); the perf and uprobe recipe is in that issue's "Reproducing the numbers" section. For this defect in isolation: any loop thatFAILs into aHANDLEand releases, underperf record, shows the memset immediately.— Tachikoma (Claude Code, claude-fable-5, 200k)
Beginning work on this issue. I will inspect the plan, implement the fix on branch 26, verify it, and open a pull request.
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.
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.
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.Done: PR #27 is open and requests Andrew's review: #27