Skip to content

handleSignal is not async-signal-safe: lazy _Thread_local read re-enters malloc and aborts with _os_unfair_lock_recursive_abort, masking the real crash #8134

Description

@maxkupetskii

Platform

iOS

Environment

Production

Installed

Swift Package Manager

Other Error Monitoring Solution

Yes

Other Error Monitoring Solution Name

Native app store crash reports

Version

9.15.0

Xcode Version

26.2

Did it work on previous versions?

No response

Steps to Reproduce

Hard to force deterministically (it requires a fatal signal to land while the same thread is inside malloc/realloc and has not yet instantiated tl_ignoreSignum), but it occurs in the field on iOS 26. A targeted repro:

  1. On a fresh thread that has never entered handleSignal, hold the allocator lock (be inside a large realloc).
  2. Deliver a fatal signal to that thread at that instant.
  3. Observe the abort at _os_unfair_lock_recursive_abort instead of a normal crash report.

Expected Result

The handler does not allocate; the original signal is captured/reported faithfully.

Actual Result

The handler allocates via lazy _Thread_local instantiation, re-enters the held allocator lock, and aborts — masking the original crash.

Exception Type:  EXC_BREAKPOINT (SIGKILL)
Exception Codes: 0x0000000000000001, 0x00000002c0f1f154   ; PC == _os_unfair_lock_recursive_abort + 36
Termination Reason: FOUNDATION 1
Triggered by Thread:  0

Thread 0 Crashed:
0   libsystem_platform.dylib   _os_unfair_lock_recursive_abort + 36 (lock.c:515)
1   libsystem_platform.dylib   _os_unfair_lock_lock_slow + 296 (lock.c:597)
2   libsystem_malloc.dylib     xzm_segment_group_alloc_chunk + 476
3   libsystem_malloc.dylib     _xzm_malloc_large_huge + 456
4   libdyld.dylib              dyld::ThreadLocalVariables::instantiateVariable(...) + 96 (ThreadLocalVariables.cpp:351)
5   libdyld.dylib              _tlv_get_addr + 104
6   <App>                      handleSignal + 72                         ; <-- SentryCrash signal handler
7   libsystem_platform.dylib   _sigtramp + 56 (sigtramp.c:116)           ; <-- signal delivered here
8   libsystem_malloc.dylib     _xzm_reclaim_mark_used_locked + 60        ; <-- interrupted mid-alloc, lock HELD
9   libsystem_malloc.dylib     _xzm_segment_group_span_mark_smaller + 240
10  libsystem_malloc.dylib     _xzm_segment_group_find_and_allocate_chunk + 528
11  libsystem_malloc.dylib     xzm_segment_group_alloc_chunk + 504
12  libsystem_malloc.dylib     _xzm_malloc_large_huge + 456
13  libsystem_malloc.dylib     xzm_realloc + 748
14  libsystem_malloc.dylib     malloc_type_realloc + 180
15  AttributeGraph             AG::details::realloc_vector<...>(...) + 104
17  AttributeGraph             AG::Graph::propagate_dirty(AG::AttributeID) + 900
18  SwiftUICore                ObservationGraphMutation.apply() + 928
...  (normal SwiftUI render/observation work below)
41  <App>                      main + 180
42  dyld                       start + 6928

Summary

SentryCrashMonitor_Signal.c:handleSignal performs a heap allocation as its first action, which is not async-signal-safe. Its first statement reads the lazily-instantiated thread-local tl_ignoreSignum. On a thread that has never touched that TLV, dyld instantiates it on first access via _tlv_get_addr, which calls malloc.

If the trapped signal interrupted that thread while it was already inside malloc/realloc holding the allocator lock, the handler re-enters malloc on the same thread and trips the non-recursive lock check, terminating with _os_unfair_lock_recursive_abort (EXC_BREAKPOINT).

The net effect: the original crash signal is masked and replaced by this misleading abort, in both Sentry's report and Apple's crash report (because the POSIX handler runs in-process before re-raising). This is a regression — it makes a class of crashes uninvestigable.

The offending code

Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.c

static _Thread_local int tl_ignoreSignum = 0;           // line ~52
...
static void
handleSignal(int sigNum, siginfo_t *signalInfo, void *userContext)
{
    int ignoreSignum = tl_ignoreSignum;     // line ~103 — FIRST action: lazy TLV read
    tl_ignoreSignum = 0;                    // -> _tlv_get_addr -> malloc on first touch
    ...
}

tl_ignoreSignum (and this first-action read) was introduced in #7340 "Write reports on concurrent crashes", shipped in 9.5.1, as the re-entrancy guard for the handler's terminal raise(sigNum). It is still present on main / 9.18.0; no release fixes it.

Why this is a real bug

Sentry's own native signal-handler design docs state that in a signal handler "not even allocations are possible" and "malloc can (and will!) hold a lock when entering into a signal handler which can cause us to either deadlock or crash," prescribing a bump allocator + an in-handler spinlock as the workaround. https://develop.sentry.dev/sdk/platform-specifics/native-sdks/signal-handlers/

handleSignal violates this invariant before any of that protection runs, because the very first line can allocate via lazy TLV instantiation.

Are you willing to submit a PR?

No response

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions