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

View File

@@ -159,6 +159,11 @@ typedef struct
typedef void (*akerr_ErrorUnhandledErrorHandler)(akerr_ErrorContext *errctx);
typedef void (*akerr_ErrorLogFunction)(const char *f, ...);
/*
* The pool. Process-global, not thread-local: a context outlives the thread that
* raised it, which is what lets one be handed to another thread and released
* there.
*/
extern akerr_ErrorContext AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR];
/*
* Set these before starting threads. They are read on every error and written
@@ -174,6 +179,17 @@ extern akerr_ErrorLogFunction akerr_log_method;
*/
extern AKERR_THREAD_LOCAL akerr_ErrorContext *__akerr_last_ignored;
/*
* Drop one reference, returning NULL once the last one is gone so the caller can
* null its own pointer.
*
* This need not be the thread that checked the context out. The reference count
* is the only field the library reads across threads, and it is only ever
* touched under the pool lock, so a context handed to another thread is released
* there. Exactly once, though: releasing a stale pointer takes the
* refcount-zero branch a second time and wipes a slot that by then holds
* somebody else's live error.
*/
akerr_ErrorContext AKERR_NOIGNORE *akerr_release_error(akerr_ErrorContext *ptr);
/*
* Check a context out of the pool. The returned context already carries one