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

@@ -95,11 +95,20 @@ Safe from any thread, with no coordination on your part:
* `akerr_name_for_status(status, NULL)` lookups, concurrently with each other
and with registrations of *other* statuses.
* `akerr_init()`, from any number of threads at once.
* **Handing a context to another thread, and releasing it there.** The reference
count is the only field the library reads across threads and it is only ever
touched under the pool lock, so `akerr_release_error()` does not care which
thread checked the slot out. Contexts live in process-global storage, not
thread-local, so one outlives the thread that raised it. What is still yours is
the handoff *itself*: it has to carry a happens-before edge, which any mutex,
condvar, `pthread_join` or acquire/release atomic gives you.
Still yours to coordinate:
* **One error context is owned by one thread.** The library hands it to the
thread that raised it. Handing it to another thread is your synchronization.
* **Two threads in one context at once.** Ownership moves; it does not fork.
Hand a context over and stop touching it — the content is written with no
lock, so the handoff is what publishes it. See "Handing an error to another
thread" in README.md for the pattern, and for why the queue has to be bounded.
* **`akerr_log_method` and `akerr_handler_unhandled_error`** are read on every
error and written by nobody but you. Set them during startup, before spawning.
* **Renaming a status while another thread looks it up.**
@@ -128,9 +137,9 @@ library from that thread.
## Proving it
`tests/err_threads_init.c`, `tests/err_threads_pool.c` and
`tests/err_threads_registry.c` assert the properties directly and run in the
normal suite. The run that proves there is no data race underneath them is
`tests/err_threads_init.c`, `tests/err_threads_pool.c`,
`tests/err_threads_registry.c` and `tests/err_threads_handoff.c` assert the
properties directly and run in the normal suite. The run that proves there is no data race underneath them is
ThreadSanitizer:
```sh