Document and test handing an error context between threads

The thread-safety section filed two different things under "does not cover,
and cannot": sharing a context between threads, and passing one to another
thread. Only the first is unsupported. Transfer already works by
construction -- the reference count is the only field the library reads
across an ownership boundary, and it is only ever touched under the pool
lock, so akerr_release_error() does not care which thread checked the slot
out. The pool is process-global, not thread-local, so a context outlives the
thread that raised it.

Calling that unsupported told readers the worker/collector shape was off the
table, which either cost them the pattern or cost them the stack trace when
they rolled their own struct instead.

Split the bullet: transfer joins the covered list and gets its own section
with the rule, the worked pattern, and the four receiving-side hazards
(PREPARE_ERROR cannot adopt, CATCH assigns over the pointer, FINISH in a
void helper still parses its return, and an unhandled error now terminates
from the collector's thread). Sharing keeps the "cannot" bullet, narrowed to
what it actually is.

err_threads_handoff.c proves it: the existing thread tests all keep every
context on the thread that raised it, so the transfer path was exercised
nowhere. Seven producers hand errors to one collector through a bounded
mutex/condvar queue -- the mutex is the thing under test, since it is what
publishes the unlocked content writes -- and the collector asserts the
context is still a live slot at refcount 1, that message and trace arrive
whole and in each producer's order, that the slot was never recycled in
flight, and that a thread which never called akerr_next_error() can release
it. A second phase reads a context whose raising thread has already exited.

Also document why copying a context by assignment is silently wrong:
stacktracebufptr is self-referential, so the copy's cursor points into the
source's buffer and the first append corrupts a slot the copier no longer
owns. TODO.md records the akerr_copy_error() shape that would fix it and the
trigger for building it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:35:18 -04:00
parent 076b80c846
commit caef63826f
6 changed files with 468 additions and 9 deletions

37
TODO.md
View File

@@ -112,6 +112,43 @@ can be configured with `-DAKERR_SANITIZE=thread`. The whole run then costs a
TSan-instrumented suite per mutant (roughly 6s instead of 0.4s), so it belongs
behind a flag rather than in the default target or in CI.
## 9. No way to keep an error context and report it at the same time
A context can be handed to another thread and released there -- `README.md` now
documents that pattern and `tests/err_threads_handoff.c` proves it -- but it is a
*move*. A thread that wants to both keep its error and report it upward has to
read the fields out into its own record, and it loses the stack trace doing so,
because `stacktracebuf` is the one thing that cannot be usefully summarized.
Copying the struct is not a workaround. `stacktracebufptr` is self-referential
(`include/akerror.tmpl.h`), so `akerr_ErrorContext c = *src;` leaves the copy's
cursor pointing into the source's buffer -- the copy logs correctly and then
corrupts a slot it does not own the first time anything appends to it. `arrayid`
is restored after the wipe in `akerr_release_error()` (`src/error.c:395-398`), so
a copied id makes the destination impersonate the source's slot for the life of
the process.
If this is ever worth an API, the shape is:
```c
akerr_ErrorContext AKERR_NOIGNORE *akerr_copy_error(akerr_ErrorContext *source,
akerr_ErrorContext *destination);
```
taking the destination as a parameter rather than allocating it. An allocating
copy could fail on pool exhaustion, and reporting *that* failure needs a pool
slot, so it would have to abort -- adding a third `exit()` site to a library that
deliberately has two. Caller-allocates puts the pool pressure where it can be
managed. The copy must repair four fields: `arrayid` (the destination's own),
`refcount` (set to 1, never inherited), `handled` (false -- a copy is a fresh
obligation, or `FINISH_NORETURN` on the receiving side drops it silently), and
`stacktracebufptr` (re-anchored to the destination's buffer at the *same offset*,
so a later append continues the trace instead of overwriting it).
Not worth building yet: no consumer needs it. The trigger is a consumer that
needs a worker's stack *trace*, not just its status and message, at the join
point -- `libakstdlib`'s planned `pthread_*` wrappers are the likely first.
## Unrelated pre-existing issues
- The `AKERR_USE_STDLIB=OFF` build does not compile at all: `bool`, `PATH_MAX`