diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b45d83ff..cdeb09990d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Native/Windows: Resolve correct symbol names for crashes in multi-module apps ([#1811](https://github.com/getsentry/sentry-native/pull/1811)) +**Features**: + +- Added an in-process app-hang detection. When enabled via `sentry_options_set_enable_app_hang_tracking`, a background thread monitors the application and captures an app-hang event if no heartbeat is received within `app_hang_timeout_ms` (default `5000` ms). Call `sentry_app_hang_heartbeat()` regularly from the thread you want monitored. ([#1806](https://github.com/getsentry/sentry-native/pull/1806)) + ## 0.15.1 **Fixes**: @@ -15,7 +19,7 @@ **Internal**: -- Refactor envelope writers to better support failure tracking on each layer and not push check responsibility to client code. ([#1807](https://github.com/getsentry/sentry-native/pull/1807)) +- Refactor envelope writers to better support failure tracking on each layer and not push check responsibility to client code. ([#1807](https://github.com/getsentry/sentry-native/pull/1807)) ## 0.15.0 diff --git a/examples/example.c b/examples/example.c index d7f80d7a79..b110440a4f 100644 --- a/examples/example.c +++ b/examples/example.c @@ -660,6 +660,11 @@ main(int argc, char **argv) sentry_options_set_enable_large_attachments(options, 1); } + if (has_arg(argc, argv, "app-hang")) { + sentry_options_set_enable_app_hang_tracking(options, 1); + sentry_options_set_app_hang_timeout_ms(options, 1000); + } + if (has_arg(argc, argv, "stdout")) { sentry_options_set_transport( options, sentry_transport_new(print_envelope)); @@ -1144,6 +1149,28 @@ main(int argc, char **argv) sleep_s(10); } + if (has_arg(argc, argv, "app-hang")) { + printf("app-hang: start\n"); + fflush(stdout); + + // A couple of heartbeats to latch this (main) thread as the monitored + // thread and keep it fresh. + for (int i = 0; i < 3; i++) { + sentry_app_hang_heartbeat(); + sleep_ms(100); + } + + printf("app-hang: doing some heavy work now (going to sleep)\n"); + fflush(stdout); + + // Block the monitored thread past the configured timeout so the + // watchdog samples this hung thread and captures an AppHang event. + sleep_s(3); + + printf("app-hang: finishing\n"); + fflush(stdout); + } + if (has_arg(argc, argv, "test-logger-before-crash")) { // Output marker directly using printf for test parsing printf("pre-crash-log-message\n"); diff --git a/include/sentry.h b/include/sentry.h index 5dab2d388e..14c8122999 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2651,6 +2651,51 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_enable_metrics( SENTRY_EXPERIMENTAL_API int sentry_options_get_enable_metrics( const sentry_options_t *opts); +/** + * Enables or disables in-process app-hang detection. When enabled, a + * background watchdog thread monitors heartbeats from the watched thread. If + * no heartbeat is received within the configured timeout, an app-hang event is + * captured and sent to Sentry. + * + * Disabled by default. Must be combined with regular calls to + * `sentry_app_hang_heartbeat()` from the thread you want monitored. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_enable_app_hang_tracking( + sentry_options_t *opts, int enable); +SENTRY_EXPERIMENTAL_API int sentry_options_get_enable_app_hang_tracking( + const sentry_options_t *opts); + +/** + * Sets the app-hang detection timeout in milliseconds. Defaults to 5000 ms. + * If `enable_app_hang_tracking` is true and no heartbeat is received within + * this window, an app-hang event is captured. + * + * Setting this to 0 while `enable_app_hang_tracking` is true is a + * configuration error: the watchdog will log a warning and skip detection. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_app_hang_timeout_ms( + sentry_options_t *opts, uint64_t millis); +SENTRY_EXPERIMENTAL_API uint64_t sentry_options_get_app_hang_timeout_ms( + const sentry_options_t *opts); + +/** + * Records a heartbeat from the calling thread. + * + * The first call latches the calling thread as the monitored thread. + * Call this regularly from the thread you want watched for hangs. If the + * watchdog does not receive a heartbeat within the configured timeout, it + * captures an app-hang event. + * + * Only a single thread is monitored: the watchdog tracks exactly the one + * thread latched by the first heartbeat. Calls from any other thread are + * ignored. To watch the thread most representative of responsiveness (e.g. + * a UI or main loop), call this from that thread first. + * + * This function is a no-op unless app-hang detection is enabled via + * `sentry_options_set_enable_app_hang_tracking`. + */ +SENTRY_EXPERIMENTAL_API void sentry_app_hang_heartbeat(void); + /** * Type of the `before_send_metric` callback. * diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3fad30e9d6..20a8036243 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,10 @@ sentry_target_sources_cwd(sentry sentry_alloc.c sentry_alloc.h + sentry_app_hang_latch.c + sentry_app_hang_latch.h + sentry_app_hang_monitor.c + sentry_app_hang_monitor.h sentry_attachment.c sentry_attachment.h sentry_backend.c @@ -54,6 +58,7 @@ sentry_target_sources_cwd(sentry sentry_symbolizer.h sentry_sync.c sentry_sync.h + sentry_thread_stackwalk.h sentry_transport.c sentry_transport.h sentry_utils.c @@ -256,6 +261,25 @@ if(SENTRY_WITH_LIBUNWIND_MAC) ) endif() +# platform thread stackwalker (suspend a thread and capture its backtrace) +if(APPLE) + sentry_target_sources_cwd(sentry + sentry_thread_stackwalk_mach.c + ) +endif() + +if(WIN32) + sentry_target_sources_cwd(sentry + sentry_thread_stackwalk_windows.c + ) +endif() + +if(LINUX OR ANDROID) + sentry_target_sources_cwd(sentry + sentry_thread_stackwalk_posix.c + ) +endif() + if(SENTRY_WITH_LIBUNWINDSTACK) target_compile_definitions(sentry PRIVATE SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) sentry_target_sources_cwd(sentry diff --git a/src/sentry_app_hang_latch.c b/src/sentry_app_hang_latch.c new file mode 100644 index 0000000000..96a1dd7361 --- /dev/null +++ b/src/sentry_app_hang_latch.c @@ -0,0 +1,115 @@ +// In-process app-hang detection, shared state and helpers. The latch, heartbeat +// API, and capture predicate are the app-thread hot path: app threads write the +// latch via the heartbeat (timestamped with sentry__monotonic_time), the +// watchdog worker in sentry_app_hang_monitor.c reads it. +#include "sentry_app_hang_latch.h" +#include "sentry_sync.h" +#include "sentry_utils.h" + +#include + +#if defined(SENTRY_PLATFORM_WINDOWS) +# include +#elif defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +# include +# include +#else // SENTRY_PLATFORM_MACOS and other POSIX +# include +#endif + +bool +sentry__app_hang_should_capture( + uint64_t hb, uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb) +{ + if (hb == 0 || timeout_ms == 0) { + return false; + } + if (now < hb || (now - hb) < timeout_ms) { + return false; + } + if (hb == last_fired_hb) { + return false; // already fired for this freeze + } + return true; +} + +// The latch is touched by app threads (writers, via the heartbeat) and the +// single watchdog worker (reader). An explicit mutex would put a lock on the +// heartbeat hot path, not ideal. +// The two fields are accessed with 64-bit atomics: +// +// - last_heartbeat_ms: written on every heartbeat, read by the worker. +// Needs 64-bit-atomic access so a 32-bit platform can't observe a torn +// half-updated timestamp. +// - target_tid: write-once (0 -> first heartbeating tid). The worker only +// uses it as a stackwalker argument so a relaxed read is sufficient. +// +// Both fields use the sentry__atomic_*_u64 helpers, which provide full 64-bit +// atomic (tear-free) access on every platform. Where the target has native +// 64-bit atomics (AArch64, x86-64, ...) this is lock-free; on ARMv7 the +// compiler lowers it to a libatomic call backed by a lock pool. That is fine +// here: the access is off any signal handler (both writer and worker run in +// normal thread context) and the heartbeat cadence dwarfs the few-ns lock, so +// the lock only delivers the tear-free guarantee we already need. +static uint64_t g_target_tid = 0; +static uint64_t g_last_heartbeat_ms = 0; +static volatile long g_app_hang_active = 0; + +void +sentry__app_hang_set_active(bool active) +{ + sentry__atomic_store(&g_app_hang_active, active ? 1 : 0); +} + +uint64_t +sentry__app_hang_current_tid(void) +{ +#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) + return (uint64_t)syscall(SYS_gettid); +#elif defined(SENTRY_PLATFORM_MACOS) + uint64_t tid = 0; + pthread_threadid_np(pthread_self(), &tid); + return tid; +#elif defined(SENTRY_PLATFORM_WINDOWS) + return (uint64_t)GetCurrentThreadId(); +#else + return 0; +#endif +} + +sentry_app_hang_latch_t +sentry__app_hang_current_latch(void) +{ + sentry_app_hang_latch_t latch; + latch.target_tid = sentry__atomic_fetch_u64(&g_target_tid); + latch.last_heartbeat_ms = sentry__atomic_fetch_u64(&g_last_heartbeat_ms); + return latch; +} + +void +sentry__app_hang_latch_reset(void) +{ + sentry__atomic_store_u64(&g_target_tid, 0); + sentry__atomic_store_u64(&g_last_heartbeat_ms, 0); +} + +void +sentry_app_hang_heartbeat(void) +{ + if (!sentry__atomic_fetch(&g_app_hang_active)) { + return; + } + uint64_t tid = sentry__app_hang_current_tid(); + + uint64_t target = sentry__atomic_fetch_u64(&g_target_tid); + if (target == 0) { + // Latch the first heartbeating thread. + sentry__atomic_store_u64(&g_target_tid, tid); + target = tid; + } + if (target == tid) { + // ignore heartbeats from other threads + sentry__atomic_store_u64( + &g_last_heartbeat_ms, sentry__monotonic_time()); + } +} diff --git a/src/sentry_app_hang_latch.h b/src/sentry_app_hang_latch.h new file mode 100644 index 0000000000..d5d5af2e44 --- /dev/null +++ b/src/sentry_app_hang_latch.h @@ -0,0 +1,29 @@ +#ifndef SENTRY_APP_HANG_LATCH_H_INCLUDED +#define SENTRY_APP_HANG_LATCH_H_INCLUDED + +#include "sentry_boot.h" +#include "sentry_value.h" + +#define SENTRY_APP_HANG_MAX_FRAMES 128 + +bool sentry__app_hang_should_capture( + uint64_t hb, uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb); + +typedef struct { + uint64_t target_tid; + uint64_t last_heartbeat_ms; +} sentry_app_hang_latch_t; + +uint64_t sentry__app_hang_current_tid(void); +sentry_app_hang_latch_t sentry__app_hang_current_latch(void); +void sentry__app_hang_latch_reset(void); + +// Enables/disables the heartbeat fast-path. The watchdog monitor sets this on +// start and clears it on stop, so sentry_app_hang_heartbeat() is a cheap no-op +// when detection is not running. +void sentry__app_hang_set_active(bool active); + +sentry_value_t sentry__app_hang_make_event( + void **ips, size_t frame_count, uint64_t freeze_ms); + +#endif diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c new file mode 100644 index 0000000000..f26e531549 --- /dev/null +++ b/src/sentry_app_hang_monitor.c @@ -0,0 +1,182 @@ +#include "sentry_app_hang_monitor.h" + +#include "sentry_app_hang_latch.h" +#include "sentry_core.h" +#include "sentry_logger.h" +#include "sentry_options.h" +#include "sentry_sync.h" +#include "sentry_thread_stackwalk.h" +#include "sentry_utils.h" + +#include +#include + +sentry_value_t +sentry__app_hang_make_event(void **ips, size_t frame_count, uint64_t freeze_ms) +{ + char value_buf[128]; + snprintf(value_buf, sizeof(value_buf), "App hung for at least %llu ms.", + (unsigned long long)freeze_ms); + + sentry_value_t event = sentry_value_new_event(); + sentry_value_set_by_key(event, "level", sentry_value_new_string("error")); + sentry_value_set_by_key( + event, "message", sentry_value_new_string(value_buf)); + + sentry_value_t exc = sentry_value_new_exception("AppHang", value_buf); + + sentry_value_t mechanism = sentry_value_new_object(); + sentry_value_set_by_key( + mechanism, "type", sentry_value_new_string("AppHang")); + sentry_value_set_by_key(mechanism, "handled", sentry_value_new_bool(true)); + sentry_value_set_by_key( + mechanism, "synthetic", sentry_value_new_bool(true)); + sentry_value_set_by_key(exc, "mechanism", mechanism); + + sentry_value_set_stacktrace(exc, ips, frame_count); + + sentry_event_add_exception(event, exc); + return event; +} + +static sentry__app_hang_stackwalk_fn g_stackwalk_override = NULL; + +void +sentry__app_hang_monitor_set_stackwalk_fn(sentry__app_hang_stackwalk_fn fn) +{ + g_stackwalk_override = fn; +} + +// Everything below is the watchdog machinery, which only makes sense where a +// platform thread stackwalker exists. On other platforms the public start/stop +// are no-ops (see below) and these would be unused (which -Werror rejects), so +// they are compiled out entirely. +#if SENTRY_HAS_THREAD_STACKWALK + +static bool g_running = false; +static volatile long g_stop = 0; +static sentry_threadid_t g_thread; +static sentry_mutex_t g_wait_mutex = SENTRY__MUTEX_INIT; +static sentry_cond_t g_wait_cond; +static uint64_t g_timeout_ms = 0; + +# define SENTRY_APP_HANG_POLL_MS 500 + +static size_t +stackwalk_thread(uint64_t tid, void **ips, size_t max) +{ + // A test installs an override. + return g_stackwalk_override != NULL + ? g_stackwalk_override(tid, ips, max) + : sentry__thread_stackwalk(tid, ips, max); +} + +static bool +app_hang_capture(uint64_t hang_time_ms, uint64_t tid) +{ + void *ips[SENTRY_APP_HANG_MAX_FRAMES]; + size_t n = stackwalk_thread(tid, ips, SENTRY_APP_HANG_MAX_FRAMES); + if (n == 0) { + SENTRY_DEBUG("app-hang: no frames sampled, skipping event"); + return false; + } + sentry_value_t event = sentry__app_hang_make_event(ips, n, hang_time_ms); + sentry__capture_event(event, NULL); + return true; +} + +SENTRY_THREAD_FN +worker(void *arg) +{ + (void)arg; + uint64_t last_fired_hb = 0; + while (!sentry__atomic_fetch(&g_stop)) { + sentry__mutex_lock(&g_wait_mutex); + sentry__cond_wait_timeout( + &g_wait_cond, &g_wait_mutex, SENTRY_APP_HANG_POLL_MS); + sentry__mutex_unlock(&g_wait_mutex); + + if (sentry__atomic_fetch(&g_stop)) { + break; + } + + const sentry_app_hang_latch_t latch = sentry__app_hang_current_latch(); + uint64_t now = sentry__monotonic_time(); + if (sentry__app_hang_should_capture( + latch.last_heartbeat_ms, now, g_timeout_ms, last_fired_hb)) { + // Only mark this freeze as fired when an event was actually + // captured. A transient stackwalk failure (0 frames) must not + // suppress retries while the thread remains stuck. + if (app_hang_capture( + now - latch.last_heartbeat_ms, latch.target_tid)) { + last_fired_hb = latch.last_heartbeat_ms; + } + } + } + return 0; +} + +int +sentry__app_hang_monitor_start(const sentry_options_t *options) +{ + if (g_running || !options) { + return 0; + } + + g_timeout_ms = options->app_hang_timeout_ms; + if (g_timeout_ms == 0) { + SENTRY_WARN("app-hang: `app_hang_timeout_ms` is 0, hang detection is " + "disabled"); + } + sentry__atomic_store(&g_stop, 0); + sentry__cond_init(&g_wait_cond); + if (sentry__thread_spawn(&g_thread, worker, NULL) != 0) { + SENTRY_WARN("app-hang: failed to spawn watchdog thread"); + return 1; + } + + g_running = true; + sentry__app_hang_set_active(true); + SENTRY_DEBUG("app-hang watchdog started"); + return 0; +} + +void +sentry__app_hang_monitor_stop(void) +{ + if (!g_running) { + return; + } + sentry__app_hang_set_active(false); + sentry__atomic_store(&g_stop, 1); + sentry__mutex_lock(&g_wait_mutex); + sentry__cond_wake(&g_wait_cond); + sentry__mutex_unlock(&g_wait_mutex); + sentry__thread_join(g_thread); + sentry__thread_free(&g_thread); + sentry__app_hang_latch_reset(); + g_running = false; + // g_timeout_ms are intentionally NOT cleared here: the worker + // (now joined) is their only reader, and start() always re-sets them. + SENTRY_DEBUG("app-hang watchdog stopped"); +} + +#else // !SENTRY_HAS_THREAD_STACKWALK + +// No thread stackwalker on this platform: a fired hang could only produce +// frameless events, so the watchdog is never started and stop is a no-op. +int +sentry__app_hang_monitor_start(const sentry_options_t *options) +{ + (void)options; + SENTRY_DEBUG( + "app-hang: no thread stackwalker for this platform, not starting"); + return 0; +} + +void +sentry__app_hang_monitor_stop(void) +{ +} + +#endif // SENTRY_HAS_THREAD_STACKWALK diff --git a/src/sentry_app_hang_monitor.h b/src/sentry_app_hang_monitor.h new file mode 100644 index 0000000000..cef1c0b4de --- /dev/null +++ b/src/sentry_app_hang_monitor.h @@ -0,0 +1,20 @@ +#ifndef SENTRY_APP_HANG_MONITOR_H_INCLUDED +#define SENTRY_APP_HANG_MONITOR_H_INCLUDED + +#include "sentry_boot.h" + +#include +#include + +struct sentry_options_s; + +int sentry__app_hang_monitor_start(const struct sentry_options_s *options); +void sentry__app_hang_monitor_stop(void); + +// Test hook: overrides the platform thread stackwalker used by the watchdog. +typedef size_t (*sentry__app_hang_stackwalk_fn)( + uint64_t target_tid, void **ips_out, size_t max_frames); +void sentry__app_hang_monitor_set_stackwalk_fn( + sentry__app_hang_stackwalk_fn fn); + +#endif diff --git a/src/sentry_core.c b/src/sentry_core.c index 68afa41299..9b19cc9d16 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -3,6 +3,7 @@ #include #include +#include "sentry_app_hang_monitor.h" #include "sentry_attachment.h" #include "sentry_backend.h" #include "sentry_client_report.h" @@ -110,6 +111,13 @@ sentry_init(sentry_options_t *options) sentry_transport_t *transport = NULL; SENTRY__MUTEX_INIT_DYN_ONCE(g_options_lock); + // Stop the app hang watchdog before locking options. The watchdog thread + // calls sentry__capture_event which acquires g_options_lock; joining it + // while holding the lock would deadlock. The sentry_close() below would + // otherwise stop it while we still hold the lock. It's a no-op when the + // watchdog isn't running. + sentry__app_hang_monitor_stop(); + // this function is to be called only once, so we do not allow more than one // caller sentry__mutex_lock(&g_options_lock); @@ -261,6 +269,10 @@ sentry_init(sentry_options_t *options) sentry__metrics_startup(options); } + if (options->enable_app_hang_tracking) { + sentry__app_hang_monitor_start(options); + } + sentry__mutex_unlock(&g_options_lock); return 0; @@ -314,6 +326,11 @@ sentry_close(void) } } + // Stop it before locking options to prevent a deadlock. The watchdog + // thread acquires the g_options_lock when it calls sentry__capture_event. + // It's a no-op when disabled. + sentry__app_hang_monitor_stop(); + SENTRY__MUTEX_INIT_DYN_ONCE(g_options_lock); // this function is to be called only once, so we do not allow more than one // caller diff --git a/src/sentry_options.c b/src/sentry_options.c index 4fcc9970d7..7f44134225 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -113,6 +113,8 @@ sentry_options_new(void) = SENTRY_CRASH_REPORTING_MODE_NATIVE_WITH_MINIDUMP; // Default: best of // both worlds opts->crash_upload_mode = SENTRY_CRASH_UPLOAD_MODE_SYNC; + opts->enable_app_hang_tracking = false; + opts->app_hang_timeout_ms = 5000; opts->http_retry = false; opts->send_client_reports = true; opts->enable_large_attachments = false; @@ -964,6 +966,30 @@ sentry_options_get_enable_metrics(const sentry_options_t *opts) return opts->enable_metrics; } +void +sentry_options_set_enable_app_hang_tracking(sentry_options_t *opts, int enable) +{ + opts->enable_app_hang_tracking = !!enable; +} + +int +sentry_options_get_enable_app_hang_tracking(const sentry_options_t *opts) +{ + return opts->enable_app_hang_tracking; +} + +void +sentry_options_set_app_hang_timeout_ms(sentry_options_t *opts, uint64_t millis) +{ + opts->app_hang_timeout_ms = millis; +} + +uint64_t +sentry_options_get_app_hang_timeout_ms(const sentry_options_t *opts) +{ + return opts->app_hang_timeout_ms; +} + void sentry_options_set_enable_large_attachments( sentry_options_t *opts, int enable_large_attachments) diff --git a/src/sentry_options.h b/src/sentry_options.h index 6f64bba436..d70b6f0685 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -85,6 +85,8 @@ struct sentry_options_s { bool enable_metrics; sentry_before_send_metric_function_t before_send_metric_func; void *before_send_metric_data; + bool enable_app_hang_tracking; + uint64_t app_hang_timeout_ms; bool http_retry; bool send_client_reports; bool enable_large_attachments; diff --git a/src/sentry_sync.h b/src/sentry_sync.h index b7ef27480d..adc904fb28 100644 --- a/src/sentry_sync.h +++ b/src/sentry_sync.h @@ -431,6 +431,32 @@ sentry__atomic_compare_swap(volatile long *val, long expected, long desired) #endif } +/** + * 64-bit variants of the atomic helpers above. The `long`-based helpers are + * only 32 bits wide on Windows and 32-bit POSIX targets, so callers that need + * a full 64-bit atomic (e.g. a monotonic timestamp that must not tear on a + * 32-bit platform) use these instead. + */ +static inline void +sentry__atomic_store_u64(uint64_t *val, uint64_t value) +{ +#ifdef SENTRY_PLATFORM_WINDOWS + InterlockedExchange64((volatile LONG64 *)val, (LONG64)value); +#else + __atomic_store_n(val, value, __ATOMIC_SEQ_CST); +#endif +} + +static inline uint64_t +sentry__atomic_fetch_u64(uint64_t *val) +{ +#ifdef SENTRY_PLATFORM_WINDOWS + return (uint64_t)InterlockedCompareExchange64((volatile LONG64 *)val, 0, 0); +#else + return __atomic_load_n(val, __ATOMIC_SEQ_CST); +#endif +} + struct sentry_bgworker_s; typedef struct sentry_bgworker_s sentry_bgworker_t; diff --git a/src/sentry_thread_stackwalk.h b/src/sentry_thread_stackwalk.h new file mode 100644 index 0000000000..ee4e907696 --- /dev/null +++ b/src/sentry_thread_stackwalk.h @@ -0,0 +1,26 @@ +#ifndef SENTRY_THREAD_STACKWALK_H_INCLUDED +#define SENTRY_THREAD_STACKWALK_H_INCLUDED + +#include "sentry_boot.h" + +#include +#include + +// A platform thread stackwalker is compiled in only for the targets below. +// On any other target `sentry__thread_stackwalk` has no definition. +#if defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_WINDOWS) \ + || defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +# define SENTRY_HAS_THREAD_STACKWALK 1 +#else +# define SENTRY_HAS_THREAD_STACKWALK 0 +#endif + +#if SENTRY_HAS_THREAD_STACKWALK +// Captures the call stack of another thread by suspending it, walking its +// stack, and writing the instruction pointers into `ips_out` (IPs only; no +// symbolication while suspended). Returns the number of frames captured. +size_t sentry__thread_stackwalk( + uint64_t target_tid, void **ips_out, size_t max_frames); +#endif + +#endif diff --git a/src/sentry_thread_stackwalk_mach.c b/src/sentry_thread_stackwalk_mach.c new file mode 100644 index 0000000000..c8b0d9b75c --- /dev/null +++ b/src/sentry_thread_stackwalk_mach.c @@ -0,0 +1,80 @@ +#include "sentry_boot.h" +#include "sentry_thread_stackwalk.h" + +#if defined(SENTRY_PLATFORM_MACOS) + +# include "sentry.h" +# include "sentry_logger.h" + +# include +# include +# include + +size_t +sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) +{ + task_t task = mach_task_self(); + thread_act_array_t threads = NULL; + mach_msg_type_number_t count = 0; + if (task_threads(task, &threads, &count) != KERN_SUCCESS) { + return 0; + } + + thread_t target = MACH_PORT_NULL; + for (mach_msg_type_number_t i = 0; i < count; i++) { + thread_identifier_info_data_t id_info; + mach_msg_type_number_t id_count = THREAD_IDENTIFIER_INFO_COUNT; + if (thread_info(threads[i], THREAD_IDENTIFIER_INFO, + (thread_info_t)&id_info, &id_count) + == KERN_SUCCESS + && id_info.thread_id == target_tid) { + if (target != MACH_PORT_NULL) { + mach_port_deallocate(task, target); + } + target = threads[i]; + } else { + mach_port_deallocate(task, threads[i]); + } + } + vm_deallocate(task, (vm_address_t)threads, count * sizeof(thread_t)); + + if (target == MACH_PORT_NULL) { + return 0; + } + + size_t n = 0; + if (thread_suspend(target) == KERN_SUCCESS) { + // Capture register state into a ucontext and unwind via the existing + // local unwinder. IPs only; no symbolication while suspended. + _STRUCT_MCONTEXT mctx; + memset(&mctx, 0, sizeof(mctx)); + kern_return_t kr; +# if defined(__aarch64__) + mach_msg_type_number_t sc = ARM_THREAD_STATE64_COUNT; + kr = thread_get_state( + target, ARM_THREAD_STATE64, (thread_state_t)&mctx.__ss, &sc); +# elif defined(__x86_64__) + mach_msg_type_number_t sc = x86_THREAD_STATE64_COUNT; + kr = thread_get_state( + target, x86_THREAD_STATE64, (thread_state_t)&mctx.__ss, &sc); +# else + kr = KERN_FAILURE; +# endif + if (kr == KERN_SUCCESS) { + ucontext_t uc; + memset(&uc, 0, sizeof(uc)); + uc.uc_mcontext = &mctx; + sentry_ucontext_t s; + memset(&s, 0, sizeof(s)); + s.user_context = &uc; + n = sentry_unwind_stack_from_ucontext(&s, ips, max); + } else { + SENTRY_DEBUGF("app-hang: thread_get_state failed: %d", kr); + } + thread_resume(target); // ALWAYS resume + } + mach_port_deallocate(task, target); + return n; +} + +#endif diff --git a/src/sentry_thread_stackwalk_posix.c b/src/sentry_thread_stackwalk_posix.c new file mode 100644 index 0000000000..7824ee35ed --- /dev/null +++ b/src/sentry_thread_stackwalk_posix.c @@ -0,0 +1,249 @@ +#include "sentry_thread_stackwalk.h" + +#include "sentry_boot.h" + +#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) + +# include "sentry_app_hang_latch.h" // SENTRY_APP_HANG_MAX_FRAMES +# include "sentry_logger.h" + +# include +# include +# include +# include +# include +# include +# include + +# if defined(SENTRY_WITH_UNWINDER_LIBUNWIND) +# define UNW_LOCAL_ONLY +# include +# endif + +# if defined(SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) +# include "sentry.h" +# endif + +# define SENTRY_APP_HANG_SIGNAL (SIGRTMIN + 4) + +static sem_t g_done; +static volatile sig_atomic_t g_active = 0; +static void *g_ips[SENTRY_APP_HANG_MAX_FRAMES]; +static volatile sig_atomic_t g_count = 0; +static volatile sig_atomic_t g_want = 0; + +# if defined(SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) +static sem_t g_uctx_ready; // handler -> watchdog: parked, uctx valid +static sem_t + g_unwind_done; // watchdog -> handler: unwind complete, you may return +static volatile sig_atomic_t g_abort_park = 0; +static ucontext_t *volatile g_park_uctx = NULL; +# endif + +static void +handler(int sig, siginfo_t *info, void *ucontext) +{ + (void)sig; + (void)info; + // The handler interrupts arbitrary code on the target thread; preserve its + // errno so the calls below (sem_*, unw_*) don't leak a value back to it. + const int saved_errno = errno; + if (!__atomic_load_n(&g_active, __ATOMIC_ACQUIRE)) { + errno = saved_errno; + return; // stray/late delivery; ignore + } + size_t n = 0; +# if defined(SENTRY_WITH_UNWINDER_LIBUNWIND) + // This duplicates the unwind loop in sentry__unwind_stack_libunwind rather + // than calling it, because we are inside a signal handler on the target + // thread: the shared unwinder is not async-signal-safe (it calls + // SENTRY_WARN and open("/proc/self/maps") for SP validation). libunwind has + // no API to local-unwind another thread off-thread, so we must walk in the + // handler with this trimmed, signal-safe loop. The libunwindstack path + // below sidesteps this by parking the handler and unwinding on the watchdog + // thread, which is why it can reuse sentry_unwind_stack_from_ucontext. + unw_cursor_t cursor; + if (unw_init_local2( + &cursor, (unw_context_t *)ucontext, UNW_INIT_SIGNAL_FRAME) + == 0) { + while (n < (size_t)__atomic_load_n(&g_want, __ATOMIC_RELAXED)) { + unw_word_t ip = 0; + if (unw_get_reg(&cursor, UNW_REG_IP, &ip) < 0 || ip == 0) { + break; + } + g_ips[n++] = (void *)(uintptr_t)ip; + if (unw_step(&cursor) <= 0) { + break; + } + } + } +# elif defined(SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) + g_park_uctx = (ucontext_t *)ucontext; + sem_post(&g_uctx_ready); + for (;;) { + if (sem_wait(&g_unwind_done) == 0) { + break; + } + if (errno != EINTR || g_abort_park) { + break; + } + } + g_park_uctx = NULL; + // watchdog wrote g_ips/g_count before releasing us + n = (size_t)__atomic_load_n(&g_count, __ATOMIC_RELAXED); +# else + (void)ucontext; +# endif + __atomic_store_n(&g_count, (sig_atomic_t)n, __ATOMIC_RELAXED); + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); + sem_post(&g_done); + errno = saved_errno; +} + +static bool g_installed = false; +static bool g_sem_initialized = false; + +static bool +ensure_installed(void) +{ + if (g_installed) { + return true; + } + if (!g_sem_initialized) { + // Semaphore is process-lifetime; intentionally never sem_destroy'd. + if (sem_init(&g_done, 0, 0) != 0) { + SENTRY_DEBUG("app-hang: sem_init failed"); + return false; + } +# if defined(SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) + if (sem_init(&g_uctx_ready, 0, 0) != 0) { + SENTRY_DEBUG("app-hang: sem_init(g_uctx_ready) failed"); + // Tear down the semaphores already initialized so a later retry + // starts from a clean slate instead of re-initializing g_done + // (re-init of a live semaphore is undefined behavior). + sem_destroy(&g_done); + return false; + } + if (sem_init(&g_unwind_done, 0, 0) != 0) { + SENTRY_DEBUG("app-hang: sem_init(g_unwind_done) failed"); + sem_destroy(&g_done); + sem_destroy(&g_uctx_ready); + return false; + } +# endif + g_sem_initialized = true; + } + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = handler; + sa.sa_flags = SA_SIGINFO | SA_RESTART; + sigemptyset(&sa.sa_mask); + if (sigaction(SENTRY_APP_HANG_SIGNAL, &sa, NULL) != 0) { + SENTRY_DEBUG("app-hang: sigaction failed"); + return false; + } +# if defined(SENTRY_WITH_UNWINDER_LIBUNWIND) + // Prime the unwinder cache so the in-handler unwind never triggers + // dl_iterate_phdr for the first time inside the signal handler. + unw_context_t uc; + unw_cursor_t cur; +# ifdef __clang__ +// This pragma is required to build with Werror on ARM64 Ubuntu +# pragma clang diagnostic push +# pragma clang diagnostic ignored \ + "-Wgnu-statement-expression-from-macro-expansion" +# endif + int got_context = unw_getcontext(&uc); +# ifdef __clang__ +# pragma clang diagnostic pop +# endif + if (got_context == 0 && unw_init_local(&cur, &uc) == 0) { + for (int i = 0; i < 5 && unw_step(&cur) > 0; i++) { } + } +# endif + g_installed = true; + return true; +} + +size_t +sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) +{ + if (!ensure_installed()) { + return 0; + } + // A signal from a previously timed-out sample may still be queued. Drain + // any stale post here. If a late handler runs during THIS sample it simply + // captures the current (correct) thread state; the worst case is one + // wasted timeout on the following cycle. Bounded and benign. + while (sem_trywait(&g_done) == 0) { } +# if defined(SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) + // Drain stale rendezvous tokens left by a previously timed-out cycle, and + // take ownership of the abort flag here (not in the handler) so a late + // handler cannot clear an abort we are about to set. + while (sem_trywait(&g_uctx_ready) == 0) { } + while (sem_trywait(&g_unwind_done) == 0) { } + g_abort_park = 0; +# endif + + __atomic_store_n(&g_want, + (sig_atomic_t)(max < SENTRY_APP_HANG_MAX_FRAMES + ? max + : SENTRY_APP_HANG_MAX_FRAMES), + __ATOMIC_RELAXED); + __atomic_store_n(&g_count, 0, __ATOMIC_RELAXED); + // Release: publishes the g_want/g_count writes above to the handler, which + // observes them via the acquire-load of g_active on signal entry. + __atomic_store_n(&g_active, 1, __ATOMIC_RELEASE); + + if (syscall(SYS_tgkill, getpid(), (pid_t)target_tid, SENTRY_APP_HANG_SIGNAL) + != 0) { + SENTRY_DEBUGF("app-hang: tgkill(%d) failed: %s", (int)target_tid, + strerror(errno)); + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); + return 0; + } + +# if defined(SENTRY_WITH_UNWINDER_LIBUNWINDSTACK) + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += 1; + if (sem_timedwait(&g_uctx_ready, &ts) != 0) { + g_abort_park = 1; + sem_post(&g_unwind_done); // release a handler that parks late + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); + return 0; + } + sentry_ucontext_t s; + memset(&s, 0, sizeof(s)); + s.user_context = g_park_uctx; + size_t n = sentry_unwind_stack_from_ucontext(&s, ips, max); + __atomic_store_n(&g_count, (sig_atomic_t)n, __ATOMIC_RELAXED); + sem_post(&g_unwind_done); // release the parked handler + struct timespec ts2; + clock_gettime(CLOCK_REALTIME, &ts2); + ts2.tv_sec += 1; + while (sem_timedwait(&g_done, &ts2) != 0 && errno == EINTR) { } + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); + return n; // ips already filled by sentry_unwind_stack_from_ucontext +# else + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += 1; // 1s budget for the handler to run + while (sem_timedwait(&g_done, &ts) != 0) { + if (errno == EINTR) { + continue; + } + // timed out (e.g. thread in uninterruptible sleep) + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); + return 0; + } + + size_t n = (size_t)__atomic_load_n(&g_count, __ATOMIC_RELAXED); + for (size_t i = 0; i < n && i < max; i++) { + ips[i] = g_ips[i]; + } + return n; +# endif +} + +#endif diff --git a/src/sentry_thread_stackwalk_windows.c b/src/sentry_thread_stackwalk_windows.c new file mode 100644 index 0000000000..828a6f7afd --- /dev/null +++ b/src/sentry_thread_stackwalk_windows.c @@ -0,0 +1,57 @@ +#include "sentry_thread_stackwalk.h" + +#include "sentry_boot.h" + +#if defined(SENTRY_PLATFORM_WINDOWS) + +# include "sentry.h" +# include "sentry_logger.h" + +# include +# include + +size_t +sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) +{ + HANDLE h = OpenThread( + THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION, + FALSE, (DWORD)target_tid); + if (!h) { + return 0; + } + + size_t n = 0; + if (SuspendThread(h) != (DWORD)-1) { + CONTEXT ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.ContextFlags = CONTEXT_FULL; + if (GetThreadContext(h, &ctx)) { // forces the suspend to complete + EXCEPTION_RECORD er; + memset(&er, 0, sizeof(er)); + EXCEPTION_POINTERS ep; + ep.ExceptionRecord = &er; + ep.ContextRecord = &ctx; + sentry_ucontext_t s; + memset(&s, 0, sizeof(s)); + s.exception_ptrs = ep; + // NOTE: the shared dbghelp unwinder drives StackWalk64 with + // GetCurrentThread() (the watchdog's pseudo-handle), not the + // suspended target thread. On x64 the walk is CONTEXT-driven and + // reads the target's stack from the shared process address space, + // so the captured IPs are correct. On x86 (frame-pointer mode) and + // ARM64, StackWalk64 may use the thread handle for register + // updates, so cross-thread unwinding there needs the target handle + // threaded through the unwinder. Tracked as a follow-up; x64 is the + // validated path for this experimental feature. + n = sentry_unwind_stack_from_ucontext(&s, ips, max); + } else { + SENTRY_DEBUGF( + "app-hang: GetThreadContext failed: %lu", GetLastError()); + } + ResumeThread(h); // ALWAYS resume + } + CloseHandle(h); + return n; +} + +#endif diff --git a/tests/test_unit.py b/tests/test_unit.py index f12d476315..a170e33116 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -4,9 +4,21 @@ from .conditions import has_http +def _skip_if_unsupported(unittest): + # app_hang_end_to_end drives the real cross-thread RT-signal sampler and + # unwinds a signal frame inside the handler. qemu-user does not emulate + # thread-targeted signal delivery/unwinding faithfully, so the sample never + # produces frames. It runs natively (incl. native arm64); skip only on qemu. + if unittest == "app_hang_end_to_end" and os.environ.get("TEST_QEMU"): + pytest.skip( + "app_hang_end_to_end requires real signal delivery (unsupported under qemu-user)" + ) + + def test_unit(cmake, unittest): if unittest in ["basic_transport_thread_name", "cache_keep"]: pytest.skip("excluded from unit test-suite") + _skip_if_unsupported(unittest) cwd = cmake( ["sentry_test_unit"], {"SENTRY_BACKEND": "none", "SENTRY_TRANSPORT": "none"}, @@ -23,6 +35,7 @@ def test_unit_transport(cmake, unittest): "logger_level", ]: pytest.skip("excluded from transport test-suite") + _skip_if_unsupported(unittest) cwd = cmake( ["sentry_test_unit"], @@ -35,6 +48,7 @@ def test_unit_transport(cmake, unittest): def test_unit_with_test_path(cmake, unittest): if unittest in ["basic_transport_thread_name", "cache_keep"]: pytest.skip("excluded from unit test-suite") + _skip_if_unsupported(unittest) cwd = cmake( ["sentry_test_unit"], {"SENTRY_BACKEND": "none", "SENTRY_TRANSPORT": "none"}, diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 5ba40746f7..b3689ba7d7 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(sentry_test_unit ${SENTRY_SOURCES} main.c sentry_testsupport.h + test_app_hang.c test_attachments.c test_basic.c test_cache.c diff --git a/tests/unit/test_app_hang.c b/tests/unit/test_app_hang.c new file mode 100644 index 0000000000..86509759a1 --- /dev/null +++ b/tests/unit/test_app_hang.c @@ -0,0 +1,194 @@ +#include "sentry_app_hang_latch.h" +#include "sentry_app_hang_monitor.h" +#include "sentry_sync.h" +#include "sentry_testsupport.h" + +SENTRY_TEST(app_hang_should_capture) +{ + // disabled (timeout == 0) -> never captures + TEST_CHECK(!sentry__app_hang_should_capture(100, 100000, 0, 0)); + // never heartbeated (hb == 0) -> no capture + TEST_CHECK(!sentry__app_hang_should_capture(0, 100000, 2000, 0)); + // fresh heartbeat (within timeout) -> no capture + TEST_CHECK(!sentry__app_hang_should_capture(99000, 100000, 2000, 0)); + // stale (now - hb >= timeout) -> capture + TEST_CHECK(sentry__app_hang_should_capture(98000, 100000, 2000, 0)); + // already fired for this hb -> cooldown, no capture + TEST_CHECK(!sentry__app_hang_should_capture(98000, 100000, 2000, 98000)); +} + +SENTRY_TEST(app_hang_latch) +{ + sentry__app_hang_latch_reset(); + sentry__app_hang_set_active(true); + sentry_app_hang_latch_t l = sentry__app_hang_current_latch(); + TEST_CHECK(l.target_tid == 0); + TEST_CHECK(l.last_heartbeat_ms == 0); + + // first heartbeat latches the calling thread + records a timestamp + sentry_app_hang_heartbeat(); + l = sentry__app_hang_current_latch(); + TEST_CHECK(l.target_tid == sentry__app_hang_current_tid()); + TEST_CHECK(l.target_tid != 0); + uint64_t first = l.last_heartbeat_ms; + TEST_CHECK(first != 0); + + sentry__app_hang_latch_reset(); + l = sentry__app_hang_current_latch(); + TEST_CHECK(l.target_tid == 0); + sentry__app_hang_set_active(false); +} + +SENTRY_TEST(app_hang_make_event) +{ + void *ips[2] = { (void *)0x1000, (void *)0x2000 }; + sentry_value_t ev = sentry__app_hang_make_event(ips, 2, 5000); + + sentry_value_t exc = sentry_value_get_by_index( + sentry_value_get_by_key( + sentry_value_get_by_key(ev, "exception"), "values"), + 0); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key(exc, "type")), + "AppHang"); + TEST_CHECK_STRING_EQUAL( + sentry_value_as_string(sentry_value_get_by_key( + sentry_value_get_by_key(exc, "mechanism"), "type")), + "AppHang"); + TEST_CHECK(sentry_value_is_true(sentry_value_get_by_key( + sentry_value_get_by_key(exc, "mechanism"), "handled"))); + sentry_value_t frames = sentry_value_get_by_key( + sentry_value_get_by_key(exc, "stacktrace"), "frames"); + TEST_CHECK(sentry_value_get_length(frames) == 2); + + sentry_value_decref(ev); +} + +static long g_app_hang_seen; +static char g_app_hang_type[32]; + +static size_t +fake_stackwalk(uint64_t tid, void **ips, size_t max) +{ + (void)tid; + if (max < 2) { + return 0; + } + ips[0] = (void *)0x4000; + ips[1] = (void *)0x5000; + return 2; +} + +static sentry_value_t +capture_before_send(sentry_value_t event, void *hint, void *data) +{ + (void)hint; + (void)data; + sentry_value_t exc = sentry_value_get_by_index( + sentry_value_get_by_key( + sentry_value_get_by_key(event, "exception"), "values"), + 0); + const char *type + = sentry_value_as_string(sentry_value_get_by_key(exc, "type")); + if (type) { + strncpy(g_app_hang_type, type, sizeof(g_app_hang_type) - 1); + } + sentry__atomic_store(&g_app_hang_seen, 1); + sentry_value_decref(event); + return sentry_value_new_null(); +} + +SENTRY_TEST(app_hang_monitor_fires) +{ + g_app_hang_seen = 0; + g_app_hang_type[0] = '\0'; + sentry__app_hang_latch_reset(); + sentry__app_hang_monitor_set_stackwalk_fn(fake_stackwalk); + + sentry_options_t *options = sentry_options_new(); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_before_send(options, capture_before_send, NULL); + sentry_options_set_enable_app_hang_tracking(options, 1); + sentry_options_set_app_hang_timeout_ms(options, 50); + sentry_init(options); + + sentry_app_hang_heartbeat(); + + for (int i = 0; i < 300 && !sentry__atomic_fetch(&g_app_hang_seen); i++) { + sleep_ms(10); + } + + TEST_CHECK(sentry__atomic_fetch(&g_app_hang_seen) == 1); + TEST_CHECK_STRING_EQUAL(g_app_hang_type, "AppHang"); + + sentry_close(); + sentry__app_hang_monitor_set_stackwalk_fn(NULL); +} + +static long g_real_seen; +static long g_real_frames; +static volatile long g_keep_spinning; + +static sentry_value_t +real_before_send(sentry_value_t event, void *hint, void *data) +{ + (void)hint; + (void)data; + sentry_value_t exc = sentry_value_get_by_index( + sentry_value_get_by_key( + sentry_value_get_by_key(event, "exception"), "values"), + 0); + sentry_value_t frames = sentry_value_get_by_key( + sentry_value_get_by_key(exc, "stacktrace"), "frames"); + sentry__atomic_store(&g_real_frames, (long)sentry_value_get_length(frames)); + sentry__atomic_store(&g_real_seen, 1); + sentry_value_decref(event); + return sentry_value_new_null(); +} + +SENTRY_THREAD_FN +spinner(void *arg) +{ + (void)arg; + sentry_app_hang_heartbeat(); // latch this thread + while (sentry__atomic_fetch(&g_keep_spinning)) { + // busy-wait: alive & sampleable but never heartbeats again -> hung + volatile int x = 0; + for (int i = 0; i < 100000; i++) { + x += i; + } + } + return 0; +} + +SENTRY_TEST(app_hang_end_to_end) +{ + g_real_seen = 0; + g_real_frames = 0; + sentry__atomic_store(&g_keep_spinning, 1); + sentry__app_hang_latch_reset(); + sentry__app_hang_monitor_set_stackwalk_fn(NULL); // use the REAL stackwalker + + sentry_options_t *options = sentry_options_new(); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_before_send(options, real_before_send, NULL); + sentry_options_set_enable_app_hang_tracking(options, 1); + sentry_options_set_app_hang_timeout_ms(options, 50); + sentry_init(options); + + sentry_threadid_t t; + sentry__thread_spawn(&t, spinner, NULL); + + for (int i = 0; i < 500 && !sentry__atomic_fetch(&g_real_seen); i++) { + sleep_ms(10); + } + + sentry__atomic_store(&g_keep_spinning, 0); + sentry__thread_join(t); + + TEST_CHECK(sentry__atomic_fetch(&g_real_seen) == 1); + TEST_CHECK(sentry__atomic_fetch(&g_real_frames) > 0); + + sentry_close(); + sentry__app_hang_monitor_set_stackwalk_fn(NULL); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index d4e9e185fe..e7d78bd710 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -15,6 +15,11 @@ XX(attachments_add_remove) XX(attachments_bytes) XX(attachments_extend) XX(attachments_more_than_ten) +XX(app_hang_should_capture) +XX(app_hang_latch) +XX(app_hang_make_event) +XX(app_hang_monitor_fires) +XX(app_hang_end_to_end) XX(background_worker) XX(baggage_iter_basic) XX(baggage_iter_case_preserved)