REV-001 — Task Panic Counter Skew Contract (ADR) #
Status: Accepted (contract + observability) Severity: Medium GitLab issue: #35 Closed: 2026-04-16
Problem #
Core.runTask increments counters.active_tasks and (optionally)
sets descriptor.claimed_by / descriptor.current_task_started_ns
before invoking user code, and decrements / clears them after the
invocation returns. If the user's task_fn panics between those two
points, Zig's default panic handler aborts the process. That happens to
be the desired outcome for a production pool — a panicking task leaves
the whole process in an undefined state — but there is a narrow window
where a caller with a custom panic handler (one that catches the panic
and returns to the scheduler) would observe skewed counters and a
descriptor slot that never gets released:
counters.active_tasksstays incremented →shutdown(.drain, …)spins until its timeout.descriptor.claimed_bystays set → the nextacquireDescriptorthat hits this slot would see a stale worker index and (in Debug /ReleaseSafe) panic the double-dequeue sentinel.WaitGroup.pendingstays positive → a caller'swg.wait()hangs.
Why not a try/catch? #
Zig panics are not catchable by design. The language deliberately
does not provide a try/catch equivalent for @panic / integer overflow
/ OOB access — those signal a bug in the program, not an error value.
Wrapping task_fn in try is a no-op because user code returns void,
not an error union. The only way to "recover" from a panic is to
install a custom panic handler, which is a process-global concern and
explicitly out of scope for a thread-pool library.
Any hot-path instrumentation we could add (errdefer-style cleanup
around task_fn.?(…)) would add a write to a cacheline the hot path
already dominates — the 5–12% throughput regression budget for SEC-018
and F-002 is already spent, and a recover() shim that never fires in
production is a bad trade.
Decision #
-
Contract. The pool's public contract is "a panicking
task_fnis a caller bug that aborts the process." Callers must not install a custom panic handler that attempts to recover and resume into pool state. This is documented onConfig.timestamp_modeand reinforced indocs/DESIGN.md(see "Task panic behaviour"). -
Observability.
statsSnapshot()computes a non-authoritativeaccounting_skew_detectedflag that istruewhenactive_tasks > submitted - completed - cancelledat snapshot time. This is a best-effort divergence signal — counters are read with.acquirebut not atomically as a group, so a snapshot taken mid- submit will naturally showactive_tasks > submitted - completedfor a brief window. Operators should treat a persistent skew (multiple snapshots 100ms apart showing the same inequality) as evidence of a panic-recovered task. -
No hot-path code change. The fix is contract-only. Adding a
panic_observed_countcounter was considered and rejected — there is no way to reach it from inside a panicking frame without a custom panic handler, which we do not own.
Non-goals #
- Surviving a panicking task. A panic is terminal; the pool aborts.
- Guaranteeing
WaitGroup.pendingdecrements under panic. Callers who want that must wrap their task body in a defer. - Catching integer-overflow / OOB / unreachable panics. Same reasoning.
Related #
src/types.zig:TimestampMode,Config,WaitGroupall gain/// # Task panic behaviourdoc sections pointing back to this file.src/pool.zig:Core.runTaskhas a top-of-function comment describing the skew hazard and the contract.docs/DESIGN.md: high-level contract section.