From a5b7c54d90f4f2461517ab696d6585bb3810a706 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 16 Jun 2026 17:01:31 +0200 Subject: [PATCH 01/21] in-proc app hang capture --- include/sentry.h | 40 +++++ src/CMakeLists.txt | 24 +++ src/sentry_app_hang_latch.c | 153 +++++++++++++++++++ src/sentry_app_hang_latch.h | 31 ++++ src/sentry_app_hang_monitor.c | 143 +++++++++++++++++ src/sentry_app_hang_monitor.h | 17 +++ src/sentry_app_hang_sampler.h | 23 +++ src/sentry_app_hang_sampler_mach.c | 80 ++++++++++ src/sentry_app_hang_sampler_posix.c | 212 ++++++++++++++++++++++++++ src/sentry_app_hang_sampler_windows.c | 57 +++++++ src/sentry_core.c | 11 ++ src/sentry_options.c | 26 ++++ src/sentry_options.h | 2 + src/sentry_sync.h | 26 ++++ tests/unit/CMakeLists.txt | 1 + tests/unit/test_app_hang.c | 203 ++++++++++++++++++++++++ tests/unit/tests.inc | 6 + 17 files changed, 1055 insertions(+) create mode 100644 src/sentry_app_hang_latch.c create mode 100644 src/sentry_app_hang_latch.h create mode 100644 src/sentry_app_hang_monitor.c create mode 100644 src/sentry_app_hang_monitor.h create mode 100644 src/sentry_app_hang_sampler.h create mode 100644 src/sentry_app_hang_sampler_mach.c create mode 100644 src/sentry_app_hang_sampler_posix.c create mode 100644 src/sentry_app_hang_sampler_windows.c create mode 100644 tests/unit/test_app_hang.c diff --git a/include/sentry.h b/include/sentry.h index 75d5ebd88a..6c830a50a5 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2651,6 +2651,46 @@ 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 2000 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. + * + * 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 317f7b3ea9..12b954bc9e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,11 @@ 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_app_hang_sampler.h sentry_attachment.c sentry_attachment.h sentry_backend.c @@ -254,6 +259,25 @@ if(SENTRY_WITH_LIBUNWIND_MAC) ) endif() +# app-hang platform sampler +if(APPLE) + sentry_target_sources_cwd(sentry + sentry_app_hang_sampler_mach.c + ) +endif() + +if(WIN32) + sentry_target_sources_cwd(sentry + sentry_app_hang_sampler_windows.c + ) +endif() + +if(LINUX OR ANDROID) + sentry_target_sources_cwd(sentry + sentry_app_hang_sampler_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..920c2b4d9c --- /dev/null +++ b/src/sentry_app_hang_latch.c @@ -0,0 +1,153 @@ +// In-process app-hang detection, thread-side state: the lock-free latch, +// heartbeat API, capture predicate, monotonic clock, and event assembly. These +// run on app threads (the heartbeat hot path) and are read by the watchdog +// worker in sentry_app_hang_monitor.c. +#include "sentry_app_hang_latch.h" +#include "sentry_sync.h" + +#include +#include + +#if defined(SENTRY_PLATFORM_WINDOWS) +# include +#elif defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +# include +# include +# include +#else // SENTRY_PLATFORM_MACOS and other POSIX +# include +# 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; +} + +uint64_t +sentry__app_hang_now_ms(void) +{ +#if defined(SENTRY_PLATFORM_WINDOWS) + return (uint64_t)GetTickCount64(); +#else + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + return 0; + } + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; +#endif +} + +// The latch is touched by app threads (writers, via the heartbeat) and the +// single watchdog worker (reader). Rather than a mutex -- which would put a +// lock on the heartbeat hot path, the very thing we're trying to detect +// stalling -- 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 the sampler argument, and the lone transition is benign, so a +// relaxed read is sufficient; we use the same atomic helpers for clarity. +// +// Both fields use the sentry__atomic_*_u64 helpers, which provide full 64-bit +// atomic access even on 32-bit platforms +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 +} + +void +sentry__app_hang_latch_read(sentry_app_hang_latch_t *out) +{ + out->target_tid = sentry__atomic_fetch_u64(&g_target_tid); + out->last_heartbeat_ms = sentry__atomic_fetch_u64(&g_last_heartbeat_ms); +} + +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__app_hang_now_ms()); + } +} + +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; +} diff --git a/src/sentry_app_hang_latch.h b/src/sentry_app_hang_latch.h new file mode 100644 index 0000000000..3532b29abc --- /dev/null +++ b/src/sentry_app_hang_latch.h @@ -0,0 +1,31 @@ +#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); + +uint64_t sentry__app_hang_now_ms(void); + +typedef struct { + uint64_t target_tid; + uint64_t last_heartbeat_ms; +} sentry_app_hang_latch_t; + +uint64_t sentry__app_hang_current_tid(void); +void sentry__app_hang_latch_read(sentry_app_hang_latch_t *out); +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..2f5192d8e2 --- /dev/null +++ b/src/sentry_app_hang_monitor.c @@ -0,0 +1,143 @@ +#include "sentry_app_hang_monitor.h" + +#include "sentry_app_hang_latch.h" +#include "sentry_app_hang_sampler.h" +#include "sentry_core.h" +#include "sentry_logger.h" +#include "sentry_options.h" +#include "sentry_sync.h" + +#include + +static sentry__app_hang_thread_sampler_fn g_thread_sampler = NULL; + +void +sentry__app_hang_monitor_set_thread_sampler( + sentry__app_hang_thread_sampler_fn fn) +{ + g_thread_sampler = fn; +} + +// Everything below is the watchdog machinery, which only makes sense where a +// platform thread sampler 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_APP_HANG_SAMPLER + +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 +sample_thread(uint64_t tid, void **ips, size_t max) +{ + // A test may install an override; otherwise use the real platform sampler. + return g_thread_sampler != NULL + ? g_thread_sampler(tid, ips, max) + : sentry__app_hang_sample_thread(tid, ips, max); +} + +static void +app_hang_capture(uint64_t hang_time_ms, uint64_t tid) +{ + void *ips[SENTRY_APP_HANG_MAX_FRAMES]; + size_t n = sample_thread(tid, ips, SENTRY_APP_HANG_MAX_FRAMES); + if (n == 0) { + SENTRY_DEBUG("app-hang: no frames sampled, skipping event"); + return; + } + sentry_value_t event = sentry__app_hang_make_event(ips, n, hang_time_ms); + sentry__capture_event(event, NULL); +} + +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; + } + + sentry_app_hang_latch_t latch; + sentry__app_hang_latch_read(&latch); + uint64_t now = sentry__app_hang_now_ms(); + if (sentry__app_hang_should_capture( + latch.last_heartbeat_ms, now, g_timeout_ms, last_fired_hb)) { + 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; + 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__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, so + // clearing would just introduce a data race for no benefit. + SENTRY_DEBUG("app-hang watchdog stopped"); +} + +#else // !SENTRY_HAS_APP_HANG_SAMPLER + +// No thread sampler 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 sampler for this platform, not starting"); + return 0; +} + +void +sentry__app_hang_monitor_stop(void) +{ +} + +#endif // SENTRY_HAS_APP_HANG_SAMPLER diff --git a/src/sentry_app_hang_monitor.h b/src/sentry_app_hang_monitor.h new file mode 100644 index 0000000000..339c71adb1 --- /dev/null +++ b/src/sentry_app_hang_monitor.h @@ -0,0 +1,17 @@ +#ifndef SENTRY_APP_HANG_MONITOR_H_INCLUDED +#define SENTRY_APP_HANG_MONITOR_H_INCLUDED + +#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); + +typedef size_t (*sentry__app_hang_thread_sampler_fn)( + uint64_t target_tid, void **ips_out, size_t max_frames); +void sentry__app_hang_monitor_set_thread_sampler( + sentry__app_hang_thread_sampler_fn fn); + +#endif diff --git a/src/sentry_app_hang_sampler.h b/src/sentry_app_hang_sampler.h new file mode 100644 index 0000000000..0bca1893c2 --- /dev/null +++ b/src/sentry_app_hang_sampler.h @@ -0,0 +1,23 @@ +#ifndef SENTRY_APP_HANG_SAMPLER_H_INCLUDED +#define SENTRY_APP_HANG_SAMPLER_H_INCLUDED + +#include +#include + +// A platform thread sampler is compiled in only for the targets below (see the +// `app-hang platform sampler` block in src/CMakeLists.txt). On any other target +// `sentry__app_hang_sample_thread` has no definition, so the monitor must not +// reference it there. +#if defined(__APPLE__) || defined(_WIN32) || defined(__linux__) \ + || defined(__ANDROID__) +# define SENTRY_HAS_APP_HANG_SAMPLER 1 +#else +# define SENTRY_HAS_APP_HANG_SAMPLER 0 +#endif + +#if SENTRY_HAS_APP_HANG_SAMPLER +size_t sentry__app_hang_sample_thread( + uint64_t target_tid, void **ips_out, size_t max_frames); +#endif + +#endif diff --git a/src/sentry_app_hang_sampler_mach.c b/src/sentry_app_hang_sampler_mach.c new file mode 100644 index 0000000000..c83e2d9ec8 --- /dev/null +++ b/src/sentry_app_hang_sampler_mach.c @@ -0,0 +1,80 @@ +#include "sentry_app_hang_sampler.h" +#include "sentry_boot.h" + +#if defined(SENTRY_PLATFORM_MACOS) + +# include "sentry.h" +# include "sentry_logger.h" + +# include +# include +# include + +size_t +sentry__app_hang_sample_thread(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_app_hang_sampler_posix.c b/src/sentry_app_hang_sampler_posix.c new file mode 100644 index 0000000000..13e310b152 --- /dev/null +++ b/src/sentry_app_hang_sampler_posix.c @@ -0,0 +1,212 @@ +#include "sentry_app_hang_sampler.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; + if (!g_active) { + return; // stray/late delivery; ignore + } + size_t n = 0; +# if defined(SENTRY_WITH_UNWINDER_LIBUNWIND) + unw_cursor_t cursor; + if (unw_init_local2(&cursor, (unw_context_t *)ucontext, + UNW_INIT_SIGNAL_FRAME) + == 0) { + while (n < g_want) { + 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; + n = (size_t)g_count; // watchdog wrote g_ips/g_count before releasing us +# else + (void)ucontext; +# endif + g_count = (sig_atomic_t)n; + g_active = 0; + sem_post(&g_done); +} + +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"); + return false; + } + if (sem_init(&g_unwind_done, 0, 0) != 0) { + SENTRY_DEBUG("app-hang: sem_init(g_unwind_done) failed"); + 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; + if (unw_getcontext(&uc) == 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__app_hang_sample_thread(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 + + g_want = (sig_atomic_t)(max < SENTRY_APP_HANG_MAX_FRAMES ? max : SENTRY_APP_HANG_MAX_FRAMES); + g_count = 0; + g_active = 1; + + 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)); + g_active = 0; + 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 + g_active = 0; + 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); + g_count = (sig_atomic_t)n; + 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) { + } + 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; + } + g_active = 0; // timed out (e.g. thread in uninterruptible sleep) + return 0; + } + + size_t n = g_count; + for (size_t i = 0; i < n && i < max; i++) { + ips[i] = g_ips[i]; + } + return n; +# endif +} + +#endif diff --git a/src/sentry_app_hang_sampler_windows.c b/src/sentry_app_hang_sampler_windows.c new file mode 100644 index 0000000000..327f48f9cf --- /dev/null +++ b/src/sentry_app_hang_sampler_windows.c @@ -0,0 +1,57 @@ +#include "sentry_app_hang_sampler.h" + +#include "sentry_boot.h" + +#if defined(SENTRY_PLATFORM_WINDOWS) + +# include "sentry.h" +# include "sentry_logger.h" + +# include +# include + +size_t +sentry__app_hang_sample_thread(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/src/sentry_core.c b/src/sentry_core.c index 68afa41299..7b40a71f96 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" @@ -261,6 +262,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 +319,12 @@ sentry_close(void) } } + // 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. monitor_stop() is a safe no-op + // when the watchdog was never started. + 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/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index a143fd540a..98bdf747cc 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..11f11d5b7e --- /dev/null +++ b/tests/unit/test_app_hang.c @@ -0,0 +1,203 @@ +#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_now_ms_monotonic) +{ + uint64_t a = sentry__app_hang_now_ms(); + uint64_t b = sentry__app_hang_now_ms(); + TEST_CHECK(b >= a); + TEST_CHECK(a != 0); +} + +SENTRY_TEST(app_hang_latch) +{ + sentry__app_hang_latch_reset(); + sentry__app_hang_set_active(true); + sentry_app_hang_latch_t l = { 0 }; + sentry__app_hang_latch_read(&l); + 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(); + sentry__app_hang_latch_read(&l); + 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(); + sentry__app_hang_latch_read(&l); + 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_sampler(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_thread_sampler(fake_sampler); + + 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_thread_sampler(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_thread_sampler(NULL); // use the REAL sampler + + 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_thread_sampler(NULL); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index ea810758b8..d1b38bab0a 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -15,6 +15,12 @@ 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(app_hang_now_ms_monotonic) XX(background_worker) XX(baggage_iter_basic) XX(baggage_iter_case_preserved) From ddc9e01d04f839443d98544b3dbd8b0e84a0495e Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 16 Jun 2026 17:08:40 +0200 Subject: [PATCH 02/21] fixed comment --- src/sentry_app_hang_latch.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/sentry_app_hang_latch.c b/src/sentry_app_hang_latch.c index 920c2b4d9c..922568ca28 100644 --- a/src/sentry_app_hang_latch.c +++ b/src/sentry_app_hang_latch.c @@ -1,7 +1,9 @@ -// In-process app-hang detection, thread-side state: the lock-free latch, -// heartbeat API, capture predicate, monotonic clock, and event assembly. These -// run on app threads (the heartbeat hot path) and are read by the watchdog -// worker in sentry_app_hang_monitor.c. +// In-process app-hang detection, shared state and helpers. The lock-free latch, +// heartbeat API, capture predicate, and monotonic clock are the app-thread hot +// path: app threads write the latch via the heartbeat, the watchdog worker in +// sentry_app_hang_monitor.c reads it. Event assembly +// (sentry__app_hang_make_event) lives here too but runs on the watchdog worker, +// not on app threads. #include "sentry_app_hang_latch.h" #include "sentry_sync.h" From c46fc68103015c430d71bc0402a4526ee6559af0 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 16 Jun 2026 17:13:12 +0200 Subject: [PATCH 03/21] updated changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cd5267b2..d477ce9aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### 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 watched. ([#1806](https://github.com/getsentry/sentry-native/pull/1806)) + ## 0.15.0 **Breaking**: From 493af7965a290c8cebce78964163c1b71cd8455d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 16 Jun 2026 17:27:32 +0200 Subject: [PATCH 04/21] updated the app hang example --- examples/example.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/examples/example.c b/examples/example.c index d7f80d7a79..d17e54e631 100644 --- a/examples/example.c +++ b/examples/example.c @@ -660,6 +660,14 @@ main(int argc, char **argv) sentry_options_set_enable_large_attachments(options, 1); } + if (has_arg(argc, argv, "app-hang")) { + // Enable in-process app-hang detection with a short timeout so the + // demo triggers quickly. The monitored thread is whichever thread + // first calls sentry_app_hang_heartbeat() (the main thread below). + 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 +1152,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"); From 92b7ec6ce62a2dc6c18bd3f60aef0d9bd2ad1663 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 09:12:59 +0200 Subject: [PATCH 05/21] . --- src/sentry_app_hang_monitor.h | 2 ++ src/sentry_app_hang_sampler.h | 10 +++++++--- src/sentry_app_hang_sampler_posix.c | 2 +- src/sentry_app_hang_sampler_windows.c | 8 ++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/sentry_app_hang_monitor.h b/src/sentry_app_hang_monitor.h index 339c71adb1..4e4ed522b0 100644 --- a/src/sentry_app_hang_monitor.h +++ b/src/sentry_app_hang_monitor.h @@ -1,6 +1,8 @@ #ifndef SENTRY_APP_HANG_MONITOR_H_INCLUDED #define SENTRY_APP_HANG_MONITOR_H_INCLUDED +#include "sentry_boot.h" + #include #include diff --git a/src/sentry_app_hang_sampler.h b/src/sentry_app_hang_sampler.h index 0bca1893c2..ff566a85f8 100644 --- a/src/sentry_app_hang_sampler.h +++ b/src/sentry_app_hang_sampler.h @@ -1,15 +1,19 @@ #ifndef SENTRY_APP_HANG_SAMPLER_H_INCLUDED #define SENTRY_APP_HANG_SAMPLER_H_INCLUDED +#include "sentry_boot.h" + #include #include // A platform thread sampler is compiled in only for the targets below (see the // `app-hang platform sampler` block in src/CMakeLists.txt). On any other target // `sentry__app_hang_sample_thread` has no definition, so the monitor must not -// reference it there. -#if defined(__APPLE__) || defined(_WIN32) || defined(__linux__) \ - || defined(__ANDROID__) +// reference it there. Note that on Apple this is macOS-only: the mach sampler +// guards its implementation with `SENTRY_PLATFORM_MACOS`, so iOS et al. must not +// advertise the capability or the monitor would reference an undefined symbol. +#if defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_WINDOWS) \ + || defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) # define SENTRY_HAS_APP_HANG_SAMPLER 1 #else # define SENTRY_HAS_APP_HANG_SAMPLER 0 diff --git a/src/sentry_app_hang_sampler_posix.c b/src/sentry_app_hang_sampler_posix.c index 13e310b152..d5d2eb9211 100644 --- a/src/sentry_app_hang_sampler_posix.c +++ b/src/sentry_app_hang_sampler_posix.c @@ -53,7 +53,7 @@ handler(int sig, siginfo_t *info, void *ucontext) if (unw_init_local2(&cursor, (unw_context_t *)ucontext, UNW_INIT_SIGNAL_FRAME) == 0) { - while (n < g_want) { + while (n < (size_t)g_want) { unw_word_t ip = 0; if (unw_get_reg(&cursor, UNW_REG_IP, &ip) < 0 || ip == 0) { break; diff --git a/src/sentry_app_hang_sampler_windows.c b/src/sentry_app_hang_sampler_windows.c index 327f48f9cf..823f5a5985 100644 --- a/src/sentry_app_hang_sampler_windows.c +++ b/src/sentry_app_hang_sampler_windows.c @@ -13,8 +13,8 @@ size_t sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) { - HANDLE h = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT - | THREAD_QUERY_INFORMATION, + HANDLE h = OpenThread( + THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION, FALSE, (DWORD)target_tid); if (!h) { return 0; @@ -45,8 +45,8 @@ sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) // validated path for this experimental feature. n = sentry_unwind_stack_from_ucontext(&s, ips, max); } else { - SENTRY_DEBUGF("app-hang: GetThreadContext failed: %lu", - GetLastError()); + SENTRY_DEBUGF( + "app-hang: GetThreadContext failed: %lu", GetLastError()); } ResumeThread(h); // ALWAYS resume } From 4fe992e3cd432c62392461a133dd22f96bff372b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 12:15:33 +0200 Subject: [PATCH 06/21] style --- src/sentry_app_hang_sampler.h | 5 +++-- src/sentry_app_hang_sampler_posix.c | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/sentry_app_hang_sampler.h b/src/sentry_app_hang_sampler.h index ff566a85f8..5a9ffa7810 100644 --- a/src/sentry_app_hang_sampler.h +++ b/src/sentry_app_hang_sampler.h @@ -10,8 +10,9 @@ // `app-hang platform sampler` block in src/CMakeLists.txt). On any other target // `sentry__app_hang_sample_thread` has no definition, so the monitor must not // reference it there. Note that on Apple this is macOS-only: the mach sampler -// guards its implementation with `SENTRY_PLATFORM_MACOS`, so iOS et al. must not -// advertise the capability or the monitor would reference an undefined symbol. +// guards its implementation with `SENTRY_PLATFORM_MACOS`, so iOS et al. must +// not advertise the capability or the monitor would reference an undefined +// symbol. #if defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_WINDOWS) \ || defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) # define SENTRY_HAS_APP_HANG_SAMPLER 1 diff --git a/src/sentry_app_hang_sampler_posix.c b/src/sentry_app_hang_sampler_posix.c index d5d2eb9211..e4e656d175 100644 --- a/src/sentry_app_hang_sampler_posix.c +++ b/src/sentry_app_hang_sampler_posix.c @@ -33,8 +33,9 @@ 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 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 @@ -50,8 +51,8 @@ handler(int sig, siginfo_t *info, void *ucontext) size_t n = 0; # if defined(SENTRY_WITH_UNWINDER_LIBUNWIND) unw_cursor_t cursor; - if (unw_init_local2(&cursor, (unw_context_t *)ucontext, - UNW_INIT_SIGNAL_FRAME) + if (unw_init_local2( + &cursor, (unw_context_t *)ucontext, UNW_INIT_SIGNAL_FRAME) == 0) { while (n < (size_t)g_want) { unw_word_t ip = 0; @@ -154,12 +155,13 @@ sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) g_abort_park = 0; # endif - g_want = (sig_atomic_t)(max < SENTRY_APP_HANG_MAX_FRAMES ? max : SENTRY_APP_HANG_MAX_FRAMES); + g_want = (sig_atomic_t)(max < SENTRY_APP_HANG_MAX_FRAMES + ? max + : SENTRY_APP_HANG_MAX_FRAMES); g_count = 0; g_active = 1; - if (syscall(SYS_tgkill, getpid(), (pid_t)target_tid, - SENTRY_APP_HANG_SIGNAL) + 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)); @@ -186,8 +188,7 @@ sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) struct timespec ts2; clock_gettime(CLOCK_REALTIME, &ts2); ts2.tv_sec += 1; - while (sem_timedwait(&g_done, &ts2) != 0 && errno == EINTR) { - } + while (sem_timedwait(&g_done, &ts2) != 0 && errno == EINTR) { } return n; // ips already filled by sentry_unwind_stack_from_ucontext # else struct timespec ts; From f8fa212bdb3233ba84b8727f80a6b49ba9db1ea7 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 12:32:47 +0200 Subject: [PATCH 07/21] fixed linux race conditions --- src/sentry_app_hang_sampler_posix.c | 40 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/sentry_app_hang_sampler_posix.c b/src/sentry_app_hang_sampler_posix.c index e4e656d175..7a351c2b78 100644 --- a/src/sentry_app_hang_sampler_posix.c +++ b/src/sentry_app_hang_sampler_posix.c @@ -45,7 +45,10 @@ handler(int sig, siginfo_t *info, void *ucontext) { (void)sig; (void)info; - if (!g_active) { + // 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)) { return; // stray/late delivery; ignore } size_t n = 0; @@ -54,7 +57,7 @@ handler(int sig, siginfo_t *info, void *ucontext) if (unw_init_local2( &cursor, (unw_context_t *)ucontext, UNW_INIT_SIGNAL_FRAME) == 0) { - while (n < (size_t)g_want) { + 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; @@ -77,13 +80,15 @@ handler(int sig, siginfo_t *info, void *ucontext) } } g_park_uctx = NULL; - n = (size_t)g_count; // watchdog wrote g_ips/g_count before releasing us + // watchdog wrote g_ips/g_count before releasing us + n = (size_t)__atomic_load_n(&g_count, __ATOMIC_RELAXED); # else (void)ucontext; # endif - g_count = (sig_atomic_t)n; - g_active = 0; + __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; @@ -155,17 +160,21 @@ sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) g_abort_park = 0; # endif - g_want = (sig_atomic_t)(max < SENTRY_APP_HANG_MAX_FRAMES - ? max - : SENTRY_APP_HANG_MAX_FRAMES); - g_count = 0; - g_active = 1; + __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)); - g_active = 0; + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); return 0; } @@ -176,14 +185,14 @@ sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) if (sem_timedwait(&g_uctx_ready, &ts) != 0) { g_abort_park = 1; sem_post(&g_unwind_done); // release a handler that parks late - g_active = 0; + __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); - g_count = (sig_atomic_t)n; + __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); @@ -198,11 +207,12 @@ sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) if (errno == EINTR) { continue; } - g_active = 0; // timed out (e.g. thread in uninterruptible sleep) + // timed out (e.g. thread in uninterruptible sleep) + __atomic_store_n(&g_active, 0, __ATOMIC_RELEASE); return 0; } - size_t n = g_count; + 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]; } From a187a1f816a86f49f33ef4c6c5db813648097ca3 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 13:29:49 +0200 Subject: [PATCH 08/21] . --- src/sentry_app_hang_sampler_posix.c | 12 +++++++++++- tests/test_unit.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/sentry_app_hang_sampler_posix.c b/src/sentry_app_hang_sampler_posix.c index 7a351c2b78..e2866a921f 100644 --- a/src/sentry_app_hang_sampler_posix.c +++ b/src/sentry_app_hang_sampler_posix.c @@ -132,7 +132,17 @@ ensure_installed(void) // dl_iterate_phdr for the first time inside the signal handler. unw_context_t uc; unw_cursor_t cur; - if (unw_getcontext(&uc) == 0 && unw_init_local(&cur, &uc) == 0) { +# 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 diff --git a/tests/test_unit.py b/tests/test_unit.py index f12d476315..2536795e99 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -4,9 +4,19 @@ 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 +33,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 +46,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"}, From 2c784da4dc85d0adfbb62e907967e0ff6225b224 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 13:33:22 +0200 Subject: [PATCH 09/21] style --- tests/test_unit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_unit.py b/tests/test_unit.py index 2536795e99..a170e33116 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -10,7 +10,9 @@ def _skip_if_unsupported(unittest): # 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)") + pytest.skip( + "app_hang_end_to_end requires real signal delivery (unsupported under qemu-user)" + ) def test_unit(cmake, unittest): From 25c47caf1ac7af6439150025f11716318de726ee Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 16:53:12 +0200 Subject: [PATCH 10/21] renamed better --- src/CMakeLists.txt | 10 ++--- src/sentry_app_hang_monitor.c | 41 ++++++++++--------- src/sentry_app_hang_monitor.h | 8 ++-- src/sentry_app_hang_sampler.h | 28 ------------- src/sentry_core.c | 3 +- src/sentry_thread_stackwalk.h | 26 ++++++++++++ ..._mach.c => sentry_thread_stackwalk_mach.c} | 4 +- ...osix.c => sentry_thread_stackwalk_posix.c} | 12 +++++- ...ws.c => sentry_thread_stackwalk_windows.c} | 4 +- tests/unit/test_app_hang.c | 10 ++--- 10 files changed, 77 insertions(+), 69 deletions(-) delete mode 100644 src/sentry_app_hang_sampler.h create mode 100644 src/sentry_thread_stackwalk.h rename src/{sentry_app_hang_sampler_mach.c => sentry_thread_stackwalk_mach.c} (95%) rename src/{sentry_app_hang_sampler_posix.c => sentry_thread_stackwalk_posix.c} (91%) rename src/{sentry_app_hang_sampler_windows.c => sentry_thread_stackwalk_windows.c} (94%) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 12b954bc9e..fa32c0248d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,7 +5,6 @@ sentry_target_sources_cwd(sentry sentry_app_hang_latch.h sentry_app_hang_monitor.c sentry_app_hang_monitor.h - sentry_app_hang_sampler.h sentry_attachment.c sentry_attachment.h sentry_backend.c @@ -59,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 @@ -259,22 +259,22 @@ if(SENTRY_WITH_LIBUNWIND_MAC) ) endif() -# app-hang platform sampler +# platform thread stackwalker (suspend a thread and capture its backtrace) if(APPLE) sentry_target_sources_cwd(sentry - sentry_app_hang_sampler_mach.c + sentry_thread_stackwalk_mach.c ) endif() if(WIN32) sentry_target_sources_cwd(sentry - sentry_app_hang_sampler_windows.c + sentry_thread_stackwalk_windows.c ) endif() if(LINUX OR ANDROID) sentry_target_sources_cwd(sentry - sentry_app_hang_sampler_posix.c + sentry_thread_stackwalk_posix.c ) endif() diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index 2f5192d8e2..e25c46ab17 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -1,7 +1,7 @@ #include "sentry_app_hang_monitor.h" #include "sentry_app_hang_latch.h" -#include "sentry_app_hang_sampler.h" +#include "sentry_thread_stackwalk.h" #include "sentry_core.h" #include "sentry_logger.h" #include "sentry_options.h" @@ -9,20 +9,19 @@ #include -static sentry__app_hang_thread_sampler_fn g_thread_sampler = NULL; +static sentry__app_hang_stackwalk_fn g_stackwalk_override = NULL; void -sentry__app_hang_monitor_set_thread_sampler( - sentry__app_hang_thread_sampler_fn fn) +sentry__app_hang_monitor_set_stackwalk_fn(sentry__app_hang_stackwalk_fn fn) { - g_thread_sampler = fn; + g_stackwalk_override = fn; } // Everything below is the watchdog machinery, which only makes sense where a -// platform thread sampler 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_APP_HANG_SAMPLER +// 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; @@ -34,19 +33,20 @@ static uint64_t g_timeout_ms = 0; # define SENTRY_APP_HANG_POLL_MS 500 static size_t -sample_thread(uint64_t tid, void **ips, size_t max) +stackwalk_thread(uint64_t tid, void **ips, size_t max) { - // A test may install an override; otherwise use the real platform sampler. - return g_thread_sampler != NULL - ? g_thread_sampler(tid, ips, max) - : sentry__app_hang_sample_thread(tid, ips, max); + // A test may install an override; otherwise use the real platform + // stackwalker. + return g_stackwalk_override != NULL + ? g_stackwalk_override(tid, ips, max) + : sentry__thread_stackwalk(tid, ips, max); } static void app_hang_capture(uint64_t hang_time_ms, uint64_t tid) { void *ips[SENTRY_APP_HANG_MAX_FRAMES]; - size_t n = sample_thread(tid, 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; @@ -123,15 +123,16 @@ sentry__app_hang_monitor_stop(void) SENTRY_DEBUG("app-hang watchdog stopped"); } -#else // !SENTRY_HAS_APP_HANG_SAMPLER +#else // !SENTRY_HAS_THREAD_STACKWALK -// No thread sampler on this platform: a fired hang could only produce frameless -// events, so the watchdog is never started and stop is a no-op. +// 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 sampler for this platform, not starting"); + SENTRY_DEBUG( + "app-hang: no thread stackwalker for this platform, not starting"); return 0; } @@ -140,4 +141,4 @@ sentry__app_hang_monitor_stop(void) { } -#endif // SENTRY_HAS_APP_HANG_SAMPLER +#endif // SENTRY_HAS_THREAD_STACKWALK diff --git a/src/sentry_app_hang_monitor.h b/src/sentry_app_hang_monitor.h index 4e4ed522b0..87b7295bbb 100644 --- a/src/sentry_app_hang_monitor.h +++ b/src/sentry_app_hang_monitor.h @@ -11,9 +11,11 @@ struct sentry_options_s; int sentry__app_hang_monitor_start(const struct sentry_options_s *options); void sentry__app_hang_monitor_stop(void); -typedef size_t (*sentry__app_hang_thread_sampler_fn)( +// Test hook: overrides the platform thread stackwalker used by the watchdog. +// Pass NULL to restore the real `sentry__thread_stackwalk`. +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_thread_sampler( - sentry__app_hang_thread_sampler_fn fn); +void sentry__app_hang_monitor_set_stackwalk_fn( + sentry__app_hang_stackwalk_fn fn); #endif diff --git a/src/sentry_app_hang_sampler.h b/src/sentry_app_hang_sampler.h deleted file mode 100644 index 5a9ffa7810..0000000000 --- a/src/sentry_app_hang_sampler.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef SENTRY_APP_HANG_SAMPLER_H_INCLUDED -#define SENTRY_APP_HANG_SAMPLER_H_INCLUDED - -#include "sentry_boot.h" - -#include -#include - -// A platform thread sampler is compiled in only for the targets below (see the -// `app-hang platform sampler` block in src/CMakeLists.txt). On any other target -// `sentry__app_hang_sample_thread` has no definition, so the monitor must not -// reference it there. Note that on Apple this is macOS-only: the mach sampler -// guards its implementation with `SENTRY_PLATFORM_MACOS`, so iOS et al. must -// not advertise the capability or the monitor would reference an undefined -// symbol. -#if defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_WINDOWS) \ - || defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) -# define SENTRY_HAS_APP_HANG_SAMPLER 1 -#else -# define SENTRY_HAS_APP_HANG_SAMPLER 0 -#endif - -#if SENTRY_HAS_APP_HANG_SAMPLER -size_t sentry__app_hang_sample_thread( - uint64_t target_tid, void **ips_out, size_t max_frames); -#endif - -#endif diff --git a/src/sentry_core.c b/src/sentry_core.c index 7b40a71f96..af6509ade9 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -321,8 +321,7 @@ sentry_close(void) // 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. monitor_stop() is a safe no-op - // when the watchdog was never started. + // while holding the lock would deadlock. It's a no-op when disabled. sentry__app_hang_monitor_stop(); SENTRY__MUTEX_INIT_DYN_ONCE(g_options_lock); 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_app_hang_sampler_mach.c b/src/sentry_thread_stackwalk_mach.c similarity index 95% rename from src/sentry_app_hang_sampler_mach.c rename to src/sentry_thread_stackwalk_mach.c index c83e2d9ec8..2d87067946 100644 --- a/src/sentry_app_hang_sampler_mach.c +++ b/src/sentry_thread_stackwalk_mach.c @@ -1,4 +1,4 @@ -#include "sentry_app_hang_sampler.h" +#include "sentry_thread_stackwalk.h" #include "sentry_boot.h" #if defined(SENTRY_PLATFORM_MACOS) @@ -11,7 +11,7 @@ # include size_t -sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) +sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) { task_t task = mach_task_self(); thread_act_array_t threads = NULL; diff --git a/src/sentry_app_hang_sampler_posix.c b/src/sentry_thread_stackwalk_posix.c similarity index 91% rename from src/sentry_app_hang_sampler_posix.c rename to src/sentry_thread_stackwalk_posix.c index e2866a921f..76932e3604 100644 --- a/src/sentry_app_hang_sampler_posix.c +++ b/src/sentry_thread_stackwalk_posix.c @@ -1,4 +1,4 @@ -#include "sentry_app_hang_sampler.h" +#include "sentry_thread_stackwalk.h" #include "sentry_boot.h" @@ -53,6 +53,14 @@ handler(int sig, siginfo_t *info, void *ucontext) } 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) @@ -151,7 +159,7 @@ ensure_installed(void) } size_t -sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) +sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) { if (!ensure_installed()) { return 0; diff --git a/src/sentry_app_hang_sampler_windows.c b/src/sentry_thread_stackwalk_windows.c similarity index 94% rename from src/sentry_app_hang_sampler_windows.c rename to src/sentry_thread_stackwalk_windows.c index 823f5a5985..828a6f7afd 100644 --- a/src/sentry_app_hang_sampler_windows.c +++ b/src/sentry_thread_stackwalk_windows.c @@ -1,4 +1,4 @@ -#include "sentry_app_hang_sampler.h" +#include "sentry_thread_stackwalk.h" #include "sentry_boot.h" @@ -11,7 +11,7 @@ # include size_t -sentry__app_hang_sample_thread(uint64_t target_tid, void **ips, size_t max) +sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) { HANDLE h = OpenThread( THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION, diff --git a/tests/unit/test_app_hang.c b/tests/unit/test_app_hang.c index 11f11d5b7e..3b33b98430 100644 --- a/tests/unit/test_app_hang.c +++ b/tests/unit/test_app_hang.c @@ -77,7 +77,7 @@ static long g_app_hang_seen; static char g_app_hang_type[32]; static size_t -fake_sampler(uint64_t tid, void **ips, size_t max) +fake_stackwalk(uint64_t tid, void **ips, size_t max) { (void)tid; if (max < 2) { @@ -112,7 +112,7 @@ 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_thread_sampler(fake_sampler); + 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"); @@ -131,7 +131,7 @@ SENTRY_TEST(app_hang_monitor_fires) TEST_CHECK_STRING_EQUAL(g_app_hang_type, "AppHang"); sentry_close(); - sentry__app_hang_monitor_set_thread_sampler(NULL); + sentry__app_hang_monitor_set_stackwalk_fn(NULL); } static long g_real_seen; @@ -176,7 +176,7 @@ SENTRY_TEST(app_hang_end_to_end) g_real_frames = 0; sentry__atomic_store(&g_keep_spinning, 1); sentry__app_hang_latch_reset(); - sentry__app_hang_monitor_set_thread_sampler(NULL); // use the REAL sampler + 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"); @@ -199,5 +199,5 @@ SENTRY_TEST(app_hang_end_to_end) TEST_CHECK(sentry__atomic_fetch(&g_real_frames) > 0); sentry_close(); - sentry__app_hang_monitor_set_thread_sampler(NULL); + sentry__app_hang_monitor_set_stackwalk_fn(NULL); } From cab5bda66246e521d493181c64c56604848048fc Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 17:23:20 +0200 Subject: [PATCH 11/21] . --- examples/example.c | 3 --- src/sentry_app_hang_latch.c | 42 +++++------------------------------ src/sentry_app_hang_monitor.c | 36 ++++++++++++++++++++++++++---- src/sentry_app_hang_monitor.h | 1 - 4 files changed, 37 insertions(+), 45 deletions(-) diff --git a/examples/example.c b/examples/example.c index d17e54e631..b110440a4f 100644 --- a/examples/example.c +++ b/examples/example.c @@ -661,9 +661,6 @@ main(int argc, char **argv) } if (has_arg(argc, argv, "app-hang")) { - // Enable in-process app-hang detection with a short timeout so the - // demo triggers quickly. The monitored thread is whichever thread - // first calls sentry_app_hang_heartbeat() (the main thread below). sentry_options_set_enable_app_hang_tracking(options, 1); sentry_options_set_app_hang_timeout_ms(options, 1000); } diff --git a/src/sentry_app_hang_latch.c b/src/sentry_app_hang_latch.c index 922568ca28..273154bed9 100644 --- a/src/sentry_app_hang_latch.c +++ b/src/sentry_app_hang_latch.c @@ -1,14 +1,11 @@ // In-process app-hang detection, shared state and helpers. The lock-free latch, // heartbeat API, capture predicate, and monotonic clock are the app-thread hot // path: app threads write the latch via the heartbeat, the watchdog worker in -// sentry_app_hang_monitor.c reads it. Event assembly -// (sentry__app_hang_make_event) lives here too but runs on the watchdog worker, -// not on app threads. +// sentry_app_hang_monitor.c reads it. #include "sentry_app_hang_latch.h" #include "sentry_sync.h" #include -#include #if defined(SENTRY_PLATFORM_WINDOWS) # include @@ -52,16 +49,15 @@ sentry__app_hang_now_ms(void) } // The latch is touched by app threads (writers, via the heartbeat) and the -// single watchdog worker (reader). Rather than a mutex -- which would put a -// lock on the heartbeat hot path, the very thing we're trying to detect -// stalling -- the two fields are accessed with 64-bit atomics: +// single watchdog worker (reader). A 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 the sampler argument, and the lone transition is benign, so a -// relaxed read is sufficient; we use the same atomic helpers for clarity. +// 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 access even on 32-bit platforms @@ -125,31 +121,3 @@ sentry_app_hang_heartbeat(void) &g_last_heartbeat_ms, sentry__app_hang_now_ms()); } } - -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; -} diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index e25c46ab17..d1023b6597 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -1,14 +1,43 @@ #include "sentry_app_hang_monitor.h" #include "sentry_app_hang_latch.h" -#include "sentry_thread_stackwalk.h" #include "sentry_core.h" #include "sentry_logger.h" #include "sentry_options.h" #include "sentry_sync.h" +#include "sentry_thread_stackwalk.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 @@ -35,8 +64,7 @@ static uint64_t g_timeout_ms = 0; static size_t stackwalk_thread(uint64_t tid, void **ips, size_t max) { - // A test may install an override; otherwise use the real platform - // stackwalker. + // A test installs an override. return g_stackwalk_override != NULL ? g_stackwalk_override(tid, ips, max) : sentry__thread_stackwalk(tid, ips, max); @@ -119,7 +147,7 @@ sentry__app_hang_monitor_stop(void) 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, so - // clearing would just introduce a data race for no benefit. + // clearing would just introduce a data race. SENTRY_DEBUG("app-hang watchdog stopped"); } diff --git a/src/sentry_app_hang_monitor.h b/src/sentry_app_hang_monitor.h index 87b7295bbb..cef1c0b4de 100644 --- a/src/sentry_app_hang_monitor.h +++ b/src/sentry_app_hang_monitor.h @@ -12,7 +12,6 @@ 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. -// Pass NULL to restore the real `sentry__thread_stackwalk`. 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( From 73488e5b50870dcc3731eb4efb573e67747f4c0f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 17 Jun 2026 17:39:54 +0200 Subject: [PATCH 12/21] bot review --- src/sentry_app_hang_monitor.c | 18 ++++++++++++++---- src/sentry_thread_stackwalk_posix.c | 6 ++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index d1023b6597..6b4b947d42 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -70,17 +70,18 @@ stackwalk_thread(uint64_t tid, void **ips, size_t max) : sentry__thread_stackwalk(tid, ips, max); } -static void +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; + 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 @@ -103,8 +104,13 @@ worker(void *arg) uint64_t now = sentry__app_hang_now_ms(); if (sentry__app_hang_should_capture( latch.last_heartbeat_ms, now, g_timeout_ms, last_fired_hb)) { - app_hang_capture(now - latch.last_heartbeat_ms, latch.target_tid); - last_fired_hb = latch.last_heartbeat_ms; + // 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; @@ -118,6 +124,10 @@ sentry__app_hang_monitor_start(const sentry_options_t *options) } 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) { diff --git a/src/sentry_thread_stackwalk_posix.c b/src/sentry_thread_stackwalk_posix.c index 76932e3604..f269f8f6e8 100644 --- a/src/sentry_thread_stackwalk_posix.c +++ b/src/sentry_thread_stackwalk_posix.c @@ -117,10 +117,16 @@ ensure_installed(void) # 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 From 2ee180531864fb641aa5c395ae7c69628d281f5b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 18 Jun 2026 11:03:43 +0200 Subject: [PATCH 13/21] resolved restart deadlock --- src/sentry_app_hang_monitor.c | 3 +-- src/sentry_core.c | 13 ++++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index 6b4b947d42..3eb1967dce 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -156,8 +156,7 @@ sentry__app_hang_monitor_stop(void) 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, so - // clearing would just introduce a data race. + // (now joined) is their only reader, and start() always re-sets them. SENTRY_DEBUG("app-hang watchdog stopped"); } diff --git a/src/sentry_core.c b/src/sentry_core.c index af6509ade9..9b19cc9d16 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -111,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); @@ -319,9 +326,9 @@ sentry_close(void) } } - // 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. It's a no-op when disabled. + // 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); From 9b9f26a12b7c763b820b0b681749a128fce8ac56 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 18 Jun 2026 11:40:45 +0200 Subject: [PATCH 14/21] release active flag --- src/sentry_thread_stackwalk_mach.c | 2 +- src/sentry_thread_stackwalk_posix.c | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry_thread_stackwalk_mach.c b/src/sentry_thread_stackwalk_mach.c index 2d87067946..c8b0d9b75c 100644 --- a/src/sentry_thread_stackwalk_mach.c +++ b/src/sentry_thread_stackwalk_mach.c @@ -1,5 +1,5 @@ -#include "sentry_thread_stackwalk.h" #include "sentry_boot.h" +#include "sentry_thread_stackwalk.h" #if defined(SENTRY_PLATFORM_MACOS) diff --git a/src/sentry_thread_stackwalk_posix.c b/src/sentry_thread_stackwalk_posix.c index f269f8f6e8..3770ed0552 100644 --- a/src/sentry_thread_stackwalk_posix.c +++ b/src/sentry_thread_stackwalk_posix.c @@ -222,6 +222,7 @@ sentry__thread_stackwalk(uint64_t target_tid, void **ips, size_t max) 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; From 77779b904ec98b7028061f8b93940a4085f4a681 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 18 Jun 2026 11:44:05 +0200 Subject: [PATCH 15/21] docs --- include/sentry.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/sentry.h b/include/sentry.h index 2d4020825a..9e09249ed5 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2666,7 +2666,7 @@ 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 2000 ms. + * 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. * From a10b76bd2b62423c53b987494af4afeb04c3a8c6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 18 Jun 2026 11:45:19 +0200 Subject: [PATCH 16/21] free handle --- src/sentry_app_hang_monitor.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index 3eb1967dce..93ab7cadad 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -153,6 +153,7 @@ sentry__app_hang_monitor_stop(void) 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 From 3cd1df53de566eb9e6595dd1c0e955db0c0b550d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 22 Jun 2026 09:52:44 +0200 Subject: [PATCH 17/21] POD --- src/sentry_app_hang_latch.c | 10 ++++++---- src/sentry_app_hang_latch.h | 2 +- src/sentry_app_hang_monitor.c | 3 +-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/sentry_app_hang_latch.c b/src/sentry_app_hang_latch.c index 273154bed9..0ed3489dd3 100644 --- a/src/sentry_app_hang_latch.c +++ b/src/sentry_app_hang_latch.c @@ -87,11 +87,13 @@ sentry__app_hang_current_tid(void) #endif } -void -sentry__app_hang_latch_read(sentry_app_hang_latch_t *out) +sentry_app_hang_latch_t +sentry__app_hang_current_latch(void) { - out->target_tid = sentry__atomic_fetch_u64(&g_target_tid); - out->last_heartbeat_ms = sentry__atomic_fetch_u64(&g_last_heartbeat_ms); + 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 diff --git a/src/sentry_app_hang_latch.h b/src/sentry_app_hang_latch.h index 3532b29abc..3c094e96e3 100644 --- a/src/sentry_app_hang_latch.h +++ b/src/sentry_app_hang_latch.h @@ -17,7 +17,7 @@ typedef struct { } sentry_app_hang_latch_t; uint64_t sentry__app_hang_current_tid(void); -void sentry__app_hang_latch_read(sentry_app_hang_latch_t *out); +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 diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index 93ab7cadad..6fff9bea75 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -99,8 +99,7 @@ worker(void *arg) break; } - sentry_app_hang_latch_t latch; - sentry__app_hang_latch_read(&latch); + const sentry_app_hang_latch_t latch = sentry__app_hang_current_latch(); uint64_t now = sentry__app_hang_now_ms(); if (sentry__app_hang_should_capture( latch.last_heartbeat_ms, now, g_timeout_ms, last_fired_hb)) { From 6e0cf5843e1756d00a6dc2f2ddd2c0039577f5ff Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 22 Jun 2026 09:58:28 +0200 Subject: [PATCH 18/21] public api comment --- include/sentry.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/include/sentry.h b/include/sentry.h index 1bfb59ddc4..14c8122999 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -2686,6 +2686,11 @@ SENTRY_EXPERIMENTAL_API uint64_t sentry_options_get_app_hang_timeout_ms( * 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`. */ From 3b8267a5e5f0a4a9f24420a3a0ad29f7d400036a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 22 Jun 2026 11:04:06 +0200 Subject: [PATCH 19/21] used sentry monotonic clock --- src/sentry_app_hang_latch.c | 38 +++++++++++++---------------------- src/sentry_app_hang_latch.h | 2 -- src/sentry_app_hang_monitor.c | 3 ++- tests/unit/test_app_hang.c | 15 +++----------- tests/unit/tests.inc | 1 - 5 files changed, 19 insertions(+), 40 deletions(-) diff --git a/src/sentry_app_hang_latch.c b/src/sentry_app_hang_latch.c index 0ed3489dd3..96a1dd7361 100644 --- a/src/sentry_app_hang_latch.c +++ b/src/sentry_app_hang_latch.c @@ -1,9 +1,10 @@ -// In-process app-hang detection, shared state and helpers. The lock-free latch, -// heartbeat API, capture predicate, and monotonic clock are the app-thread hot -// path: app threads write the latch via the heartbeat, the watchdog worker in -// sentry_app_hang_monitor.c reads it. +// 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 @@ -11,11 +12,9 @@ # include #elif defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) # include -# include # include #else // SENTRY_PLATFORM_MACOS and other POSIX # include -# include #endif bool @@ -34,23 +33,9 @@ sentry__app_hang_should_capture( return true; } -uint64_t -sentry__app_hang_now_ms(void) -{ -#if defined(SENTRY_PLATFORM_WINDOWS) - return (uint64_t)GetTickCount64(); -#else - struct timespec ts; - if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { - return 0; - } - return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; -#endif -} - // The latch is touched by app threads (writers, via the heartbeat) and the -// single watchdog worker (reader). A mutex would put a lock on the heartbeat -// hot path, not ideal. +// 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. @@ -60,7 +45,12 @@ sentry__app_hang_now_ms(void) // 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 access even on 32-bit platforms +// 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; @@ -120,6 +110,6 @@ sentry_app_hang_heartbeat(void) if (target == tid) { // ignore heartbeats from other threads sentry__atomic_store_u64( - &g_last_heartbeat_ms, sentry__app_hang_now_ms()); + &g_last_heartbeat_ms, sentry__monotonic_time()); } } diff --git a/src/sentry_app_hang_latch.h b/src/sentry_app_hang_latch.h index 3c094e96e3..d5d5af2e44 100644 --- a/src/sentry_app_hang_latch.h +++ b/src/sentry_app_hang_latch.h @@ -9,8 +9,6 @@ bool sentry__app_hang_should_capture( uint64_t hb, uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb); -uint64_t sentry__app_hang_now_ms(void); - typedef struct { uint64_t target_tid; uint64_t last_heartbeat_ms; diff --git a/src/sentry_app_hang_monitor.c b/src/sentry_app_hang_monitor.c index 6fff9bea75..f26e531549 100644 --- a/src/sentry_app_hang_monitor.c +++ b/src/sentry_app_hang_monitor.c @@ -6,6 +6,7 @@ #include "sentry_options.h" #include "sentry_sync.h" #include "sentry_thread_stackwalk.h" +#include "sentry_utils.h" #include #include @@ -100,7 +101,7 @@ worker(void *arg) } const sentry_app_hang_latch_t latch = sentry__app_hang_current_latch(); - uint64_t now = sentry__app_hang_now_ms(); + 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 diff --git a/tests/unit/test_app_hang.c b/tests/unit/test_app_hang.c index 3b33b98430..86509759a1 100644 --- a/tests/unit/test_app_hang.c +++ b/tests/unit/test_app_hang.c @@ -17,33 +17,24 @@ SENTRY_TEST(app_hang_should_capture) TEST_CHECK(!sentry__app_hang_should_capture(98000, 100000, 2000, 98000)); } -SENTRY_TEST(app_hang_now_ms_monotonic) -{ - uint64_t a = sentry__app_hang_now_ms(); - uint64_t b = sentry__app_hang_now_ms(); - TEST_CHECK(b >= a); - TEST_CHECK(a != 0); -} - SENTRY_TEST(app_hang_latch) { sentry__app_hang_latch_reset(); sentry__app_hang_set_active(true); - sentry_app_hang_latch_t l = { 0 }; - sentry__app_hang_latch_read(&l); + 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(); - sentry__app_hang_latch_read(&l); + 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(); - sentry__app_hang_latch_read(&l); + l = sentry__app_hang_current_latch(); TEST_CHECK(l.target_tid == 0); sentry__app_hang_set_active(false); } diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index a560572da3..e7d78bd710 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -20,7 +20,6 @@ XX(app_hang_latch) XX(app_hang_make_event) XX(app_hang_monitor_fires) XX(app_hang_end_to_end) -XX(app_hang_now_ms_monotonic) XX(background_worker) XX(baggage_iter_basic) XX(baggage_iter_case_preserved) From 88e1e73f1863b79fdb2fb2c724bc341746b38d30 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 22 Jun 2026 11:07:18 +0200 Subject: [PATCH 20/21] updated changelog --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9824c5f1f8..5208349f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ # Changelog -## 0.15.1 +## Unreleased ### 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 watched. ([#1806](https://github.com/getsentry/sentry-native/pull/1806)) +## 0.15.1 + **Fixes**: - Report on partial disk writes when streaming envelopes to file, which previously left truncated envelopes on disk and reported success. ([#1804](https://github.com/getsentry/sentry-native/pull/1804)) @@ -13,7 +15,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 From 386f4f77c225dfa11d1889b325677488e4dfdd4f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 22 Jun 2026 11:17:38 +0200 Subject: [PATCH 21/21] restore errno --- src/sentry_thread_stackwalk_posix.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry_thread_stackwalk_posix.c b/src/sentry_thread_stackwalk_posix.c index 3770ed0552..7824ee35ed 100644 --- a/src/sentry_thread_stackwalk_posix.c +++ b/src/sentry_thread_stackwalk_posix.c @@ -49,6 +49,7 @@ handler(int sig, siginfo_t *info, void *ucontext) // 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;