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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 5 additions & 1 deletion 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,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)
Expand Down
39 changes: 39 additions & 0 deletions include/sentry.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
35 changes: 35 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,39 @@ public static void close() {
shutdown();
}

/**
* Captures the native stack of another thread in the current process by Linux kernel TID.
*
* <p>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).
*
* <p>The TID must belong to the current process. Cross-process TIDs are not supported.
*
* <p>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.
*
* <p>Linux/Android only. Other platforms return an empty array.
*
* <p>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.
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;
}
35 changes: 35 additions & 0 deletions ndk/sample/src/main/java/io/sentry/ndk/sample/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions ndk/sample/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
android:text="Trigger Transaction"
tools:ignore="HardcodedText" />

<Button
android:id="@+id/capture_main_stack_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Capture Main Stack"
tools:ignore="HardcodedText" />

</LinearLayout>

</ScrollView>
6 changes: 6 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading