Invalid for libakstdlib: PASS/CATCH do release; the one demonstrable leak is libakerror's IGNORE(), fixed by libakerror #21 #37

Closed
opened 2026-08-03 12:51:31 -04:00 by tachikoma · 4 comments
Collaborator

This issue is invalid and should be closed. Nothing in libakstdlib is missing or misshapen. Rewritten a second time after @andrew's review (comment 1094).

Every claim this ticket has been filed on has now been withdrawn:

  1. "No way to annotate" — retracted (comment 1091). AKERR_STACKTRACE_APPEND works as he wrote it.
  2. "No just-tell-me-whether-it-worked form" — retracted (comment 1091). NULL is the test.
  3. "Discarding a context leaks the pool, so libakstdlib's shape is the hazard" — retracted here. The measured idioms in the previous version (return aksl_remove(name) == NULL;, (void)aksl_remove(name);) were written by me for the measurement. No consumer writes them. A footgun that only fires in code nobody writes is not a defect report, and the burden was correctly placed on this ticket to produce a real call site.

Andrew is right about PASS and CATCH

Verified against include/akerror.tmpl.h at libakerror 5eaa956 (the commit akbasic pins):

  • DETECT (:417) is guarded by if ( __err_context != NULL ), so a NULL return does nothing. Correct.
  • CATCH (:426) breaks to CLEANUP; PROCESS (:445) dispatches on status; HANDLE/HANDLE_GROUP/HANDLE_DEFAULT (:451/:457/:463) set handled = true; FINISH (:476) ends in RELEASE_ERROR(__err_context).
  • PASS (:429) hands the context up to a frame that does the above.

There is no user intervention required and no leak on either path. That is the whole of what this issue claimed, and it was wrong.

The one demonstrable leak is on a third path, and it is not libakstdlib's

Andrew asked for a real consumer site. There is one, and it is IGNORE() — which is neither PASS nor CATCH, and which logs the context without releasing it:

#define IGNORE(__stmt)                          \
    __akerr_last_ignored = __stmt;              \
    if ( __akerr_last_ignored != NULL ) {       \
        LOG_ERROR_WITH_MESSAGE(__akerr_last_ignored, "** IGNORED ERROR **"); \
    }

akbasic main (330d731) wraps an aksl_* call in it at nine checked-in sites:

src/main.c:207 · src/runtime_disk.c:64,347,349,428,476,677 · src/runtime_commands.c:654,705

This is already known in this repository. libakstdlib/src/stdlib.c:1294 avoids the macro for exactly this reason, and says why:

A failure to release the queue must not mask the error that got us here, so it is logged and dropped — but dropped by hand rather than with IGNORE(), which logs the context and then never releases it back to the pool.

libakstdlib uses IGNORE() nowhere. akbasic uses it in nine places where the wrapped call is an aksl_*.

Measured, end to end, on a stock build

cmake -S . -B build && cmake --build build on akbasic 330d731 with its pinned submodules, no patches. /dev/full stands in for the full disk that akbasic_cmd_dsave's own comment names as the motivating case.

10 REM SAVE ONTO A DISK THAT IS ALWAYS FULL
20 FOR I# = 1 TO 200
30 DSAVE "/dev/full"
40 NEXT I#
50 PRINT "SURVIVED 200 DSAVES"
akbasic/deps/libakstdlib/src/stdlib.c:aksl_fclose:399: 28 (No space left on device) : fclose failed, and any buffered data is lost
akbasic/src/runtime_commands.c:akbasic_cmd_dsave:705: ** IGNORED ERROR ** 28 (No space left on device): ...
   ... x128 ...
akbasic/src/symtab.c:akbasic_symtab_get:110: Unable to pull an error context from the array!

Exit status 1 on the 129th iteration. AKERR_MAX_ARRAY_ERROR is 128, so the count is exactly the pool. Line 50 never runs. A second, independent site reproduces identically:

repro leaked contexts outcome
DSAVE "/dev/full" x200 (runtime_commands.c:705) 128 exit(1) on the 129th
COPY "f.txt","/dev/full" x200 (runtime_disk.c:349) 128 exit(1) on the 129th

The BASIC program is five lines and uses no unusual construct. The failure it needs is a disk that is full — which is the exact condition akbasic_cmd_dsave was changed to report in the first place.

The fix exists and is already open

Rebuilding the same akbasic against libakerror PR #21 (4fe7571, "Release ignored error contexts"), which adds one line to IGNORE:

repro libakerror 5eaa956 libakerror PR #21
DSAVE "/dev/full" x200 dies on the 129th survives 200, logs 200, exit 0
COPY "f.txt","/dev/full" x200 dies on the 129th survives 200, logs 200, exit 0

akbasic's own suite is 112/112 against that branch.

Recommendation

Close this issue. No libakstdlib change follows from any version of it.

The action is in libakerror, and it is already written: merge libakerror #21. Until it lands, the workaround is the one libakstdlib/src/stdlib.c:1294 already uses — log by hand, then akerr_release_error() — and akbasic's nine sites are akbasic's to convert.

Two unrelated libakerror observations

Neither defends this ticket; recorded so they are not lost.

  • docs/uncaught-errors.md:17 says "By design users may explicitly ignore an error code from a function marked with warn_unused_result by explicitly casting the return to void." Under gcc 15.2 that is false — (void)f() still raises -Wunused-result — and the cast holds a pool slot besides (measured: 5 discards, 5 slots). The sentence describes clang's behaviour and reads as sanctioning an idiom that neither compiles clean nor releases.
  • HANDLE/HANDLE_GROUP/HANDLE_DEFAULT reset stacktracebufptr to offset 0 without clearing stacktracebuf. Inside the handler the buffer still reads intact, but the cursor is at 0, so the next AKERR_STACKTRACE_APPEND overwrites from the start. Measured: a caller's annotation is readable in the handler and gone after one further append.

Effect on #38

Unchanged by the above; @andrew has ruled on #38 separately (its comment 1095) and it is being rewritten to record that ruling.

Filed by Tachikoma (Claude Code, Opus 5, 1M context)

**This issue is invalid and should be closed.** Nothing in libakstdlib is missing or misshapen. Rewritten a second time after @andrew's review (comment 1094). Every claim this ticket has been filed on has now been withdrawn: 1. "No way to annotate" — retracted (comment 1091). `AKERR_STACKTRACE_APPEND` works as he wrote it. 2. "No just-tell-me-whether-it-worked form" — retracted (comment 1091). `NULL` is the test. 3. "Discarding a context leaks the pool, so libakstdlib's shape is the hazard" — **retracted here.** The measured idioms in the previous version (`return aksl_remove(name) == NULL;`, `(void)aksl_remove(name);`) were written by me for the measurement. No consumer writes them. A footgun that only fires in code nobody writes is not a defect report, and the burden was correctly placed on this ticket to produce a real call site. ## Andrew is right about `PASS` and `CATCH` Verified against `include/akerror.tmpl.h` at libakerror `5eaa956` (the commit akbasic pins): * `DETECT` (:417) is guarded by `if ( __err_context != NULL )`, so a `NULL` return does nothing. Correct. * `CATCH` (:426) breaks to `CLEANUP`; `PROCESS` (:445) dispatches on status; `HANDLE`/`HANDLE_GROUP`/`HANDLE_DEFAULT` (:451/:457/:463) set `handled = true`; `FINISH` (:476) ends in `RELEASE_ERROR(__err_context)`. * `PASS` (:429) hands the context up to a frame that does the above. There is no user intervention required and no leak on either path. That is the whole of what this issue claimed, and it was wrong. ## The one demonstrable leak is on a third path, and it is not libakstdlib's Andrew asked for a real consumer site. There is one, and it is `IGNORE()` — which is neither `PASS` nor `CATCH`, and which logs the context without releasing it: ```c #define IGNORE(__stmt) \ __akerr_last_ignored = __stmt; \ if ( __akerr_last_ignored != NULL ) { \ LOG_ERROR_WITH_MESSAGE(__akerr_last_ignored, "** IGNORED ERROR **"); \ } ``` akbasic `main` (`330d731`) wraps an `aksl_*` call in it at **nine** checked-in sites: `src/main.c:207` · `src/runtime_disk.c:64,347,349,428,476,677` · `src/runtime_commands.c:654,705` **This is already known in this repository.** `libakstdlib/src/stdlib.c:1294` avoids the macro for exactly this reason, and says why: > A failure to release the queue must not mask the error that got us here, so it is logged and dropped — but dropped by hand rather than with `IGNORE()`, **which logs the context and then never releases it back to the pool.** libakstdlib uses `IGNORE()` nowhere. akbasic uses it in nine places where the wrapped call is an `aksl_*`. ### Measured, end to end, on a stock build `cmake -S . -B build && cmake --build build` on akbasic `330d731` with its pinned submodules, no patches. `/dev/full` stands in for the full disk that `akbasic_cmd_dsave`'s own comment names as the motivating case. ```basic 10 REM SAVE ONTO A DISK THAT IS ALWAYS FULL 20 FOR I# = 1 TO 200 30 DSAVE "/dev/full" 40 NEXT I# 50 PRINT "SURVIVED 200 DSAVES" ``` ``` akbasic/deps/libakstdlib/src/stdlib.c:aksl_fclose:399: 28 (No space left on device) : fclose failed, and any buffered data is lost akbasic/src/runtime_commands.c:akbasic_cmd_dsave:705: ** IGNORED ERROR ** 28 (No space left on device): ... ... x128 ... akbasic/src/symtab.c:akbasic_symtab_get:110: Unable to pull an error context from the array! ``` Exit status 1 on the **129th** iteration. `AKERR_MAX_ARRAY_ERROR` is 128, so the count is exactly the pool. Line 50 never runs. A second, independent site reproduces identically: | repro | leaked contexts | outcome | |---|---|---| | `DSAVE "/dev/full"` x200 (`runtime_commands.c:705`) | 128 | `exit(1)` on the 129th | | `COPY "f.txt","/dev/full"` x200 (`runtime_disk.c:349`) | 128 | `exit(1)` on the 129th | The BASIC program is five lines and uses no unusual construct. The failure it needs is a disk that is full — which is the exact condition `akbasic_cmd_dsave` was changed to report in the first place. ### The fix exists and is already open Rebuilding the same akbasic against **libakerror PR #21** (`4fe7571`, "Release ignored error contexts"), which adds one line to `IGNORE`: | repro | libakerror `5eaa956` | libakerror PR #21 | |---|---|---| | `DSAVE "/dev/full"` x200 | dies on the 129th | **survives 200**, logs 200, exit 0 | | `COPY "f.txt","/dev/full"` x200 | dies on the 129th | **survives 200**, logs 200, exit 0 | akbasic's own suite is 112/112 against that branch. ## Recommendation **Close this issue.** No libakstdlib change follows from any version of it. The action is in libakerror, and it is already written: **merge libakerror #21.** Until it lands, the workaround is the one `libakstdlib/src/stdlib.c:1294` already uses — log by hand, then `akerr_release_error()` — and akbasic's nine sites are akbasic's to convert. ## Two unrelated libakerror observations Neither defends this ticket; recorded so they are not lost. * **`docs/uncaught-errors.md:17`** says "By design users may explicitly ignore an error code from a function marked with `warn_unused_result` by explicitly casting the return to `void`." Under gcc 15.2 that is false — `(void)f()` still raises `-Wunused-result` — and the cast holds a pool slot besides (measured: 5 discards, 5 slots). The sentence describes clang's behaviour and reads as sanctioning an idiom that neither compiles clean nor releases. * **`HANDLE`/`HANDLE_GROUP`/`HANDLE_DEFAULT`** reset `stacktracebufptr` to offset 0 without clearing `stacktracebuf`. Inside the handler the buffer still reads intact, but the cursor is at 0, so the next `AKERR_STACKTRACE_APPEND` overwrites from the start. Measured: a caller's annotation is readable in the handler and gone after one further append. ## Effect on #38 Unchanged by the above; @andrew has ruled on #38 separately (its comment 1095) and it is being rewritten to record that ruling. Filed by Tachikoma (Claude Code, Opus 5, 1M context)
tachikoma added this to the 0.3.0 milestone 2026-08-03 12:51:31 -04:00
tachikoma added the design-decisionblast-radius:mediumstatus::grooming labels 2026-08-03 12:51:31 -04:00
Owner

@tachikoma

No way to annotate.

AKERR_STACKTRACE_APPEND(e, "SCRATCH could not delete \"%s\"", name) ... but setting and reading strings is a dumb way of handling errors

No "just tell me whether it worked" form.

If an aksl_* function returns NULL, it worked. What's the problem?

@tachikoma > No way to annotate. `AKERR_STACKTRACE_APPEND(e, "SCRATCH could not delete \"%s\"", name)` ... but setting and reading strings is a dumb way of handling errors > No "just tell me whether it worked" form. If an aksl_* function returns NULL, it worked. What's the problem?
tachikoma changed title from A caller cannot add its own context to a wrapper's error, so it raises and discards instead to A caller CAN annotate and CAN test success; the real hazard is that discarding a context leaks the pool 2026-08-03 14:20:55 -04:00
Author
Collaborator

@andrew You are right on both counts. I have retracted both claims and rewritten the body; the ticket now recommends closing itself. Retitled too, since the old title asserted the thing that turned out to be false.

On AKERR_STACKTRACE_APPEND. I filed "no way to annotate" without compiling it, and it works exactly as you wrote it. Real aksl_remove() failure, caller appends, propagates up two frames through PASS, read at the top:

status .......... 2 (No such file or directory)
->message ....... "t_nosuch.txt"
->stacktracebuf .
libakstdlib/src/stream.c:aksl_remove:516: 2 (No such file or directory) : t_nosuch.txt
SCRATCH could not delete "t_nosuch.txt"
exp1.c:dispatch:35
exp1.c:run:42

One caveat I did find, small and libakerror's: HANDLE/HANDLE_GROUP/HANDLE_DEFAULT reset stacktracebufptr to the start of the buffer without clearing it, so an annotation looks intact after a HANDLE but the next append overwrites it from offset 0.

On NULL means it worked. Also right, and I should not have written otherwise. But testing it and disposing of the context are different problems, and that is where the actual bug is. This compiles under -Wall -Wextra with no -Wunused-result warning, because the NULL test counts as using the value:

static bool scratch_ok(const char *name) { return aksl_remove(name) == NULL; }

and it holds a pool slot per failure. Measured, 200 consecutive failures each:

idiom result
RELEASE_ERROR(e) pool returns to 0
e->handled = true; IGNORE(akerr_release_error(e)) pool returns to 0
return aksl_remove(name) == NULL; leaks, exit(1) on the 128th
(void)aksl_remove(name); leaks, exit(1) on the 128th
IGNORE(aksl_remove(name)); leaks, exit(1) on the 128th

sizeof(akerr_ErrorContext) is 37296 and the pool is 128 slots, so that is 4.77 MB going dry and ENSURE_ERROR_READY calling exit(1). The macro named IGNORE is the one that kills you, it appears nowhere in libakerror's README or docs/, and docs/uncaught-errors.md:17 recommends the (void) cast — which gcc does not honour for warn_unused_result and which leaks too. I rebuilt libakstdlib against libakerror #21 and it fixes the IGNORE row cleanly (pool stays at 0 across 200). The (void) row is unfixable and stays fatal.

On "setting and reading strings is a dumb way of handling errors." Agreed, and I think the sharper version is that at these sites you are not actually missing a structured channel — you already have one and akbasic already uses it. akbasic/src/error.c:20-37 reserves a status range and registers eight AKBASIC_ERR_* names. And aksl_remove raises AKSL_ERRNO_OR(AKERR_IO) with "%s", pathname, so the context arrives carrying ->status == ENOENT, ->message == "t_nosuch.txt", ->function == "aksl_remove". Every part of SCRATCH could not delete "t_nosuch.txt" except the verb's own name is already in a machine-readable field, and the verb name is in the frame above. What makes the string feel load-bearing is only that akbasic/src/runtime.c:472,495,1275,1376,1818 render ->message alone. Going further — typed (kind, value) payload slots — costs either more of a struct that is 37 KB because everything is a fixed string buffer, or the first heap allocation on a path that is currently allocation-free. I do not think that trade pays at this size.

What I think should happen, and the only thing I need a ruling on: close #37, and let me file two against libakerror — (1) document the discard contract, since the failure mode is a process death at the 128th error rather than a compile error, and (2) the HANDLE cursor-reset caveat. #21 already covers the third. Say the word and I will file them; I have not touched anything yet.

#38 is affected too. Its "a bool predicate has no way to call one" framing is wrong the same way — a bool predicate can call a wrapper and RELEASE_ERROR the context. What survives there is different and I still think worth deciding: the cost in scanner.c's per-character loop, and that a bool return has nowhere to distinguish "not equal" from "the comparison failed". I would narrow #38 rather than close it, but I have left it untouched pending your call.

@andrew You are right on both counts. I have retracted both claims and rewritten the body; the ticket now recommends closing itself. Retitled too, since the old title asserted the thing that turned out to be false. **On `AKERR_STACKTRACE_APPEND`.** I filed "no way to annotate" without compiling it, and it works exactly as you wrote it. Real `aksl_remove()` failure, caller appends, propagates up two frames through `PASS`, read at the top: ``` status .......... 2 (No such file or directory) ->message ....... "t_nosuch.txt" ->stacktracebuf . libakstdlib/src/stream.c:aksl_remove:516: 2 (No such file or directory) : t_nosuch.txt SCRATCH could not delete "t_nosuch.txt" exp1.c:dispatch:35 exp1.c:run:42 ``` One caveat I did find, small and libakerror's: `HANDLE`/`HANDLE_GROUP`/`HANDLE_DEFAULT` reset `stacktracebufptr` to the start of the buffer without clearing it, so an annotation *looks* intact after a `HANDLE` but the next append overwrites it from offset 0. **On `NULL` means it worked.** Also right, and I should not have written otherwise. But testing it and *disposing of the context* are different problems, and that is where the actual bug is. This compiles under `-Wall -Wextra` with no `-Wunused-result` warning, because the `NULL` test counts as using the value: ```c static bool scratch_ok(const char *name) { return aksl_remove(name) == NULL; } ``` and it holds a pool slot per failure. Measured, 200 consecutive failures each: | idiom | result | |---|---| | `RELEASE_ERROR(e)` | pool returns to 0 | | `e->handled = true; IGNORE(akerr_release_error(e))` | pool returns to 0 | | `return aksl_remove(name) == NULL;` | leaks, `exit(1)` on the 128th | | `(void)aksl_remove(name);` | leaks, `exit(1)` on the 128th | | `IGNORE(aksl_remove(name));` | leaks, `exit(1)` on the 128th | `sizeof(akerr_ErrorContext)` is 37296 and the pool is 128 slots, so that is 4.77 MB going dry and `ENSURE_ERROR_READY` calling `exit(1)`. **The macro named `IGNORE` is the one that kills you**, it appears nowhere in libakerror's README or `docs/`, and `docs/uncaught-errors.md:17` recommends the `(void)` cast — which gcc does not honour for `warn_unused_result` and which leaks too. I rebuilt libakstdlib against libakerror **#21** and it fixes the `IGNORE` row cleanly (pool stays at 0 across 200). The `(void)` row is unfixable and stays fatal. **On "setting and reading strings is a dumb way of handling errors."** Agreed, and I think the sharper version is that at these sites you are not actually missing a structured channel — you already have one and akbasic already uses it. `akbasic/src/error.c:20-37` reserves a status range and registers eight `AKBASIC_ERR_*` names. And `aksl_remove` raises `AKSL_ERRNO_OR(AKERR_IO)` with `"%s", pathname`, so the context arrives carrying `->status == ENOENT`, `->message == "t_nosuch.txt"`, `->function == "aksl_remove"`. Every part of `SCRATCH could not delete "t_nosuch.txt"` except the verb's own name is already in a machine-readable field, and the verb name is in the frame above. What makes the string feel load-bearing is only that `akbasic/src/runtime.c:472,495,1275,1376,1818` render `->message` alone. Going further — typed `(kind, value)` payload slots — costs either more of a struct that is 37 KB *because* everything is a fixed string buffer, or the first heap allocation on a path that is currently allocation-free. I do not think that trade pays at this size. **What I think should happen, and the only thing I need a ruling on:** close #37, and let me file two against libakerror — (1) document the discard contract, since the failure mode is a process death at the 128th error rather than a compile error, and (2) the `HANDLE` cursor-reset caveat. #21 already covers the third. Say the word and I will file them; I have not touched anything yet. **#38 is affected too.** Its "a `bool` predicate has no way to call one" framing is wrong the same way — a `bool` predicate can call a wrapper and `RELEASE_ERROR` the context. What survives there is different and I still think worth deciding: the cost in `scanner.c`'s per-character loop, and that a `bool` return has nowhere to distinguish "not equal" from "the comparison failed". I would narrow #38 rather than close it, but I have left it untouched pending your call.
Owner

@tachikoma

But testing it and disposing of the context are different problems, and that is where the actual bug is.

There is no bug. PASS() and CATCH() already handle all of this complexity. If it's NULL, nothing happens. If it's non-NULL, it goes to a HANDLE {} block, and releasing happens there, without user intervention. There is no leak.

Where are you getting this? Unless you can point me to something akbasic (or some other) consumer in doing in this regard that creates a demonstrable leak, I'm going to close this.

@tachikoma > But testing it and disposing of the context are different problems, and that is where the actual bug is. There is no bug. `PASS()` and `CATCH()` already handle all of this complexity. If it's NULL, nothing happens. If it's non-NULL, it goes to a `HANDLE {}` block, and releasing happens there, without user intervention. There is no leak. Where are you getting this? Unless you can point me to something akbasic (or some other) consumer in doing in this regard that creates a demonstrable leak, I'm going to close this.
tachikoma changed title from A caller CAN annotate and CAN test success; the real hazard is that discarding a context leaks the pool to Invalid for libakstdlib: PASS/CATCH do release; the one demonstrable leak is libakerror's IGNORE(), fixed by libakerror #21 2026-08-03 14:46:58 -04:00
Author
Collaborator

@andrew You are right, and this ticket should be closed. PASS and CATCH do release without user intervention — I read the expansions this time: DETECT is guarded by if (e != NULL), HANDLE* sets handled, and FINISH ends in RELEASE_ERROR. The idioms I measured last time were ones I wrote for the measurement. No consumer writes them, and that made the whole table an argument about nothing. Body rewritten to say so; nothing in libakstdlib changes.

You asked me to point at a consumer doing this. There is one, and it is on neither of the paths you described — it is IGNORE(), which logs the context and does not release it:

#define IGNORE(__stmt)                          \
    __akerr_last_ignored = __stmt;              \
    if ( __akerr_last_ignored != NULL ) {       \
        LOG_ERROR_WITH_MESSAGE(__akerr_last_ignored, "** IGNORED ERROR **"); \
    }

The search. grep -rn "IGNORE(" src/ include/ tests/ | grep -v NOIGNORE on akbasic 330d731, then the same over libakstdlib's src/ and tests/, plus (void)-cast, bare-expression and == NULL-in-a-bool-function sweeps over both. libakstdlib: no IGNORE() at all, no (void) discards, nothing. akbasic: nine sites wrap an aksl_* call in IGNOREsrc/main.c:207, src/runtime_disk.c:64,347,349,428,476,677, src/runtime_commands.c:654,705.

The reason libakstdlib has none is written down in libakstdlib. src/stdlib.c:1294, in the tree traversal's CLEANUP:

... it is logged and dropped -- but dropped by hand rather than with IGNORE(), which logs the context and then never releases it back to the pool.

So this is your own finding, already acted on in one repo and not the other.

Measured on a stock build — akbasic 330d731, its pinned submodules, cmake -S . -B build && cmake --build build, no patches. /dev/full stands in for the full disk that akbasic_cmd_dsave's own comment names as the case it was changed to report:

10 REM SAVE ONTO A DISK THAT IS ALWAYS FULL
20 FOR I# = 1 TO 200
30 DSAVE "/dev/full"
40 NEXT I#
50 PRINT "SURVIVED 200 DSAVES"

128 ** IGNORED ERROR ** 28 (No space left on device) lines, then symtab.c:akbasic_symtab_get:110: Unable to pull an error context from the array! and exit 1 on the 129th iteration. Line 50 never runs. COPY "f.txt","/dev/full" x200 does the same thing at runtime_disk.c:349 — 128, then dead.

Your own open PR fixes it. Rebuilding that same akbasic against libakerror #21 (4fe7571), which adds one RELEASE_ERROR line to IGNORE: both programs survive all 200, log all 200, exit 0. akbasic's suite is 112/112 against that branch. So the whole action here is merging #21 — and closing this ticket, which never had anything to do with libakstdlib.

Two unrelated things I tripped over in libakerror while checking, not a defence of anything above: docs/uncaught-errors.md:17 says a (void) cast is the by-design way to ignore a warn_unused_result return, but gcc 15.2 warns on it anyway (that spelling is clang's), and it holds a pool slot besides. And HANDLE/HANDLE_GROUP/HANDLE_DEFAULT reset stacktracebufptr to offset 0 without clearing the buffer, so inside a handler an annotation still reads intact but the next append writes over it from the start.

@andrew You are right, and this ticket should be closed. `PASS` and `CATCH` do release without user intervention — I read the expansions this time: `DETECT` is guarded by `if (e != NULL)`, `HANDLE*` sets `handled`, and `FINISH` ends in `RELEASE_ERROR`. The idioms I measured last time were ones I wrote for the measurement. No consumer writes them, and that made the whole table an argument about nothing. Body rewritten to say so; nothing in libakstdlib changes. You asked me to point at a consumer doing this. There is one, and it is on neither of the paths you described — it is `IGNORE()`, which logs the context and does not release it: ```c #define IGNORE(__stmt) \ __akerr_last_ignored = __stmt; \ if ( __akerr_last_ignored != NULL ) { \ LOG_ERROR_WITH_MESSAGE(__akerr_last_ignored, "** IGNORED ERROR **"); \ } ``` **The search.** `grep -rn "IGNORE(" src/ include/ tests/ | grep -v NOIGNORE` on akbasic `330d731`, then the same over libakstdlib's `src/` and `tests/`, plus `(void)`-cast, bare-expression and `== NULL`-in-a-`bool`-function sweeps over both. libakstdlib: no `IGNORE()` at all, no `(void)` discards, nothing. akbasic: **nine** sites wrap an `aksl_*` call in `IGNORE` — `src/main.c:207`, `src/runtime_disk.c:64,347,349,428,476,677`, `src/runtime_commands.c:654,705`. The reason libakstdlib has none is written down in libakstdlib. `src/stdlib.c:1294`, in the tree traversal's `CLEANUP`: > ... it is logged and dropped -- but dropped by hand rather than with `IGNORE()`, which logs the context and then never releases it back to the pool. So this is your own finding, already acted on in one repo and not the other. **Measured on a stock build** — akbasic `330d731`, its pinned submodules, `cmake -S . -B build && cmake --build build`, no patches. `/dev/full` stands in for the full disk that `akbasic_cmd_dsave`'s own comment names as the case it was changed to report: ```basic 10 REM SAVE ONTO A DISK THAT IS ALWAYS FULL 20 FOR I# = 1 TO 200 30 DSAVE "/dev/full" 40 NEXT I# 50 PRINT "SURVIVED 200 DSAVES" ``` 128 `** IGNORED ERROR ** 28 (No space left on device)` lines, then `symtab.c:akbasic_symtab_get:110: Unable to pull an error context from the array!` and exit 1 on the 129th iteration. Line 50 never runs. `COPY "f.txt","/dev/full"` x200 does the same thing at `runtime_disk.c:349` — 128, then dead. **Your own open PR fixes it.** Rebuilding that same akbasic against libakerror **#21** (`4fe7571`), which adds one `RELEASE_ERROR` line to `IGNORE`: both programs survive all 200, log all 200, exit 0. akbasic's suite is 112/112 against that branch. So the whole action here is merging #21 — and closing this ticket, which never had anything to do with libakstdlib. Two unrelated things I tripped over in libakerror while checking, not a defence of anything above: `docs/uncaught-errors.md:17` says a `(void)` cast is the by-design way to ignore a `warn_unused_result` return, but gcc 15.2 warns on it anyway (that spelling is clang's), and it holds a pool slot besides. And `HANDLE`/`HANDLE_GROUP`/`HANDLE_DEFAULT` reset `stacktracebufptr` to offset 0 without clearing the buffer, so inside a handler an annotation still reads intact but the next append writes over it from the start.
Sign in to join this conversation.