diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3a3f8168a..afc2ad7515 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+**Features**:
+
+- Add `sentry_unwind_thread_stack` for async-signal-safe cross-thread stack sampling on Linux and Android. ([#1721](https://github.com/getsentry/sentry-native/pull/1721))
+
**Fixes**:
- Reject overly deep msgpack payloads during deserialization. ([#1727](https://github.com/getsentry/sentry-native/pull/1727))
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 76b2377197..d3d7091345 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -297,6 +297,10 @@ endif()
if(ANDROID)
set(SENTRY_WITH_LIBUNWINDSTACK TRUE)
+ # libunwind is also enabled on Android so the cross-thread sampler
+ # (sentry_unwind_thread_stack) has an async-signal-safe unwinder.
+ # libunwindstack allocates and is unsafe from a signal handler.
+ set(SENTRY_WITH_LIBUNWIND TRUE)
elseif(LINUX)
set(SENTRY_WITH_LIBUNWIND TRUE)
elseif(APPLE)
@@ -669,7 +673,7 @@ if(SENTRY_WITH_LIBUNWINDSTACK)
endif()
if(SENTRY_WITH_LIBUNWIND)
- if(LINUX)
+ if(LINUX OR ANDROID)
# Use vendored libunwind
add_subdirectory(vendor/libunwind)
target_link_libraries(sentry PRIVATE unwind)
diff --git a/include/sentry.h b/include/sentry.h
index a3a6921919..f00e18e022 100644
--- a/include/sentry.h
+++ b/include/sentry.h
@@ -707,6 +707,45 @@ SENTRY_EXPERIMENTAL_API size_t sentry_unwind_stack(
SENTRY_EXPERIMENTAL_API size_t sentry_unwind_stack_from_ucontext(
const sentry_ucontext_t *uctx, void **stacktrace_out, size_t max_len);
+/**
+ * Captures a stacktrace from another thread in the current process by Linux
+ * kernel thread ID (TID).
+ *
+ * A real-time signal is sent to the target thread, and the thread's stack is
+ * unwound from the signal context. The function blocks until the unwind
+ * completes or times out (1 second).
+ *
+ * Linux and Android only. Other platforms return 0.
+ *
+ * Concurrent calls are serialized internally; only one unwind runs at a time.
+ *
+ * The TID must belong to the current process. Cross-process TIDs are not
+ * supported and will fail.
+ *
+ * Callers must not re-request the same TID faster than the 1-second timeout:
+ * if a previous request timed out, its signal is still queued for the target,
+ * and a follow-up request to the same TID before the queued signal is
+ * delivered may receive stale frames. This is acceptable for ANR /
+ * frozen-frame capture (one request per event) but precludes profiler-style
+ * continuous sampling.
+ *
+ * The first call on a supported platform installs a signal handler for
+ * `SIGRTMIN + 5`. The handler chains to any previously installed handler for
+ * the same signal: deliveries that did not originate from this unwinder are
+ * forwarded, so host applications or other libraries using `SIGRTMIN + 5`
+ * keep working. The handler is not removed by `sentry_close()` and stays
+ * installed for the lifetime of the process.
+ *
+ * @param tid Linux kernel TID of the target thread (e.g. from gettid() or
+ * android.os.Process.myTid()).
+ * @param stacktrace_out Caller-provided buffer for instruction pointers.
+ * @param max_len Capacity of stacktrace_out.
+ * @return Number of frames written. 0 on failure (invalid TID, signal delivery
+ * failure, timeout, or unsupported platform).
+ */
+SENTRY_EXPERIMENTAL_API size_t sentry_unwind_thread_stack(
+ int tid, void **stacktrace_out, size_t max_len);
+
/**
* A UUID
*/
diff --git a/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java b/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java
index 2369c11042..ae17c6f72f 100644
--- a/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java
+++ b/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java
@@ -22,6 +22,8 @@ private SentryNdk() {}
private static native void shutdown();
+ private static native long[] captureThreadStackNative(long tid);
+
/**
* Preloads sentry-native into the process signal chain before full
* initialization.
@@ -63,6 +65,39 @@ public static void close() {
shutdown();
}
+ /**
+ * Captures the native stack of another thread in the current process by Linux kernel TID.
+ *
+ *
Uses a real-time signal sent via {@code tgkill} to interrupt the target thread and unwind
+ * its stack from the signal context. Returns instruction-pointer addresses as longs; an empty
+ * array indicates failure (invalid TID, signal delivery failure, timeout, or unsupported
+ * platform).
+ *
+ *
The TID must belong to the current process. Cross-process TIDs are not supported.
+ *
+ *
Callers must not re-request the same TID faster than the 1-second timeout: if a previous
+ * request timed out, its signal is still queued for the target, and a follow-up request to the
+ * same TID before the queued signal is delivered may receive stale frames. This is acceptable
+ * for ANR / frozen-frame capture (one request per event) but precludes profiler-style continuous
+ * sampling.
+ *
+ *
Linux/Android only. Other platforms return an empty array.
+ *
+ *
The first call on a supported platform installs a signal handler for {@code SIGRTMIN + 5}
+ * in the process. The handler chains to any previously installed handler for the same signal:
+ * deliveries that did not originate from this unwinder are forwarded, so host applications or
+ * other libraries using {@code SIGRTMIN + 5} keep working. The handler is not removed by {@link
+ * #close()} and stays installed for the lifetime of the process.
+ *
+ * @param tid Linux kernel TID of the target thread (e.g. android.os.Process.myTid()).
+ * @return array of instruction-pointer addresses (up to 128 frames), or empty on failure.
+ */
+ public static long[] captureThreadStack(final long tid) {
+ loadNativeLibraries();
+ final long[] result = captureThreadStackNative(tid);
+ return result != null ? result : new long[0];
+ }
+
/**
* Loads all required native libraries. This is automatically done by {@link #init(NdkOptions)},
* but can be called manually in case you want to preload the libraries before calling #init.
diff --git a/ndk/lib/src/main/jni/sentry.c b/ndk/lib/src/main/jni/sentry.c
index c64f005655..1b2071a29d 100644
--- a/ndk/lib/src/main/jni/sentry.c
+++ b/ndk/lib/src/main/jni/sentry.c
@@ -1,5 +1,6 @@
#include
#include
+#include
#include
#include
#include
@@ -575,3 +576,28 @@ JNIEXPORT void JNICALL
Java_io_sentry_ndk_SentryNdk_shutdown(JNIEnv *env, jclass cls) {
sentry_close();
}
+
+JNIEXPORT jlongArray JNICALL
+Java_io_sentry_ndk_SentryNdk_captureThreadStackNative(JNIEnv *env, jclass cls, jlong tid) {
+ (void)cls;
+ enum { MAX_FRAMES = 128 };
+ void *frames[MAX_FRAMES];
+
+ size_t count = sentry_unwind_thread_stack((int)tid, frames, MAX_FRAMES);
+
+ jlongArray result = (*env)->NewLongArray(env, (jsize)count);
+ if (!result) {
+ return NULL;
+ }
+ if (count == 0) {
+ return result;
+ }
+
+ // Copy via a small stack buffer so we don't depend on sizeof(void*) == sizeof(jlong)
+ jlong buf[MAX_FRAMES];
+ for (size_t i = 0; i < count; i++) {
+ buf[i] = (jlong)(uintptr_t)frames[i];
+ }
+ (*env)->SetLongArrayRegion(env, result, 0, (jsize)count, buf);
+ return result;
+}
diff --git a/ndk/sample/src/main/java/io/sentry/ndk/sample/MainActivity.java b/ndk/sample/src/main/java/io/sentry/ndk/sample/MainActivity.java
index fdef19d5aa..cc9efe8864 100644
--- a/ndk/sample/src/main/java/io/sentry/ndk/sample/MainActivity.java
+++ b/ndk/sample/src/main/java/io/sentry/ndk/sample/MainActivity.java
@@ -2,20 +2,55 @@
import android.app.Activity;
import android.os.Bundle;
+import android.os.Process;
+import android.util.Log;
+import android.widget.Toast;
import io.sentry.ndk.NdkOptions;
import io.sentry.ndk.SentryNdk;
import java.io.File;
public class MainActivity extends Activity {
+ private static final String TAG = "NdkSample";
+
+ private int mainTid;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
+ mainTid = Process.myTid();
+
findViewById(R.id.init_ndk_button).setOnClickListener(v -> initNdk());
findViewById(R.id.trigger_native_crash_button).setOnClickListener(v -> NdkSample.crash());
findViewById(R.id.capture_message_button).setOnClickListener(v -> NdkSample.message());
findViewById(R.id.capture_transaction_button).setOnClickListener(v -> NdkSample.transaction());
+ findViewById(R.id.capture_main_stack_button).setOnClickListener(v -> captureMainStack());
+ }
+
+ /**
+ * Capture the main thread's native stack from a background thread. Mirrors how an ANR watchdog
+ * would call into the NDK after detecting that the UI thread is stuck.
+ */
+ private void captureMainStack() {
+ final int tid = mainTid;
+ new Thread(
+ () -> {
+ final long[] frames = SentryNdk.captureThreadStack(tid);
+ Log.i(TAG, "Captured " + frames.length + " frames from main thread (tid=" + tid + ")");
+ for (int i = 0; i < frames.length; i++) {
+ Log.i(TAG, String.format(" #%02d: 0x%016x", i, frames[i]));
+ }
+ runOnUiThread(
+ () ->
+ Toast.makeText(
+ this,
+ "Captured " + frames.length + " frames (see logcat)",
+ Toast.LENGTH_SHORT)
+ .show());
+ },
+ "ndk-sample-stack-capture")
+ .start();
}
private void initNdk() {
diff --git a/ndk/sample/src/main/res/layout/activity_main.xml b/ndk/sample/src/main/res/layout/activity_main.xml
index 460091277d..b0017b23a4 100644
--- a/ndk/sample/src/main/res/layout/activity_main.xml
+++ b/ndk/sample/src/main/res/layout/activity_main.xml
@@ -38,6 +38,13 @@
android:text="Trigger Transaction"
tools:ignore="HardcodedText" />
+
+
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 6086dbaafb..0176dc34a5 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -240,6 +240,12 @@ if(SENTRY_WITH_LIBUNWIND)
)
endif()
+# Cross-thread stack unwinder (sentry_unwind_thread_stack). Linux and Android
+# only, via tgkill + signal handler + libunwind. No-op stub everywhere else.
+sentry_target_sources_cwd(sentry
+ sentry_cross_thread_unwind.c
+)
+
if(SENTRY_WITH_LIBUNWIND_MAC)
target_compile_definitions(sentry PRIVATE SENTRY_WITH_UNWINDER_LIBUNWIND_MAC)
sentry_target_sources_cwd(sentry
diff --git a/src/sentry_cross_thread_unwind.c b/src/sentry_cross_thread_unwind.c
new file mode 100644
index 0000000000..3d8d0b35c4
--- /dev/null
+++ b/src/sentry_cross_thread_unwind.c
@@ -0,0 +1,273 @@
+#include "sentry_boot.h"
+
+#include
+
+#if (defined(__linux__) || defined(__ANDROID__)) \
+ && defined(SENTRY_WITH_UNWINDER_LIBUNWIND)
+# define SENTRY_CROSS_THREAD_UNWIND_SUPPORTED 1
+#else
+# define SENTRY_CROSS_THREAD_UNWIND_SUPPORTED 0
+#endif
+
+#if SENTRY_CROSS_THREAD_UNWIND_SUPPORTED
+
+# include
+# include
+# include
+# include
+# include
+# include
+# include
+# include
+# include
+# include
+
+# define UNW_LOCAL_ONLY
+# include
+
+/*
+ * Real-time signal used to interrupt a target thread and unwind its stack.
+ *
+ * `SIGRTMIN + 5` is chosen because:
+ * - real-time signals (>= SIGRTMIN) are queued, not coalesced, and are not
+ * used by libc itself, so they will not collide with internal C library
+ * machinery (e.g. NPTL uses SIGRTMIN .. SIGRTMIN+2 on glibc, and Bionic
+ * reserves a similar low range for its own thread plumbing);
+ * - +5 matches the offset that async-profiler uses for the same purpose,
+ * which avoids stepping on common application-side users of low real-time
+ * signal slots.
+ *
+ * NOTE: the actual value of SIGRTMIN is only known at runtime on glibc/Bionic
+ * (it is a function call expanding to libc internals), so we cannot use it in
+ * a `case` label and must compute it at handler-install time.
+ */
+# define SENTRY_UNWIND_SIGNAL (SIGRTMIN + 5)
+
+/*
+ * Memory ordering between the caller (requesting thread) and the signal
+ * handler (target thread) is provided by the kernel: `tgkill` is a syscall,
+ * and the kernel issues full memory barriers across syscall entry/exit and
+ * during signal-frame setup on the target thread. The `volatile` qualifiers
+ * below prevent compiler reordering only — the cross-thread visibility comes
+ * from the kernel. This is Linux/Android-specific and not portable.
+ */
+static pthread_mutex_t g_unwind_lock = PTHREAD_MUTEX_INITIALIZER;
+static sem_t g_unwind_done;
+static void **g_unwind_out_buf;
+static size_t g_unwind_out_max;
+static volatile size_t g_unwind_out_written;
+static volatile int g_unwind_initialized = 0;
+
+/*
+ * TID the currently active unwind request is expecting. Set by the caller
+ * before `tgkill` (under `g_unwind_lock`) and consulted inside the signal
+ * handler to discard stale signals delivered after a previous request timed
+ * out. Real-time signals are queued, not coalesced, so a target thread that
+ * was blocked when we sent it the original signal may eventually run our
+ * handler at an arbitrarily later time. The TID guard rejects deliveries that
+ * land while a different (or no) request is in flight.
+ *
+ * NOTE: This does NOT discriminate between successive requests targeting the
+ * same TID. A caller that re-requests the same thread within the timeout
+ * window of a previous timed-out request may receive stale frames. The
+ * intended consumer (ANR / frozen-frame capture) issues one request per
+ * event, so this is not a concern in practice; callers must not exceed the
+ * 1s request cadence per TID.
+ */
+static volatile int g_expected_tid = 0;
+
+/*
+ * Previous disposition of SENTRY_UNWIND_SIGNAL, captured at install time so
+ * we can forward signals not originating from this unwinder (e.g. a host
+ * application or another library that also uses this slot). Written once
+ * under `g_unwind_lock` before the handler can fire; the `sigaction` syscall
+ * provides the memory barrier that publishes it to the handler context.
+ */
+static struct sigaction g_prev_action;
+
+/*
+ * Forward a SENTRY_UNWIND_SIGNAL delivery to whatever handler was installed
+ * before us. Async-signal-safe by construction: just a function-pointer
+ * dispatch into code the host already deemed safe for this signal.
+ */
+static void
+sentry__cross_thread_unwind_chain_previous(
+ int sig, siginfo_t *info, void *ucontext_v)
+{
+ void *fn = (g_prev_action.sa_flags & SA_SIGINFO)
+ ? (void *)g_prev_action.sa_sigaction
+ : (void *)g_prev_action.sa_handler;
+ if (fn == NULL || fn == (void *)SIG_DFL || fn == (void *)SIG_IGN) {
+ // No-op for SIG_DFL too: the default disposition for a real-time
+ // signal is process termination, which we never want to trigger from
+ // a stale or unrelated delivery.
+ return;
+ }
+ if (g_prev_action.sa_flags & SA_SIGINFO) {
+ g_prev_action.sa_sigaction(sig, info, ucontext_v);
+ } else {
+ g_prev_action.sa_handler(sig);
+ }
+}
+
+/*
+ * Signal handler running on the *target* thread's stack. Must be strictly
+ * async-signal-safe: no malloc, no logging, no mutex acquisition.
+ *
+ * We unwind from the saved ucontext using libunwind's
+ * `UNW_INIT_SIGNAL_FRAME` mode (same pattern as
+ * `sentry__unwind_stack_libunwind` for the crash path), write IPs into the
+ * caller-provided buffer, then signal completion via `sem_post`, which POSIX
+ * mandates be async-signal-safe.
+ */
+static void
+sentry__cross_thread_unwind_signal_handler(
+ int sig, siginfo_t *info, void *ucontext_v)
+{
+ // Save and restore errno around any work done in the signal handler.
+ // The handler can interrupt arbitrary user code that may be inspecting
+ // errno after a failed libc call; modifying it here would corrupt that
+ // observation. syscall(), unw_*(), and sem_post() can all touch errno.
+ const int saved_errno = errno;
+
+ // Stale-signal guard: if our TID doesn't match the request currently in
+ // flight, this delivery is either (a) a queued stale signal from a
+ // previous timed-out request targeting some other thread (impossible
+ // here, since tgkill is thread-targeted) or (b) a signal a host
+ // application or unrelated library sent on this slot. Case (a) cannot
+ // occur — a stale queued signal can only land on the thread it was
+ // targeted at — so any mismatch is by definition not ours. Forward to
+ // the previously installed handler so the host's use of this signal
+ // keeps working.
+ const int my_tid = (int)syscall(SYS_gettid);
+ if (my_tid != g_expected_tid) {
+ sentry__cross_thread_unwind_chain_previous(sig, info, ucontext_v);
+ errno = saved_errno;
+ return;
+ }
+
+ size_t written = 0;
+ if (g_unwind_out_buf && g_unwind_out_max > 0 && ucontext_v) {
+ unw_cursor_t cursor;
+ if (unw_init_local2(
+ &cursor, (unw_context_t *)ucontext_v, UNW_INIT_SIGNAL_FRAME)
+ == 0) {
+ unw_word_t prev_ip = 0;
+ unw_word_t prev_sp = 0;
+ int have_prev = 0;
+ for (;;) {
+ unw_word_t ip = 0;
+ if (unw_get_reg(&cursor, UNW_REG_IP, &ip) != 0) {
+ break;
+ }
+ unw_word_t sp = 0;
+ (void)unw_get_reg(&cursor, UNW_REG_SP, &sp);
+
+ // Stop on lack of progress (mirrors the crash unwinder).
+ if (have_prev && ip == prev_ip && sp == prev_sp) {
+ break;
+ }
+
+ g_unwind_out_buf[written++] = (void *)(uintptr_t)ip;
+ if (written >= g_unwind_out_max) {
+ break;
+ }
+
+ prev_ip = ip;
+ prev_sp = sp;
+ have_prev = 1;
+
+ if (unw_step(&cursor) <= 0) {
+ break;
+ }
+ }
+ }
+ }
+ g_unwind_out_written = written;
+ // sem_post is in the POSIX async-signal-safe list.
+ sem_post(&g_unwind_done);
+ errno = saved_errno;
+}
+
+#endif // SENTRY_CROSS_THREAD_UNWIND_SUPPORTED
+
+size_t
+sentry_unwind_thread_stack(int tid, void **stacktrace_out, size_t max_len)
+{
+#if !SENTRY_CROSS_THREAD_UNWIND_SUPPORTED
+ (void)tid;
+ (void)stacktrace_out;
+ (void)max_len;
+ return 0;
+#else
+ if (!stacktrace_out || max_len == 0 || tid <= 0) {
+ return 0;
+ }
+
+ pthread_mutex_lock(&g_unwind_lock);
+
+ if (!g_unwind_initialized) {
+ if (sem_init(&g_unwind_done, 0, 0) != 0) {
+ pthread_mutex_unlock(&g_unwind_lock);
+ return 0;
+ }
+
+ struct sigaction sa;
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_sigaction = sentry__cross_thread_unwind_signal_handler;
+ sa.sa_flags = SA_SIGINFO | SA_RESTART;
+ sigemptyset(&sa.sa_mask);
+
+ // Capture the previous disposition so the handler can chain to it for
+ // any delivery that isn't our unwind request (see
+ // `sentry__cross_thread_unwind_chain_previous`). The handler is
+ // permanent for the process lifetime — `sentry_close()` does not
+ // restore the previous action, because doing so races with
+ // queued-but-undelivered requests and could reinstate `SIG_DFL` in
+ // time to terminate the process.
+ if (sigaction(SENTRY_UNWIND_SIGNAL, &sa, &g_prev_action) != 0) {
+ sem_destroy(&g_unwind_done);
+ pthread_mutex_unlock(&g_unwind_lock);
+ return 0;
+ }
+ g_unwind_initialized = 1;
+ }
+
+ g_unwind_out_buf = stacktrace_out;
+ g_unwind_out_max = max_len;
+ g_unwind_out_written = 0;
+ g_expected_tid = tid;
+
+ // Drain any spurious posts from a previous timed-out request, so that the
+ // wait below cannot return prematurely on a stale token.
+ while (sem_trywait(&g_unwind_done) == 0) {
+ // discard
+ }
+
+ pid_t my_pid = getpid();
+ if (syscall(SYS_tgkill, my_pid, tid, SENTRY_UNWIND_SIGNAL) != 0) {
+ g_unwind_out_buf = NULL;
+ g_expected_tid = 0;
+ pthread_mutex_unlock(&g_unwind_lock);
+ return 0;
+ }
+
+ // Bounded wait — 1 second max.
+ struct timespec timeout;
+ clock_gettime(CLOCK_REALTIME, &timeout);
+ timeout.tv_sec += 1;
+
+ int rc;
+ do {
+ rc = sem_timedwait(&g_unwind_done, &timeout);
+ } while (rc == -1 && errno == EINTR);
+
+ size_t result = (rc == 0) ? g_unwind_out_written : 0;
+ g_unwind_out_buf = NULL;
+ g_unwind_out_max = 0;
+ g_expected_tid = 0;
+
+ pthread_mutex_unlock(&g_unwind_lock);
+ return result;
+#endif
+}
diff --git a/tests/__init__.py b/tests/__init__.py
index ecfd7f300e..87b9bbd5ad 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -121,7 +121,7 @@ def run(cwd, exe, args, expect_failure=False, env=None, **kwargs):
# `LD_LIBRARY_PATH` to force the android dynamic loader to
# load `libsentry.so` from the correct library.
# See https://android.googlesource.com/platform/bionic/+/master/android-changes-for-ndk-developers.md#dt_runpath-support-available-in-api-level-24
- "cd /data/local/tmp && LD_LIBRARY_PATH=. ./{} {}; echo -n ret:$?".format(
+ "cd /data/local/tmp && TMPDIR=/data/local/tmp LD_LIBRARY_PATH=. ./{} {}; echo -n ret:$?".format(
exe, " ".join(args)
),
],
diff --git a/tests/tsan.supp b/tests/tsan.supp
index 4fd6bbe101..f700758bdf 100644
--- a/tests/tsan.supp
+++ b/tests/tsan.supp
@@ -2,4 +2,12 @@
# synchronization issues in the initialization. Yes, the logger initialization is racy wrt
# the logging itself, but real usage calls sentry_init once per process and does not justify
# a mutex acquisition on every log
-race:g_logger
\ No newline at end of file
+race:g_logger
+
+# Suppress races between the cross-thread unwinder caller (sentry_unwind_thread_stack) and
+# the async-signal handler running on the target thread. Memory ordering between the two is
+# provided by the kernel: tgkill is a syscall, and signal-frame setup on the target thread
+# carries the full memory barriers required to publish the request globals to the handler.
+# TSan cannot model this kernel-mediated synchronization and flags the accesses as races.
+# See the comment block in src/sentry_cross_thread_unwind.c for details.
+race:sentry__cross_thread_unwind_signal_handler
\ No newline at end of file
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index a143fd540a..92e41b33be 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -27,6 +27,7 @@ add_executable(sentry_test_unit
test_client_report.c
test_consent.c
test_concurrency.c
+ test_cross_thread_unwind.c
test_embedded_info.c
test_envelopes.c
test_failures.c
diff --git a/tests/unit/test_cross_thread_unwind.c b/tests/unit/test_cross_thread_unwind.c
new file mode 100644
index 0000000000..810bdf8926
--- /dev/null
+++ b/tests/unit/test_cross_thread_unwind.c
@@ -0,0 +1,159 @@
+#include "sentry_testsupport.h"
+
+#include
+
+#if defined(__linux__) || defined(__ANDROID__)
+# include
+# include
+# include
+
+SENTRY_TEST(cross_thread_unwind_self)
+{
+ // qemu-user does not faithfully emulate tgkill + libunwind from a
+ // real-time signal frame; the unwinder intermittently returns zero
+ // frames. The behaviour is exercised on every native runner in CI.
+ if (getenv("TEST_QEMU")) {
+ SKIP_TEST();
+ }
+ void *frames[32];
+ pid_t tid = (pid_t)syscall(SYS_gettid);
+ size_t n = sentry_unwind_thread_stack((int)tid, frames, 32);
+ // Unit tests run on Linux with the vendored libunwind compiled in, so the
+ // unwinder must successfully unwind at least one frame from this thread.
+ // A zero result here indicates a regression in signal delivery, the TID
+ // guard, or libunwind initialisation.
+ TEST_CHECK(n >= 1);
+ TEST_CHECK(n <= 32);
+ TEST_CHECK(frames[0] != NULL);
+}
+
+struct worker_ctx {
+ pthread_mutex_t mutex;
+ pthread_cond_t tid_published;
+ pthread_cond_t exit_requested;
+ pid_t tid;
+ int should_exit;
+};
+
+static void *
+worker_thread(void *arg)
+{
+ struct worker_ctx *ctx = (struct worker_ctx *)arg;
+ pthread_mutex_lock(&ctx->mutex);
+ ctx->tid = (pid_t)syscall(SYS_gettid);
+ pthread_cond_signal(&ctx->tid_published);
+ // Park until the main thread is done unwinding. The SIGRTMIN+5 delivery
+ // interrupts the futex wait transparently; the while-loop also guards
+ // against spurious wakeups.
+ while (!ctx->should_exit) {
+ pthread_cond_wait(&ctx->exit_requested, &ctx->mutex);
+ }
+ pthread_mutex_unlock(&ctx->mutex);
+ return NULL;
+}
+
+SENTRY_TEST(cross_thread_unwind_other_thread)
+{
+ // The self-unwind test exercises signal delivery from a thread to itself.
+ // This test exercises the cross-thread tgkill path that ANR / frozen-frame
+ // callers actually use, where the requesting thread and the unwound
+ // thread are distinct.
+ if (getenv("TEST_QEMU")) {
+ SKIP_TEST();
+ }
+ struct worker_ctx ctx;
+ pthread_mutex_init(&ctx.mutex, NULL);
+ pthread_cond_init(&ctx.tid_published, NULL);
+ pthread_cond_init(&ctx.exit_requested, NULL);
+ ctx.tid = 0;
+ ctx.should_exit = 0;
+
+ pthread_t worker;
+ TEST_CHECK_INT_EQUAL(pthread_create(&worker, NULL, worker_thread, &ctx), 0);
+
+ pthread_mutex_lock(&ctx.mutex);
+ while (ctx.tid == 0) {
+ pthread_cond_wait(&ctx.tid_published, &ctx.mutex);
+ }
+ const pid_t worker_tid = ctx.tid;
+ pthread_mutex_unlock(&ctx.mutex);
+
+ void *frames[32];
+ size_t n = sentry_unwind_thread_stack((int)worker_tid, frames, 32);
+ TEST_CHECK(n >= 1);
+ TEST_CHECK(n <= 32);
+ TEST_CHECK(frames[0] != NULL);
+
+ pthread_mutex_lock(&ctx.mutex);
+ ctx.should_exit = 1;
+ pthread_cond_signal(&ctx.exit_requested);
+ pthread_mutex_unlock(&ctx.mutex);
+ pthread_join(worker, NULL);
+
+ pthread_cond_destroy(&ctx.tid_published);
+ pthread_cond_destroy(&ctx.exit_requested);
+ pthread_mutex_destroy(&ctx.mutex);
+}
+
+SENTRY_TEST(cross_thread_unwind_rejects_invalid_tid)
+{
+ void *frames[32];
+ size_t n = sentry_unwind_thread_stack(-1, frames, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+
+ n = sentry_unwind_thread_stack(0, frames, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+SENTRY_TEST(cross_thread_unwind_rejects_null_buf)
+{
+ size_t n = sentry_unwind_thread_stack(1, NULL, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+SENTRY_TEST(cross_thread_unwind_rejects_zero_max)
+{
+ void *frames[1];
+ size_t n = sentry_unwind_thread_stack(1, frames, 0);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+#else // non-Linux/Android: function must be a no-op returning 0.
+
+SENTRY_TEST(cross_thread_unwind_self)
+{
+ void *frames[32];
+ size_t n = sentry_unwind_thread_stack(1, frames, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+SENTRY_TEST(cross_thread_unwind_other_thread)
+{
+ void *frames[32];
+ size_t n = sentry_unwind_thread_stack(1, frames, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+SENTRY_TEST(cross_thread_unwind_rejects_invalid_tid)
+{
+ void *frames[32];
+ size_t n = sentry_unwind_thread_stack(-1, frames, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+ n = sentry_unwind_thread_stack(0, frames, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+SENTRY_TEST(cross_thread_unwind_rejects_null_buf)
+{
+ size_t n = sentry_unwind_thread_stack(1, NULL, 32);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+SENTRY_TEST(cross_thread_unwind_rejects_zero_max)
+{
+ void *frames[1];
+ size_t n = sentry_unwind_thread_stack(1, frames, 0);
+ TEST_CHECK_INT_EQUAL((int)n, 0);
+}
+
+#endif
diff --git a/tests/unit/test_unwinder.c b/tests/unit/test_unwinder.c
index 07fb2a0a7c..f606540dd9 100644
--- a/tests/unit/test_unwinder.c
+++ b/tests/unit/test_unwinder.c
@@ -6,6 +6,7 @@
#ifdef SENTRY_WITH_UNWINDER_LIBUNWIND
# include "unwinder/sentry_unwinder.h"
# include
+# include
# include
extern bool find_mem_range_from_fd(int fd, uintptr_t ptr, mem_range_t *range);
#endif
@@ -135,7 +136,17 @@ SENTRY_TEST(find_mem_range)
// Test that the target range ON an oversized line (> 4096 bytes) is
// still found. The lo-hi prefix must be parsed before discarding the
// remainder of the line.
- char tmp_path[] = "/tmp/sentry_test_maps_XXXXXX";
+ // Android emulator and sandboxed environments have no writable /tmp,
+ // so prefer $TMPDIR when set.
+ const char *tmpdir = getenv("TMPDIR");
+ if (!tmpdir || !*tmpdir) {
+ tmpdir = "/tmp";
+ }
+ char tmp_path[256];
+ TEST_ASSERT(
+ snprintf(tmp_path, sizeof(tmp_path), "%s/sentry_test_maps_XXXXXX",
+ tmpdir)
+ < (int)sizeof(tmp_path));
fd = mkstemp(tmp_path);
TEST_ASSERT(fd >= 0);
const char *long_prefix = "dead0000-deadf000 r-xp 00000000 08:01 1234 /";
diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc
index 355f052242..e1f51fcfb7 100644
--- a/tests/unit/tests.inc
+++ b/tests/unit/tests.inc
@@ -95,6 +95,11 @@ XX(crash_context_options_propagation)
XX(crash_context_transport_fields)
XX(crash_marker)
XX(crashed_last_run)
+XX(cross_thread_unwind_other_thread)
+XX(cross_thread_unwind_rejects_invalid_tid)
+XX(cross_thread_unwind_rejects_null_buf)
+XX(cross_thread_unwind_rejects_zero_max)
+XX(cross_thread_unwind_self)
XX(custom_logger)
XX(deserialize_envelope)
XX(deserialize_envelope_empty)
diff --git a/vendor/libunwind/CMakeLists.txt b/vendor/libunwind/CMakeLists.txt
index 6a684f0c95..4acb105e59 100644
--- a/vendor/libunwind/CMakeLists.txt
+++ b/vendor/libunwind/CMakeLists.txt
@@ -288,4 +288,10 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(unwind PRIVATE -w)
endif()
-target_link_libraries(unwind PRIVATE ${CMAKE_DL_LIBS} pthread)
+if(ANDROID)
+ # Bionic merges pthread into libc, so there is no libpthread.so to link.
+ # CMAKE_DL_LIBS is still kept — it may resolve to libdl.so on Android.
+ target_link_libraries(unwind PRIVATE ${CMAKE_DL_LIBS})
+else()
+ target_link_libraries(unwind PRIVATE ${CMAKE_DL_LIBS} pthread)
+endif()