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:
150
README.md
150
README.md
@@ -132,7 +132,7 @@ What that covers:
|
||||
|
||||
* **The error pool.** Finding a free slot in `AKERR_ARRAY_ERROR` and taking its
|
||||
reference is one operation under a lock, so two threads can never be handed
|
||||
the same context. A context is then owned by the thread that raised it, all
|
||||
the same context. A context is then owned by exactly one thread at a time, all
|
||||
the way through `CATCH`, `HANDLE`, and release.
|
||||
* **The status registry.** Reservations and name registrations are serialized
|
||||
against each other and against lookups. Two threads reserving the same range
|
||||
@@ -141,11 +141,22 @@ What that covers:
|
||||
* **Per-thread state.** The context behind `IGNORE` (`__akerr_last_ignored`) and
|
||||
the last-ditch context used to report `akerr_release_error(NULL)` are
|
||||
thread-local, so one thread's ignored error is never another's.
|
||||
* **Handing a context from one thread to another.** A context is not thread
|
||||
state — it lives in `AKERR_ARRAY_ERROR`, which is process-global — so it
|
||||
outlives the thread that raised it. 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.
|
||||
Raise on a worker, queue it, let the worker exit; the collector still has a
|
||||
whole error with a whole stack trace. See
|
||||
[Handing an error to another thread](#handing-an-error-to-another-thread).
|
||||
|
||||
What it does not cover, and cannot:
|
||||
|
||||
* **Sharing one error context between threads.** The library hands a context to
|
||||
one thread; passing it to another is your synchronization to do.
|
||||
* **Two threads inside one context at the same time.** A context has one owner,
|
||||
and only one. `FAIL` rewrites the message, `HANDLE` rewinds the stack-trace
|
||||
cursor, and none of that is locked — two threads in one context splice their
|
||||
messages together and truncate each other's trace, without crashing. Handing a
|
||||
context *from* one thread *to* another is a different thing, and is supported.
|
||||
* **`akerr_log_method` and `akerr_handler_unhandled_error`.** Set them during
|
||||
startup, before you spawn threads. They are read on every error and the
|
||||
library never writes them after initialization, so setting one while other
|
||||
@@ -176,6 +187,135 @@ consumer can check what it linked against:
|
||||
#endif
|
||||
```
|
||||
|
||||
## Handing an error to another thread
|
||||
|
||||
**Transfer** an error context; never **share** one. One thread owns it at a time,
|
||||
and ownership moves in a single step: the thread giving it up stops touching it
|
||||
in the same act that makes it visible to the thread taking it over.
|
||||
|
||||
This works because a context is not thread state. It lives in
|
||||
`AKERR_ARRAY_ERROR`, which is process-global, so a context outlives the thread
|
||||
that raised it — a worker can raise an error, queue it, and exit, and the
|
||||
collector still has a whole error with a whole stack trace. Nothing in the
|
||||
library reads a context through any pointer but the one its current owner handed
|
||||
in. The one field it reads across threads is the reference count, and that is
|
||||
only ever touched under the pool lock, so `akerr_release_error()` does not care
|
||||
which thread checked the slot out. Release it wherever it ended up.
|
||||
|
||||
**Always** hand it over through something that synchronizes: a mutex, a
|
||||
condition variable, `pthread_join`, or an acquire/release atomic. The content of
|
||||
a context is written with no lock at all — that is deliberate, error
|
||||
construction should not pay for a lock it does not need — so the handoff itself
|
||||
is what publishes those writes. Push the pointer through a relaxed atomic or a
|
||||
plain global and the receiver can read a half-written message, on a machine you
|
||||
did not test on.
|
||||
|
||||
**Never** touch a context after you have given it away. Not a `->status`, not a
|
||||
log line. The receiver may be inside `HANDLE` rewinding the trace cursor, or
|
||||
inside `akerr_release_error()` memsetting the slot.
|
||||
|
||||
**Release it exactly once.** A context released twice from a stale pointer takes
|
||||
the refcount-zero branch a second time and wipes the slot again — which by then
|
||||
holds somebody else's live error. Nothing crashes; a different thread's error
|
||||
just quietly goes blank.
|
||||
|
||||
```c
|
||||
/* Producer. Owns the context until queue_push() returns, and not after. */
|
||||
static void report_unit_failure(int unit)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
FAIL(e, MYLIB_UNIT_FAILED, "unit %d stopped responding", unit);
|
||||
/* The last thing this thread does to it, so the trace records the crossing.
|
||||
The buffer belongs to the context, so it travels with it. */
|
||||
AKERR_STACKTRACE_APPEND(e, "%s:%s:%d: queued for the collector\n",
|
||||
__FILE__, __func__, __LINE__);
|
||||
queue_push(e); /* takes the queue mutex; `e` is not ours after this */
|
||||
}
|
||||
|
||||
/* Collector. Owns it from the moment queue_pop() returns. */
|
||||
static void collect_one(akerr_ErrorContext *e)
|
||||
{
|
||||
ATTEMPT {
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} HANDLE(e, MYLIB_UNIT_FAILED) {
|
||||
restart_unit(e);
|
||||
} HANDLE_DEFAULT(e) {
|
||||
LOG_ERROR_WITH_MESSAGE(e, "collector: unrecognized failure");
|
||||
} FINISH_NORETURN(e);
|
||||
}
|
||||
|
||||
static void *collector(void *unused)
|
||||
{
|
||||
akerr_ErrorContext *e;
|
||||
|
||||
(void)unused;
|
||||
while ( (e = queue_pop()) != NULL ) {
|
||||
collect_one(e);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
```
|
||||
|
||||
Four things about the receiving side:
|
||||
|
||||
* **Declare a plain pointer, not `PREPARE_ERROR`.** That macro *declares* a
|
||||
fresh context variable set to `NULL`; it cannot adopt one. For the same
|
||||
reason, never `CATCH` into the variable holding a received context — `CATCH`
|
||||
assigns over it, and the slot you were handed is gone.
|
||||
* **In a `void` helper, use `FINISH_NORETURN`.** `FINISH_LOGIC` decides whether
|
||||
to propagate at run time, so the compiler still *parses* its
|
||||
`return __err_context` even when the second argument is the literal `false`.
|
||||
`FINISH(e, false)` in a function returning void therefore draws
|
||||
`warning: 'return' with a value, in function returning void` from gcc — a
|
||||
constraint violation, and a build failure under `-Werror`. To propagate
|
||||
inside the collector's own call stack, give the
|
||||
helper an `akerr_ErrorContext *` return and use `FINISH(e, true)` as usual —
|
||||
just never let an error propagate out of the thread body itself, whose
|
||||
`void *` return nobody reads. `PASS` has the same problem for the same reason:
|
||||
in a thread body it compiles, hands the pointer back as a `void *`, and leaks
|
||||
the slot.
|
||||
* **`FINISH_NORETURN(e)` on a received context still terminates the process.**
|
||||
That is right — an unhandled error is unhandled wherever it was raised — but
|
||||
it is now the collector's thread deciding the exit status, not the raiser's.
|
||||
* **Give the handler blocks their own function.** `ATTEMPT` is a `switch`, and a
|
||||
`break` inside one written directly in a loop leaves the `switch`, not the
|
||||
loop. Same hazard as
|
||||
[do not use CATCH or FAIL_*_BREAK inside a loop](#important-do-not-use-catch-or-fail__break-inside-a-loop).
|
||||
|
||||
**Always** bound the queue. A queued error is a checked-out pool slot, and there
|
||||
are `AKERR_MAX_ARRAY_ERROR` (128) of them in the entire process. Size *queue
|
||||
depth + producers with an error in flight* well under that. When the pool runs
|
||||
dry the library logs and calls `exit(1)` from inside `FAIL` — there is no slot
|
||||
left to raise the failure *from*, which is exactly why a collector that stops
|
||||
draining takes the process with it.
|
||||
|
||||
### If you need to keep it as well as report it
|
||||
|
||||
There is no copy or retain call, on purpose: a context is a pool slot, and two
|
||||
owners of one slot is the thing this whole section exists to prevent. So either
|
||||
read out what you want to keep — `status` is an `int`, `message` and
|
||||
`stacktracebuf` are ordinary NUL-terminated strings you can `snprintf` into your
|
||||
own, much smaller, record — or raise two errors and hand one of them over.
|
||||
|
||||
**Never** copy an `akerr_ErrorContext` by assignment and keep the copy:
|
||||
|
||||
```c
|
||||
akerr_ErrorContext snapshot = *failed; /* looks fine. is not. */
|
||||
```
|
||||
|
||||
`stacktracebufptr` points into the context's *own* `stacktracebuf`, so after that
|
||||
assignment `snapshot`'s cursor still points into `failed`'s buffer.
|
||||
`LOG_ERROR(&snapshot)` reads the array and prints correctly, so it looks healthy
|
||||
— and then the first `AKERR_STACKTRACE_APPEND(&snapshot, ...)` writes into a
|
||||
pool slot you no longer own, arbitrarily far from the copy. `arrayid` has the
|
||||
same shape: it is restored after the wipe, so a copied id makes the destination
|
||||
impersonate the source's slot forever. And the copy is not a pool address, so
|
||||
`akerr_valid_error_address()` rejects it, every `CATCH` on it becomes
|
||||
`AKERR_BADEXC`, and releasing it memsets your own storage while the real slot
|
||||
stays checked out for the life of the process.
|
||||
|
||||
## Building single threaded
|
||||
|
||||
The threading backend is chosen when libakerror is configured. `auto` (the
|
||||
@@ -195,7 +335,9 @@ one thread is then undefined.
|
||||
|
||||
The thread tests (`tests/err_threads_*.c`) assert the properties above directly:
|
||||
exclusive ownership of pool slots, exactly one winner for a contested range,
|
||||
every registered name readable back. They run in the normal suite. The run that
|
||||
every registered name readable back, and — in `err_threads_handoff.c` — an error
|
||||
raised on one thread arriving whole on another and released there. They run in
|
||||
the normal suite. The run that
|
||||
proves the *absence* of a data race underneath them is ThreadSanitizer:
|
||||
|
||||
```sh
|
||||
|
||||
Reference in New Issue
Block a user