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" /> +