Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -669,8 +673,10 @@ if(SENTRY_WITH_LIBUNWINDSTACK)
endif()

if(SENTRY_WITH_LIBUNWIND)
if(LINUX)
# Use vendored libunwind
if(LINUX OR ANDROID)
# Use vendored libunwind (on Android too: matches Linux behaviour and
# avoids relying on the NDK toolchain's libunwind, whose API can drift
# between NDK versions).
add_subdirectory(vendor/libunwind)
target_link_libraries(sentry PRIVATE unwind)
if(NOT SENTRY_BUILD_SHARED_LIBS)
Expand Down
21 changes: 21 additions & 0 deletions include/sentry.h
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,27 @@ 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 by Linux kernel thread ID (TID).
*
* Uses signal-based sampling: 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 sample completes or times out (1 second).
*
* Linux and Android only. Other platforms return 0.
*
* Concurrent calls are serialized internally; only one sample runs at a time.
*
* @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
*/
Expand Down
20 changes: 20 additions & 0 deletions ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -63,6 +65,24 @@ public static void close() {
shutdown();
}

/**
* Captures the native stack of another thread by Linux kernel TID.
*
* Uses signal-based sampling internally. Returns instruction-pointer
* addresses as longs; an empty array indicates sampling failure
* (invalid TID, signal delivery failure, timeout, or unsupported platform).
*
* <p>Linux/Android only. Other platforms return an empty array.
*
* @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.
Expand Down
26 changes: 26 additions & 0 deletions ndk/lib/src/main/jni/sentry.c
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <string.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <sentry.h>
#include <jni.h>
Expand Down Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ if(SENTRY_WITH_LIBUNWIND)
)
endif()

# Cross-thread stack sampler (sentry_unwind_thread_stack). Self-gates to
# Linux + libunwind at compile time; on other platforms the file compiles to
# a no-op stub, so we add it unconditionally.
sentry_target_sources_cwd(sentry
sentry_thread_sampler.c
)

if(SENTRY_WITH_LIBUNWIND_MAC)
target_compile_definitions(sentry PRIVATE SENTRY_WITH_UNWINDER_LIBUNWIND_MAC)
sentry_target_sources_cwd(sentry
Expand Down
216 changes: 216 additions & 0 deletions src/sentry_thread_sampler.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
#include "sentry_boot.h"

#include <stddef.h>

#if (defined(__linux__) || defined(__ANDROID__)) \
&& defined(SENTRY_WITH_UNWINDER_LIBUNWIND)
# define SENTRY_THREAD_SAMPLER_SUPPORTED 1
#else
# define SENTRY_THREAD_SAMPLER_SUPPORTED 0
#endif

#if SENTRY_THREAD_SAMPLER_SUPPORTED

# include <errno.h>
# include <pthread.h>
# include <semaphore.h>
# include <signal.h>
# include <stdint.h>
# include <string.h>
# include <sys/syscall.h>
# include <sys/types.h>
# include <time.h>
# include <unistd.h>

# define UNW_LOCAL_ONLY
# include <libunwind.h>

/*
* Real-time signal used for asynchronous stack sampling.
*
* `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_SAMPLER_SIGNAL (SIGRTMIN + 5)

static pthread_mutex_t g_sampler_lock = PTHREAD_MUTEX_INITIALIZER;
static sem_t g_sampler_done;
static void **g_sampler_out_buf;
static size_t g_sampler_out_max;
static volatile size_t g_sampler_out_written;
static volatile int g_sampler_initialized = 0;

/*
* TID the currently active sampling request is expecting. Set by the caller
* before `tgkill` (under `g_sampler_lock`) and consulted inside the signal
* handler to discard stale signals delivered after a previous sampling 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 — potentially while another sampling
* request targeting a different thread is in flight. Without this guard, the
* stale handler would write the wrong thread's stack into the new request's
* buffer.
*/
static volatile int g_expected_tid = 0;

/*
* 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__sampler_signal_handler(int sig, siginfo_t *info, void *ucontext_v)
{
(void)sig;
(void)info;

// Stale-signal guard: if our TID doesn't match the request currently in
// flight, return silently without posting. Writing into the active
// request's buffer here would corrupt the result. Not posting is safe —
// the active request's tgkill will produce its own (correctly-targeted)
// handler invocation, and `sem_trywait` drains any leftover posts at the
// start of each request.
const int my_tid = (int)syscall(SYS_gettid);
if (my_tid != g_expected_tid) {
return;
}

size_t written = 0;
if (g_sampler_out_buf && g_sampler_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_sampler_out_buf[written++] = (void *)(uintptr_t)ip;
if (written >= g_sampler_out_max) {
break;
}

prev_ip = ip;
prev_sp = sp;
have_prev = 1;

if (unw_step(&cursor) <= 0) {
break;
}
}
}
}
g_sampler_out_written = written;
// sem_post is in the POSIX async-signal-safe list.
sem_post(&g_sampler_done);
}

#endif // SENTRY_THREAD_SAMPLER_SUPPORTED

size_t
sentry_unwind_thread_stack(int tid, void **stacktrace_out, size_t max_len)
{
#if !SENTRY_THREAD_SAMPLER_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_sampler_lock);

if (!g_sampler_initialized) {
if (sem_init(&g_sampler_done, 0, 0) != 0) {
pthread_mutex_unlock(&g_sampler_lock);
return 0;
}

struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = sentry__sampler_signal_handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigemptyset(&sa.sa_mask);

// Save any previous disposition. We intentionally overwrite it; this
// signal slot is owned by the sampler for the lifetime of the
// process. If the slot was already in use by the host application we
// would still want to know — but since we have no logger available
// here that is async-signal-safe to call later, just proceed.
struct sigaction oldact;
memset(&oldact, 0, sizeof(oldact));
if (sigaction(SENTRY_SAMPLER_SIGNAL, &sa, &oldact) != 0) {
sem_destroy(&g_sampler_done);
pthread_mutex_unlock(&g_sampler_lock);
return 0;
}
g_sampler_initialized = 1;
}

g_sampler_out_buf = stacktrace_out;
g_sampler_out_max = max_len;
g_sampler_out_written = 0;
g_expected_tid = tid;

// Drain any spurious posts from a previous timed-out sample, so that the
// wait below cannot return prematurely on a stale token.
while (sem_trywait(&g_sampler_done) == 0) {
// discard
}

pid_t my_pid = getpid();
if (syscall(SYS_tgkill, my_pid, tid, SENTRY_SAMPLER_SIGNAL) != 0) {
g_sampler_out_buf = NULL;
g_expected_tid = 0;
pthread_mutex_unlock(&g_sampler_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_sampler_done, &timeout);
} while (rc == -1 && errno == EINTR);

size_t result = (rc == 0) ? g_sampler_out_written : 0;
g_sampler_out_buf = NULL;
g_sampler_out_max = 0;
g_expected_tid = 0;

pthread_mutex_unlock(&g_sampler_lock);
return result;
#endif
}
1 change: 1 addition & 0 deletions tests/unit/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ add_executable(sentry_test_unit
test_string.c
test_symbolizer.c
test_sync.c
test_thread_sampler.c
test_tracing.c
test_tus.c
test_uninit.c
Expand Down
Loading
Loading