Need a way to tell tsan that a signal has been handled properly
- Dominant language
- LLVM
- Stars
- 40.5k
- Forks
- 18.7k
- PR merge metrics
- PR metrics pending
Description
Hi,
There is a proposal before WG14 to modernise signal handling: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3924.htm. Its reference implementation library is at https://github.com/ned14/wg14_signals.
Unfortunately, successful recovery from signal raises triggers a diagnostic from tsan because it doesn't know that the signal has been handled and it thinks the program is still running within an async signal handler.
We need some way to tell tsan that we have exited the async signal handler and to please no longer issue the diagnostic.
There is also a minor issue that longjmp isn't resetting the signal mask properly, which breaks semantics.
I paste a LLM generated issue report below. I don't like to receive those without a human written cover letter myself, so this is the human written cover letter. My thanks in advance for your help.
Regards,
Niall
--- cut ---
# TSan issue report (draft, for submission to llvm/llvm-project)
This is a draft issue report prepared for submission to the LLVM ThreadSanitizer
bug tracker (github.com/llvm/llvm-project, label `compiler-rt:tsan`). It has not
been submitted. Prior-art research was done against the tracker before drafting:
no existing open issue covers exactly this gap, and the closest related issues
are listed under "Related prior art" below.
---
## Title
TSan has no way for a signal-handling library to report that a signal raise has
been handled; recovery by `longjmp` out of the signal handler leaves
`in_signal_handler` and the substituted signal mask stuck
## Summary
The proposed WG14 C signals API (N3924 revision 4; reference implementation
`wg14_signals`, https://github.com/ned14/wg14_signals) recovers from a handled
signal by transferring control **out of the signal handler** via `longjmp` back
to a `setjmp`'d guarded frame. This is a standard-sanctioned pattern
(C11 7.13.2.1, POSIX `siglongjmp`), but TSan has no way to learn that the raise
was handled and that the thread has left signal context.
TSan proxies every user signal handler through its own `sighandler`, which
increments the per-thread `in_signal_handler` counter (and, in the async
delivery path, replaces the thread's signal mask with `internal_sigfillset`).
The matching decrement / mask restore only runs when the user handler *returns*.
When the handler instead `longjmp`s out, TSan's restoration is best-effort and
platform-dependent, so:
- `malloc`/`free`/`printf`/pthread calls made *after* recovery (which are
outside the handler and perfectly legal) are falsely reported as
"signal-unsafe call inside of a signal";
- the substituted signal mask is left installed, which on Linux x86_64 causes a
*later* hardware fault signal to be mishandled (an unhandled SIGFPE that
terminates the process).
The reference implementation currently has to disable the entire
`report_signal_unsafe` report class in its CI and to avoid kernel-delivered
faults under TSan. Both are blunt workarounds for a missing mechanism: **there is
no public sanitizer interface a library can call to tell TSan "this raise has
been handled, the thread has exited signal context"** (see
`compiler-rt/lib/tsan/rtl/tsan_interface.h` — it covers memory accesses, sync,
mutexes, annotations and fibers, but nothing for signal context).
Please add such a mechanism so that TSan's implementation is ready if/when this
standards proposal is adopted.
## Background: the proposed API and the recovery mechanism
The WG14 proposal N3924 (revision 4), "improved C signals handling", adds
thread-local signal guards to C. The reference implementation's key entry points
are:
- `sigguarded(const sigset_t *signals, sig_func_t guarded,
sig_recover_t recovery, sig_decide_t decider, value)` — runs `guarded(value)`
inside a thread-local guard for `signals`. The library installs a filtering
handler (POSIX `sigaction` with `SA_SIGINFO`) / vectored handler (Windows).
- When a guarded signal is raised during `guarded()`, the library's handler runs
the *decider*. If the decider returns `sig_decision_invoke_recovery`, the
library performs `longjmp(frame->buf, 1)` **from inside the kernel-delivered
handler** straight to the `setjmp` in `sigguarded()`, then calls the *recovery*
function. The signal handler never returns.
In the reference implementation this is:
`include/wg14_signals/detail/impl/thrd_signal_handle_posix.c.ipp` — the handler
(`raw_signal_handler`) forwards to `stdc_raise`, which walks the guard frame
stack and, for `sig_decision_invoke_recovery`, executes
`WG14_SIGNALS_LONGJMP(frame->buf, 1)` (the library uses `setjmp`/`longjmp` on
glibc and `_setjmp`/`_longjmp` on Apple/BSD). The recovery function then runs
in the guarded frame, outside any handler.
This "longjmp out of the handler" is exactly the POSIX pattern of recovering
from a synchronous fault (e.g. a `SIGFPE` from an integer divide, or a `SIGSEGV`
from a null store) and continuing normal execution, and it is the core value
proposition of the proposal.
## TSan interaction
TSan intercepts `sigaction`/`signal` and replaces the user's handler with its own
proxy (`tsan_interceptors_posix.cpp`, `sighandler` at ~L2308). The proxy:
1. increments `thr->in_signal_handler` (atomic) — sync path at ~L2322;
2. calls the real handler via `CallUserSignalHandler` (~L2203);
3. decrements `in_signal_handler` (~L2336) and restores state — **only when the
handler returns**.
For asynchronous delivery, `ProcessPendingSignalsImpl` (~L2267) additionally
replaces the thread's signal mask with `internal_sigfillset` via
`pthread_sigmask(SIG_SETMASK, ...)` (~L2273-2275) and restores it afterwards
(~L2285) — again, only when the handler returns.
When the library `longjmp`s out of the handler:
- TSan's `siglongjmp`/`longjmp` interceptors (~L634-660) call `LongJmp`
(~L558-584), which restores `in_signal_handler` **only if** it finds the
`JmpBuf` recorded by an intercepted `setjmp` matching the longjmp target's
stack pointer (the `setjmp`/`_setjmp`/`sigsetjmp`/`__sigsetjmp` asm
interceptors, ~L590-621). If the setjmp was not captured, or the runtime
variant does not match, the counter stays non-zero.
- `LongJmp` pops the internal `oldset` bookkeeping but never calls
`pthread_sigmask` to restore the real OS signal mask that the async path
substituted, so the thread keeps `internal_sigfillset` installed.
With `in_signal_handler` non-zero, every subsequently intercepted
non-async-signal-safe call (e.g. `malloc` at `tsan_mman.cpp` ~L234/L244) invokes
`SignalUnsafeCall` (`tsan_mman.cpp` ~L177-203), which reports
```
WARNING: ThreadSanitizer: signal-unsafe call inside of a signal
```
gated only by the global `report_signal_unsafe` flag (`tsan_flags.inc`).
There is no public `__tsan_*` interface to clear this per-thread signal state.
## Observed symptoms (the reference implementation's CI)
TSan CI (Ubuntu gcc/clang and macOS clang, C11 and C23) runs the full test
suite. Observed, reproducible:
1. **Linux TSan legs**: `thrd_sigfpe_test` and `recovery_null_loop_test` (both of
which recover via longjmp-out-of-handler and then run ordinary library code
such as `signal_decider_create`/`siguninstall` after recovery) produce false
`signal-unsafe call inside of a signal` reports for `malloc`/`free` that run
strictly outside the handler.
2. **Linux x86_64**: after suppressing those reports, `thrd_signal_sigfpe_handle_test`
failed because the *next* hardware `SIGFPE` (from the divide-by-zero the guard
was recovering from) was mishandled and terminated the process with an
unhandled SIGFPE — consistent with TSan's substituted signal mask still being
installed on the thread.
3. **macOS clang legs pass** — there the `_setjmp`/`_longjmp` symbols the library
uses are intercepted and matched, so the counter is restored. This asymmetry
is why the failure only shows up on the Linux legs.
Current workarounds in the repo (`.github/workflows/ci.yml`, `test/thrd_sigfpe_test.c`):
- `TSAN_OPTIONS=... report_signal_unsafe=0` — disables the entire report class,
so real async-signal-safety violations in *user* code are no longer caught;
- `thrd_sigfpe_test.c` detects TSan and raises `SIGFPE` via the library's
`stdc_raise()` (software raise, no kernel-delivered fault) instead of a real
divide-by-zero, sidestepping the stale-mask problem.
Neither is acceptable for a production standard library: the whole point of
running under TSan is to catch async-signal-unsafety, and a proposed standards
API should not have to contort its tests around the sanitizer.
## Requested mechanism
Please add a way for a signal-handling library (or a signal handler) to inform
TSan that a raise has been handled and that the thread has exited signal
context. Suggested shapes, in order of preference:
1. **A public, async-signal-safe sanitizer interface function**, e.g.
`__tsan_signal_handler_exit()` (or `__tsan_on_signal_handled()`), which the
library calls from its recovery path *after* the longjmp (i.e. outside the
handler) to clear `in_signal_handler` and restore the thread's real signal
mask. `tsan_interface.h` already exposes similar stateful toggles
(ignore-reads/writes, thread ignore, fiber switch); a signal-context toggle
is the missing counterpart.
2. **Make the longjmp interceptors unconditionally restore the per-thread
signal state** when the thread is currently inside a signal handler, rather
than only when the matching `setjmp` was registered; and make `LongJmp` /
`ProcessPendingSignalsImpl` restore the actual OS signal mask via
`pthread_sigmask` (not just the internal `oldset` bookkeeping).
Either would make TSan correct for the N3924 pattern today, and would also fix
the same latent issue for any existing code that legitimately longjmps out of a
handler and then allocates (the pattern `compiler-rt/test/tsan/signal_longjmp.cpp`
exists precisely because this is a real-world pattern).
## Reproduction sketch
Minimal (no library needed): install a `SIGSEGV` handler that does
`siglongjmp` to a `sigsetjmp`'d frame in `main`; after the longjmp, call
`malloc(10)` and free it. The existing `compiler-rt/test/tsan/signal_longjmp.cpp`
covers this and passes because its `sigsetjmp` is registered with TSan; the
failure appears when the handler state is not restored (variants: setjmp not
captured, or the async-delivery mask-substitution path).
Library-based: build https://github.com/ned14/wg14_signals with
`-fsanitize=thread` and run `recovery_null_loop_test` / `thrd_sigfpe_test`
without `report_signal_unsafe=0` — the false `signal-unsafe call inside of a
signal` reports reproduce, and on Linux x86_64 the subsequent hardware SIGFPE
terminates the process.
## Related prior art
- **google/sanitizers#482** — "TSAN does not handle siglongjmp(3) jumping out of
signal handler" (closed/fixed). The fix added the `SetJmp`/`LongJmp`
`JmpBuf` mechanism in `tsan_interceptors_posix.cpp` and the
`compiler-rt/test/tsan/signal_longjmp.cpp` regression test. As described
above, that fix only restores `in_signal_handler` when the matching `setjmp`
was captured and does not restore the real signal mask.
- **llvm/llvm-project#134358** and **PR #138599** "[tsan] Fix nested signal
handling" — signal-mask restoration stack for nested handlers (merged 2025-05);
shows TSan's per-thread signal-mask handling is fragile and was already
reworked once.
- **llvm/llvm-project#43170** (ex Bugzilla 43825) — "signal-unsafe call inside
of a signal" false positive in libFuzzer's interrupt handler; still open; the
same report class that the library must currently disable globally.
- **`report_signal_unsafe` flag** (`compiler-rt/lib/tsan/rtl/tsan_flags.inc`) —
the current blunt workaround.
Prior-art search was run against the llvm/llvm-project issue tracker
("signal longjmp", "longjmp out of signal handler", "in_signal_handler",
"signal-unsafe call inside of a signal", "report_signal_unsafe"); no existing
open issue was found for the specific gap described here.
## Environment
- Compiler-rt TSan as shipped with LLVM 17-22 (Ubuntu gcc and clang libtsan,
macOS Apple clang), observed 2026-08.
- Reference implementation: https://github.com/ned14/wg14_signals
(WG14 proposal N3924 rev 4).
- All `compiler-rt` source line references are approximate, from `main`
as of 2026-08.
Contributor guide
Research direction
Start with compiler-rt/lib/tsan/rtl/tsan_interface.h and tsan_interceptors_posix.cpp, especially sighandler, LongJmp, and ProcessPendingSignalsImpl; run compiler-rt/test/tsan/signal_longjmp.cpp and the reported wg14_signals reproductions. Done means TSan no longer falsely reports calls after recovery and restores the real signal mask after longjmp, with regression coverage.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- compilers, testing-qa
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100