Skip to content

fiber: fix TSan SEGV on fiber switch (func-entry/exit shadow-stack underflow) (#134)#197

Merged
chen3feng merged 1 commit into
masterfrom
tsan-fiber-disable-instrumentation
Jun 18, 2026
Merged

fiber: fix TSan SEGV on fiber switch (func-entry/exit shadow-stack underflow) (#134)#197
chen3feng merged 1 commit into
masterfrom
tsan-fiber-disable-instrumentation

Conversation

@chen3feng

Copy link
Copy Markdown
Collaborator

Fixes the TSan half of #134 (the ASan half landed in #196).

Symptom

bazel test --config=tsan //flare/fiber:* (clang) crashes on the first fiber resume:

ThreadSanitizer: SEGV ... in __tsan_func_entry
  <- flare::fiber::detail::FiberProc   (first instruction of a freshly-started fiber)

Root cause (traced with gdb)

TSan keeps a per-fiber shadow stack of call frames: __tsan_func_entry pushes, __tsan_func_exit pops, and __tsan_switch_to_fiber makes a given fiber's shadow stack the active one.

Tracing shadow_stack_pos (ThreadState+16) across the switch:

switch fiber=0x…3800  pos=0x…f000     ← shadow_stack base (fresh fiber, empty)
CRASH:  fiber=0x…3800  pos=0x…eff8     ← base − 8  (UNDERFLOW)

flare::internal::tsan::SwitchToFiber calls __tsan_switch_to_fiber from inside an instrumented function. So the sequence is:

  1. __tsan_func_entry → push onto the old fiber's shadow stack
  2. __tsan_switch_to_fiber(new) → active stack = the new fiber's (empty, pos = base)
  3. __tsan_func_exit (the wrapper's own, on return) → pop the new fiber's empty stack → pos = base − 8
  4. jump to FiberProc__tsan_func_entry writes at base − 8 (unmapped) → SEGV

Two things this rules out, both verified:

  • Not stack size (crashes identically 128 KiB → 8 MiB) and not arch (x86_64 in the report, aarch64 locally).
  • Plain [[no_sanitize("thread")]] does NOT fix it — it disables the data-race read/write checks but keeps the func-entry/exit hooks, so the underflow remains. (-fno-sanitize-thread-func-entry-exit does fix it, confirming the mechanism.)

Fix

Add FLARE_INTERNAL_DISABLE_SANITIZER_INSTRUMENTATION ([[clang::disable_sanitizer_instrumentation]], which strips all hooks incl. func-entry/exit — unlike no_sanitize) and apply it to the functions that bracket a raw context switch:

  • flare::internal::tsan::SwitchToFiber (the wrapper around __tsan_switch_to_fiber)
  • FiberEntity::Resume, FiberEntity::ResumeOn (perform the jump_context)
  • FiberProc (fiber entry trampoline)

This keeps full TSan instrumentation — including call stacks in race reports — everywhere else, unlike the global -fno-sanitize-thread-func-entry-exit flag. A GCC 12+ spelling is provided; on older GCC it expands to nothing (those release builds are non-sanitized, so it's harmless; TSan-with-fibers under GCC < 12 is simply unsupported).

Verification (clang-17, Ubuntu 22.04 container)

All pass under --config=tsan, on both x86_64 (the report's arch) and aarch64:

  • future_test, fiber_test, timer_test, this_fiber_test

ASan (#196) and the normal non-sanitized build are unaffected (re-verified).

Follow-up

With #196 + this, both ASan and TSan fiber tests pass under sanitizers. flare's CI is currently blade-based; a --config=asan / --config=tsan bazel lane over //flare/fiber:... would be a good regression guard — happy to add one if you'd like.

🤖 Generated with Claude Code

…ks (#134)

Under TSan with clang, `bazel test --config=tsan //flare/fiber:*` crashes on
the first fiber resume:

    ThreadSanitizer: SEGV ... in __tsan_func_entry
      <- FiberProc (first instruction of a freshly-started fiber)

Root cause (traced with gdb): TSan keeps a per-fiber "shadow stack" of call
frames; `__tsan_func_entry` pushes and `__tsan_func_exit` pops it.
`flare::internal::tsan::SwitchToFiber` calls `__tsan_switch_to_fiber`, which
makes the *target* fiber's shadow stack active. But that call sits inside an
instrumented function, so the function's own injected `__tsan_func_exit` runs
*after* the switch -- popping the just-activated, and for a brand-new fiber
empty, shadow stack. shadow_stack_pos underflows to base-8; the next
`__tsan_func_entry` (at FiberProc) writes below the mapped region and SEGVs.

Confirmed: `-fno-sanitize-thread-func-entry-exit` (which removes those hooks)
makes it pass. Crucially, plain `[[no_sanitize("thread")]]` does NOT fix it --
it disables the data-race read/write checks but keeps the func-entry/exit
hooks, so the underflow remains.

Fix: add `FLARE_INTERNAL_DISABLE_SANITIZER_INSTRUMENTATION`
(`[[clang::disable_sanitizer_instrumentation]]`, which strips ALL hooks
including func-entry/exit) and apply it to the functions that bracket a raw
context switch: the `SwitchToFiber` wrapper, `FiberEntity::Resume`,
`FiberEntity::ResumeOn`, and the `FiberProc` entry trampoline. This keeps full
TSan instrumentation (and call stacks in race reports) everywhere else, unlike
the global compile flag. GCC has no such attribute (only Clang), so the macro
expands to nothing there -- GCC builds are non-sanitized, and TSan-with-fibers
is a Clang-only configuration in practice.

Verified with clang-17 under `--config=tsan` on both x86_64 and aarch64:
future_test / fiber_test / timer_test / this_fiber_test all pass. ASan (#196)
and the normal non-sanitized build are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chen3feng chen3feng force-pushed the tsan-fiber-disable-instrumentation branch from 65a327e to 491b65e Compare June 18, 2026 17:08
@chen3feng chen3feng merged commit 88f15ec into master Jun 18, 2026
8 checks passed
@chen3feng chen3feng deleted the tsan-fiber-disable-instrumentation branch June 18, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant