From 3c6fd49203bd5da3206f55cd83550f82f85c7773 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 29 May 2026 16:39:40 +0200 Subject: [PATCH 01/26] refactor --- src/backends/native/sentry_crash_daemon.c | 26 ++-- src/backends/sentry_backend_native.c | 147 +++++++++------------- 2 files changed, 74 insertions(+), 99 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index be660d4326..ff42856b09 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2119,14 +2119,21 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) } /** - * Build native crash event with exception, mechanism, and debug_meta + * Build a native event from the scope-complete base event, adding the + * caller-specified framing (level, mechanism) plus threads and debug_meta. + * The base event (contexts, tags, user, breadcrumbs, ...) is identical + * regardless of event type; the caller states what this event is. * * @param ctx Crash context - * @param event_file_path Path to event file from parent process + * @param event_file_path Path to base event file from parent process + * @param level Event level (e.g. "fatal") + * @param mechanism_type Exception mechanism type (e.g. "signalhandler") + * @param handled Whether the mechanism was handled */ static sentry_value_t -build_native_crash_event( - const sentry_crash_context_t *ctx, const char *event_file_path) +build_native_event(const sentry_crash_context_t *ctx, + const char *event_file_path, const char *level, + const char *mechanism_type, bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2152,8 +2159,7 @@ build_native_crash_event( sentry_value_set_by_key( event, "platform", sentry_value_new_string("native")); - // Set level to fatal - sentry_value_set_by_key(event, "level", sentry_value_new_string("fatal")); + sentry_value_set_by_key(event, "level", sentry_value_new_string(level)); // Build exception const char *signal_name = "UNKNOWN"; @@ -2175,10 +2181,11 @@ build_native_crash_event( // Add mechanism sentry_value_t mechanism = sentry_value_new_object(); sentry_value_set_by_key( - mechanism, "type", sentry_value_new_string("signalhandler")); + mechanism, "type", sentry_value_new_string(mechanism_type)); sentry_value_set_by_key( mechanism, "synthetic", sentry_value_new_bool(true)); - sentry_value_set_by_key(mechanism, "handled", sentry_value_new_bool(false)); + sentry_value_set_by_key( + mechanism, "handled", sentry_value_new_bool(handled)); // Add signal metadata sentry_value_t meta = sentry_value_new_object(); @@ -2477,7 +2484,8 @@ write_envelope_with_native_stacktrace(const sentry_options_t *options, // Build native crash event (always include threads with names) SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); - sentry_value_t event = build_native_crash_event(ctx, event_file_path); + sentry_value_t event = build_native_event( + ctx, event_file_path, "fatal", "signalhandler", false); // Serialize event to JSON size_t event_size = 0; diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 3c3e508434..bea2986b1a 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -767,9 +767,61 @@ native_backend_write_attachments(const sentry_path_t *event_path) } } +#if defined(SENTRY_PLATFORM_WINDOWS) +// Sentry's symbolicator needs `contexts.device.arch` to process PE modules. If +// the scope already carries a device context with arch (host SDKs like Unity +// provide one), leave it; otherwise synthesize a minimal one so native-only +// consumers still symbolicate. +static void +native_backend_ensure_device_arch(sentry_value_t event) +{ + sentry_value_t contexts = sentry_value_get_by_key(event, "contexts"); + if (sentry_value_is_null(contexts)) { + contexts = sentry_value_new_object(); + sentry_value_set_by_key(event, "contexts", contexts); + } + sentry_value_t device = sentry_value_get_by_key(contexts, "device"); + if (sentry_value_is_null(device)) { + device = sentry_value_new_object(); + sentry_value_set_by_key( + device, "type", sentry_value_new_string("device")); + sentry_value_set_by_key(contexts, "device", device); + } + if (!sentry_value_is_null(sentry_value_get_by_key(device, "arch"))) { + return; + } +# if defined(_M_AMD64) + sentry_value_set_by_key(device, "arch", sentry_value_new_string("x86_64")); +# elif defined(_M_IX86) + sentry_value_set_by_key(device, "arch", sentry_value_new_string("x86")); +# elif defined(_M_ARM64) + sentry_value_set_by_key(device, "arch", sentry_value_new_string("arm64")); +# endif +} +#endif + +// Applies the full scope to `event`: contexts (os, device, gpu, app, runtime, +// plus SDK-specific entries such as the Unity context), user, tags, extra, +// fingerprint, release/dist/env, sdk metadata, and breadcrumbs - plus the +// Windows device.arch fallback. Single source of truth for the base event the +// daemon reads, shared by the continuous scope flush and the crash handler so +// both write an identical base regardless of which one wins the race. +static void +native_backend_apply_scope( + sentry_value_t event, const sentry_options_t *options) +{ + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event( + scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + } +#if defined(SENTRY_PLATFORM_WINDOWS) + native_backend_ensure_device_arch(event); +#endif +} + static void native_backend_flush_scope( - sentry_backend_t *backend, const sentry_options_t *UNUSED(options)) + sentry_backend_t *backend, const sentry_options_t *options) { native_backend_state_t *state = (native_backend_state_t *)backend->data; if (!state || !state->event_path) { @@ -784,65 +836,11 @@ native_backend_flush_scope( return; } - // Create event with current scope + // Keep the on-disk base event complete and current, so the daemon has the + // full scope even if a crash beats the in-process handler to the file. sentry_value_t event = sentry_value_new_object(); - sentry_value_set_by_key( - event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - - // Apply scope with contexts (includes OS, device info from Sentry) - SENTRY_WITH_SCOPE (scope) { - // Get contexts from scope (includes OS info) - sentry_value_t os_context - = sentry_value_get_by_key(scope->contexts, "os"); - if (!sentry_value_is_null(os_context)) { - sentry_value_t event_contexts = sentry_value_new_object(); - sentry_value_set_by_key(event_contexts, "os", os_context); - sentry_value_incref(os_context); + native_backend_apply_scope(event, options); -#if defined(SENTRY_PLATFORM_WINDOWS) - // Add device context with arch for Windows native events - // This is required for Sentry's symbolicator to process PE modules - sentry_value_t device_context = sentry_value_new_object(); - sentry_value_set_by_key( - device_context, "type", sentry_value_new_string("device")); -# if defined(_M_AMD64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86_64")); -# elif defined(_M_IX86) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86")); -# elif defined(_M_ARM64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("arm64")); -# endif - sentry_value_set_by_key(event_contexts, "device", device_context); -#endif - - sentry_value_set_by_key(event, "contexts", event_contexts); - } - - // Also copy other scope data (user, tags, extra, etc.) - sentry_value_t user = scope->user; - if (sentry_value_get_type(user) == SENTRY_VALUE_TYPE_OBJECT - && sentry_value_get_length(user) > 0) { - sentry_value_set_by_key(event, "user", user); - sentry_value_incref(user); - } - - sentry_value_t tags = scope->tags; - if (!sentry_value_is_null(tags)) { - sentry_value_set_by_key(event, "tags", tags); - sentry_value_incref(tags); - } - - sentry_value_t extra = scope->extra; - if (!sentry_value_is_null(extra)) { - sentry_value_set_by_key(event, "extra", extra); - sentry_value_incref(extra); - } - } - - // Serialize to JSON (so it can be deserialized on next start) size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); sentry_value_decref(event); @@ -1024,38 +1022,7 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // Apply scope to event including breadcrumbs - SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event( - scope, options, event, SENTRY_SCOPE_BREADCRUMBS); - } - -#if defined(SENTRY_PLATFORM_WINDOWS) - // Add device context with arch for Windows native events - // This is required for Sentry's symbolicator to process PE - // modules - sentry_value_t contexts - = sentry_value_get_by_key(event, "contexts"); - if (sentry_value_is_null(contexts)) { - contexts = sentry_value_new_object(); - sentry_value_set_by_key(event, "contexts", contexts); - } - sentry_value_t device_context = sentry_value_new_object(); - sentry_value_set_by_key( - device_context, "type", sentry_value_new_string("device")); -# if defined(_M_AMD64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86_64")); -# elif defined(_M_IX86) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("x86")); -# elif defined(_M_ARM64) - sentry_value_set_by_key( - device_context, "arch", sentry_value_new_string("arm64")); -# endif - sentry_value_set_by_key(contexts, "device", device_context); - -#endif + native_backend_apply_scope(event, options); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From a26c756e6e76aa1eed23344d6f1b783440b86114 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 10:11:04 +0200 Subject: [PATCH 02/26] linter --- src/backends/native/sentry_crash_daemon.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index ff42856b09..7be96c4685 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2132,8 +2132,8 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) */ static sentry_value_t build_native_event(const sentry_crash_context_t *ctx, - const char *event_file_path, const char *level, - const char *mechanism_type, bool handled) + const char *event_file_path, const char *level, const char *mechanism_type, + bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); From 0668a9345174b35f896fe3024ec0f54a188e0a6d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 14:10:42 +0200 Subject: [PATCH 03/26] restored default fatal behaviour --- src/backends/sentry_backend_native.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index bea2986b1a..d0e4d15c21 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -839,6 +839,9 @@ native_backend_flush_scope( // Keep the on-disk base event complete and current, so the daemon has the // full scope even if a crash beats the in-process handler to the file. sentry_value_t event = sentry_value_new_object(); + // Default to `FATAL` for all paths, i.e. minidump mode. + sentry_value_set_by_key( + event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); native_backend_apply_scope(event, options); size_t json_len = 0; From caeb8fbe142f03269756cb17478b327020766b22 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 14:13:16 +0200 Subject: [PATCH 04/26] fix naming --- src/backends/sentry_backend_native.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index d0e4d15c21..3c5bb62c6a 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -773,7 +773,7 @@ native_backend_write_attachments(const sentry_path_t *event_path) // provide one), leave it; otherwise synthesize a minimal one so native-only // consumers still symbolicate. static void -native_backend_ensure_device_arch(sentry_value_t event) +ensure_device_arch(sentry_value_t event) { sentry_value_t contexts = sentry_value_get_by_key(event, "contexts"); if (sentry_value_is_null(contexts)) { @@ -807,7 +807,7 @@ native_backend_ensure_device_arch(sentry_value_t event) // daemon reads, shared by the continuous scope flush and the crash handler so // both write an identical base regardless of which one wins the race. static void -native_backend_apply_scope( +apply_scope( sentry_value_t event, const sentry_options_t *options) { SENTRY_WITH_SCOPE (scope) { @@ -815,7 +815,7 @@ native_backend_apply_scope( scope, options, event, SENTRY_SCOPE_BREADCRUMBS); } #if defined(SENTRY_PLATFORM_WINDOWS) - native_backend_ensure_device_arch(event); + ensure_device_arch(event); #endif } @@ -842,7 +842,7 @@ native_backend_flush_scope( // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - native_backend_apply_scope(event, options); + apply_scope(event, options); size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -1025,7 +1025,7 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - native_backend_apply_scope(event, options); + apply_scope(event, options); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From 0a3a6466ad0f063b2bf87dffb2616a9db0b426ab Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 16:40:45 +0200 Subject: [PATCH 05/26] perf(native): keep breadcrumbs off the per-mutation scope flush native_backend_flush_scope runs on every scope mutation (set_tag, set_context, set_user, ...). Folding breadcrumbs into the flushed base event re-serialized the entire breadcrumb ring on each of those calls - prohibitive on a hot path such as a 60fps game main thread. Give apply_scope a scope-mode argument: the continuous flush now passes SENTRY_SCOPE_NONE, while the crash handler still passes SENTRY_SCOPE_BREADCRUMBS to capture them at crash time (the process's last chance to record them). This matches the pre-existing behavior before breadcrumbs were added to the shared flush path. Co-Authored-By: Claude --- src/backends/sentry_backend_native.c | 39 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 3c5bb62c6a..f2b391002d 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -800,19 +800,24 @@ ensure_device_arch(sentry_value_t event) } #endif -// Applies the full scope to `event`: contexts (os, device, gpu, app, runtime, -// plus SDK-specific entries such as the Unity context), user, tags, extra, -// fingerprint, release/dist/env, sdk metadata, and breadcrumbs - plus the -// Windows device.arch fallback. Single source of truth for the base event the -// daemon reads, shared by the continuous scope flush and the crash handler so -// both write an identical base regardless of which one wins the race. +// Applies the scope to `event`: contexts (os, device, gpu, app, runtime, plus +// SDK-specific entries such as the Unity context), user, tags, extra, +// fingerprint, release/dist/env, sdk metadata - plus the Windows device.arch +// fallback. Shared by the continuous scope flush and the crash handler so both +// write an identical base regardless of which one wins the race. +// +// `mode` controls the expensive, list-shaped parts. The crash handler passes +// SENTRY_SCOPE_BREADCRUMBS to capture them at crash time, but the continuous +// flush passes SENTRY_SCOPE_NONE: it runs on *every* scope mutation, so folding +// the breadcrumb buffer in there would re-serialize the whole ring on every +// set_tag/set_context/... - prohibitive on a hot path such as a 60fps main +// thread. static void -apply_scope( - sentry_value_t event, const sentry_options_t *options) +apply_scope(sentry_value_t event, const sentry_options_t *options, + sentry_scope_mode_t mode) { SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event( - scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + sentry__scope_apply_to_event(scope, options, event, mode); } #if defined(SENTRY_PLATFORM_WINDOWS) ensure_device_arch(event); @@ -836,13 +841,17 @@ native_backend_flush_scope( return; } - // Keep the on-disk base event complete and current, so the daemon has the - // full scope even if a crash beats the in-process handler to the file. + // Keep the on-disk base event current, so the daemon has the full scope + // even if a crash beats the in-process handler to the file. Breadcrumbs are + // deliberately excluded here (SENTRY_SCOPE_NONE): they are flushed + // incrementally to the breadcrumb ring files and the crash handler captures + // them at crash time. This keeps the per-mutation flush off the breadcrumb + // serialization cost. sentry_value_t event = sentry_value_new_object(); // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - apply_scope(event, options); + apply_scope(event, options, SENTRY_SCOPE_NONE); size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -1025,7 +1034,9 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - apply_scope(event, options); + // At crash time we capture breadcrumbs (unlike the continuous + // flush) - this is the process's last chance to record them. + apply_scope(event, options, SENTRY_SCOPE_BREADCRUMBS); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From 57af0047faaffbca54d064ed6eb6f0d84742403a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 17:57:02 +0200 Subject: [PATCH 06/26] read breadcrumbs from ring file --- src/backends/native/sentry_crash_context.h | 2 + src/backends/native/sentry_crash_daemon.c | 98 +++++++++++++++++++--- src/backends/sentry_backend_native.c | 50 +++++------ 3 files changed, 116 insertions(+), 34 deletions(-) diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index e5d0bd63e7..e2dcd9a9b0 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -289,6 +289,8 @@ typedef struct { uint64_t shutdown_timeout; uint64_t transfer_timeout; bool system_crash_reporter_enabled; + uint32_t max_breadcrumbs; // Breadcrumb cap, so the daemon merges the ring + // files with the same limit the app enforced // Atomic user consent (sentry_user_consent_t), updated whenever user // consent changes so the daemon can honor it at crash time. diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 7be96c4685..af31d35663 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2118,22 +2118,89 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) return build_stacktrace_for_thread(ctx, SIZE_MAX); } +/** + * Reads one breadcrumb ring file the crashing process appended on its hot path + * (concatenated msgpack values) into a breadcrumb list. Returns null if the + * file is absent or empty. + */ +static sentry_value_t +read_breadcrumb_ring_file(const sentry_path_t *run_folder, const char *name) +{ + if (!run_folder) { + return sentry_value_new_null(); + } + sentry_path_t *path = sentry__path_join_str(run_folder, name); + if (!path) { + return sentry_value_new_null(); + } + size_t size = 0; + char *buf = sentry__path_read_to_buffer(path, &size); + sentry__path_free(path); + if (!buf || size == 0) { + sentry_free(buf); + return sentry_value_new_null(); + } + sentry_value_t list = sentry__value_from_msgpack(buf, size); + sentry_free(buf); + // `sentry__value_from_msgpack` only builds a list when the file holds 2+ + // concatenated values; a file with a single breadcrumb decodes to a bare + // object. Wrap it so the merge step (which ignores non-lists) keeps it. + if (sentry_value_get_type(list) == SENTRY_VALUE_TYPE_OBJECT) { + sentry_value_t wrapper = sentry_value_new_list(); + sentry_value_append(wrapper, list); + return wrapper; + } + return list; +} + +/** + * Assembles the crash event's breadcrumbs from the two ring files the crashing + * process appended one-at-a-time, merges them in timestamp order, keeps the + * newest `max_breadcrumbs`, and attaches them to `event`. This is what keeps + * breadcrumb persistence off the per-mutation scope-flush path - the app only + * ever appends a single breadcrumb, and the daemon does the assembly here. + * Mirrors the crashpad backend's `report_to_envelope`. + */ +static void +apply_breadcrumbs_from_ring_files(sentry_value_t event, + const sentry_path_t *run_folder, const sentry_crash_context_t *ctx) +{ + sentry_value_t b1 + = read_breadcrumb_ring_file(run_folder, "__sentry-breadcrumb1"); + sentry_value_t b2 + = read_breadcrumb_ring_file(run_folder, "__sentry-breadcrumb2"); + size_t max = ctx && ctx->max_breadcrumbs ? ctx->max_breadcrumbs + : SENTRY_BREADCRUMBS_MAX; + sentry_value_t merged = sentry__value_merge_breadcrumbs(b1, b2, max); + sentry_value_decref(b1); + sentry_value_decref(b2); + // Overwrite any breadcrumbs the base event may carry: the ring files are the + // single source of truth, so this is idempotent and never duplicates. + if (sentry_value_get_type(merged) == SENTRY_VALUE_TYPE_LIST) { + sentry_value_set_by_key(event, "breadcrumbs", merged); + } else { + sentry_value_decref(merged); + } +} + /** * Build a native event from the scope-complete base event, adding the - * caller-specified framing (level, mechanism) plus threads and debug_meta. - * The base event (contexts, tags, user, breadcrumbs, ...) is identical - * regardless of event type; the caller states what this event is. + * caller-specified framing (level, mechanism) plus threads, breadcrumbs (read + * from the ring files), and debug_meta. The base event (contexts, tags, user, + * ...) is identical regardless of event type; the caller states what this + * event is. * * @param ctx Crash context * @param event_file_path Path to base event file from parent process + * @param run_folder Run directory holding the breadcrumb ring files * @param level Event level (e.g. "fatal") * @param mechanism_type Exception mechanism type (e.g. "signalhandler") * @param handled Whether the mechanism was handled */ static sentry_value_t build_native_event(const sentry_crash_context_t *ctx, - const char *event_file_path, const char *level, const char *mechanism_type, - bool handled) + const char *event_file_path, const sentry_path_t *run_folder, + const char *level, const char *mechanism_type, bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2155,6 +2222,10 @@ build_native_event(const sentry_crash_context_t *ctx, event = sentry_value_new_event(); } + // Assemble breadcrumbs from the ring files (the base event carries none - + // the app keeps them off the scope-flush hot path). + apply_breadcrumbs_from_ring_files(event, run_folder, ctx); + // Set platform to native sentry_value_set_by_key( event, "platform", sentry_value_new_string("native")); @@ -2485,7 +2556,7 @@ write_envelope_with_native_stacktrace(const sentry_options_t *options, SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); sentry_value_t event = build_native_event( - ctx, event_file_path, "fatal", "signalhandler", false); + ctx, event_file_path, run_folder, "fatal", "signalhandler", false); // Serialize event to JSON size_t event_size = 0; @@ -2734,21 +2805,28 @@ write_envelope_with_minidump(const sentry_options_t *options, const char *event_msgpack_path, const char *minidump_path, sentry_path_t *run_folder) { - // Read event JSON data + // Read the base event, merge in the breadcrumbs from the ring files (the + // base event carries none), and re-serialize. Unlike the native-stacktrace + // path this mode otherwise streams the event verbatim, so we have to + // round-trip through a value to attach breadcrumbs. size_t event_size = 0; char *event_json = NULL; char *event_id = NULL; sentry_path_t *ev_path = sentry__path_from_str(event_msgpack_path); if (ev_path) { - event_json = sentry__path_read_to_buffer(ev_path, &event_size); + size_t base_size = 0; + char *base_json = sentry__path_read_to_buffer(ev_path, &base_size); sentry__path_free(ev_path); - if (event_json && event_size > 0) { + if (base_json && base_size > 0) { sentry_value_t event - = sentry__value_from_json(event_json, event_size); + = sentry__value_from_json(base_json, base_size); + apply_breadcrumbs_from_ring_files(event, run_folder, ctx); event_id = sentry__string_clone(sentry_value_as_string( sentry_value_get_by_key(event, "event_id"))); + event_json = sentry__value_to_json(event, &event_size); sentry_value_decref(event); } + sentry_free(base_json); } // Open envelope file for writing diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index f2b391002d..e4d080dda2 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -309,6 +309,7 @@ native_backend_startup( ctx->http_retry = options->http_retry; ctx->shutdown_timeout = options->shutdown_timeout; ctx->transfer_timeout = options->transfer_timeout; + ctx->max_breadcrumbs = (uint32_t)options->max_breadcrumbs; sentry__atomic_store( &ctx->user_consent, sentry__atomic_fetch(&options->run->user_consent)); @@ -806,18 +807,19 @@ ensure_device_arch(sentry_value_t event) // fallback. Shared by the continuous scope flush and the crash handler so both // write an identical base regardless of which one wins the race. // -// `mode` controls the expensive, list-shaped parts. The crash handler passes -// SENTRY_SCOPE_BREADCRUMBS to capture them at crash time, but the continuous -// flush passes SENTRY_SCOPE_NONE: it runs on *every* scope mutation, so folding -// the breadcrumb buffer in there would re-serialize the whole ring on every -// set_tag/set_context/... - prohibitive on a hot path such as a 60fps main -// thread. +// Breadcrumbs are deliberately excluded (SENTRY_SCOPE_NONE): they are persisted +// incrementally to the breadcrumb ring files via `add_breadcrumb_func` and +// assembled by the daemon at crash time (see the daemon's +// `apply_breadcrumbs_from_ring_files`). Folding them in here would re-serialize +// the whole breadcrumb buffer on every scope mutation - prohibitive on a hot +// path such as a 60fps main thread. This mirrors the crashpad backend's +// `flush_scope_to_event`. static void -apply_scope(sentry_value_t event, const sentry_options_t *options, - sentry_scope_mode_t mode) +apply_scope(sentry_value_t event, const sentry_options_t *options) { SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event(scope, options, event, mode); + sentry__scope_apply_to_event( + scope, options, event, SENTRY_SCOPE_NONE); } #if defined(SENTRY_PLATFORM_WINDOWS) ensure_device_arch(event); @@ -843,15 +845,13 @@ native_backend_flush_scope( // Keep the on-disk base event current, so the daemon has the full scope // even if a crash beats the in-process handler to the file. Breadcrumbs are - // deliberately excluded here (SENTRY_SCOPE_NONE): they are flushed - // incrementally to the breadcrumb ring files and the crash handler captures - // them at crash time. This keeps the per-mutation flush off the breadcrumb - // serialization cost. + // not part of this (see apply_scope) - the daemon merges them from the ring + // files at crash time. sentry_value_t event = sentry_value_new_object(); // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - apply_scope(event, options, SENTRY_SCOPE_NONE); + apply_scope(event, options); size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -890,18 +890,22 @@ native_backend_add_breadcrumb(sentry_backend_t *backend, return; } - // Serialize to JSON (so it can be deserialized on next start) - size_t json_len = 0; - char *json_str = sentry__value_to_json(breadcrumb, &json_len); - if (!json_str) { + // Append as msgpack, matching the crashpad backend. msgpack values are + // self-delimiting, so the daemon can read the concatenated ring file back + // into a list via `sentry__value_from_msgpack`. This is the only breadcrumb + // persistence on the hot path: one serialize + one append per breadcrumb, + // never a full scope re-serialization. + size_t mpack_size = 0; + char *mpack = sentry_value_to_msgpack(breadcrumb, &mpack_size); + if (!mpack) { return; } int rv = first_breadcrumb - ? sentry__path_write_buffer(breadcrumb_file, json_str, json_len) - : sentry__path_append_buffer(breadcrumb_file, json_str, json_len); + ? sentry__path_write_buffer(breadcrumb_file, mpack, mpack_size) + : sentry__path_append_buffer(breadcrumb_file, mpack, mpack_size); - sentry_free(json_str); + sentry_free(mpack); if (rv != 0) { SENTRY_WARN("failed to write breadcrumb"); @@ -1034,9 +1038,7 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // At crash time we capture breadcrumbs (unlike the continuous - // flush) - this is the process's last chance to record them. - apply_scope(event, options, SENTRY_SCOPE_BREADCRUMBS); + apply_scope(event, options); #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From 0a57db69d5e63db67b4e543bafae24b5d0dd7ed4 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 18:31:44 +0200 Subject: [PATCH 07/26] minified change --- src/backends/native/sentry_crash_daemon.c | 5 +-- src/backends/sentry_backend_native.c | 52 ++++++++--------------- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 7be96c4685..a2e03f7d33 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2119,10 +2119,7 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) } /** - * Build a native event from the scope-complete base event, adding the - * caller-specified framing (level, mechanism) plus threads and debug_meta. - * The base event (contexts, tags, user, breadcrumbs, ...) is identical - * regardless of event type; the caller states what this event is. + * Build a native event and set the level, mechanism, and handled state * * @param ctx Crash context * @param event_file_path Path to base event file from parent process diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index f2b391002d..7768fe0304 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -800,30 +800,6 @@ ensure_device_arch(sentry_value_t event) } #endif -// Applies the scope to `event`: contexts (os, device, gpu, app, runtime, plus -// SDK-specific entries such as the Unity context), user, tags, extra, -// fingerprint, release/dist/env, sdk metadata - plus the Windows device.arch -// fallback. Shared by the continuous scope flush and the crash handler so both -// write an identical base regardless of which one wins the race. -// -// `mode` controls the expensive, list-shaped parts. The crash handler passes -// SENTRY_SCOPE_BREADCRUMBS to capture them at crash time, but the continuous -// flush passes SENTRY_SCOPE_NONE: it runs on *every* scope mutation, so folding -// the breadcrumb buffer in there would re-serialize the whole ring on every -// set_tag/set_context/... - prohibitive on a hot path such as a 60fps main -// thread. -static void -apply_scope(sentry_value_t event, const sentry_options_t *options, - sentry_scope_mode_t mode) -{ - SENTRY_WITH_SCOPE (scope) { - sentry__scope_apply_to_event(scope, options, event, mode); - } -#if defined(SENTRY_PLATFORM_WINDOWS) - ensure_device_arch(event); -#endif -} - static void native_backend_flush_scope( sentry_backend_t *backend, const sentry_options_t *options) @@ -841,17 +817,18 @@ native_backend_flush_scope( return; } - // Keep the on-disk base event current, so the daemon has the full scope - // even if a crash beats the in-process handler to the file. Breadcrumbs are - // deliberately excluded here (SENTRY_SCOPE_NONE): they are flushed - // incrementally to the breadcrumb ring files and the crash handler captures - // them at crash time. This keeps the per-mutation flush off the breadcrumb - // serialization cost. + // Create event with current scope sentry_value_t event = sentry_value_new_object(); - // Default to `FATAL` for all paths, i.e. minidump mode. sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); - apply_scope(event, options, SENTRY_SCOPE_NONE); + + // Apply scope with contexts + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event(scope, options, event, SENTRY_SCOPE_NONE); + } +#if defined(SENTRY_PLATFORM_WINDOWS) + ensure_device_arch(event); +#endif size_t json_len = 0; char *json_str = sentry__value_to_json(event, &json_len); @@ -1034,9 +1011,14 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // At crash time we capture breadcrumbs (unlike the continuous - // flush) - this is the process's last chance to record them. - apply_scope(event, options, SENTRY_SCOPE_BREADCRUMBS); + // Apply scope to event including breadcrumbs + SENTRY_WITH_SCOPE (scope) { + sentry__scope_apply_to_event( + scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + } +#if defined(SENTRY_PLATFORM_WINDOWS) + ensure_device_arch(event); +#endif #ifndef SENTRY_SCREENSHOT_NONE // The screenshot is captured by the daemon out-of-process, so From b157d91f9308f7b62c0237d2129d7f670e149d02 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 1 Jun 2026 18:41:47 +0200 Subject: [PATCH 08/26] minified changes here too --- src/backends/native/sentry_crash_context.h | 3 +-- src/backends/native/sentry_crash_daemon.c | 20 ++++---------------- src/backends/sentry_backend_native.c | 4 +--- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index e2dcd9a9b0..b6c985cd3d 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -289,8 +289,7 @@ typedef struct { uint64_t shutdown_timeout; uint64_t transfer_timeout; bool system_crash_reporter_enabled; - uint32_t max_breadcrumbs; // Breadcrumb cap, so the daemon merges the ring - // files with the same limit the app enforced + uint32_t max_breadcrumbs; // Atomic user consent (sentry_user_consent_t), updated whenever user // consent changes so the daemon can honor it at crash time. diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index af0d0b3514..1873ed73ae 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2120,8 +2120,7 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) /** * Reads one breadcrumb ring file the crashing process appended on its hot path - * (concatenated msgpack values) into a breadcrumb list. Returns null if the - * file is absent or empty. + * into a breadcrumb list. Returns null if the file is absent or empty. */ static sentry_value_t read_breadcrumb_ring_file(const sentry_path_t *run_folder, const char *name) @@ -2156,9 +2155,7 @@ read_breadcrumb_ring_file(const sentry_path_t *run_folder, const char *name) /** * Assembles the crash event's breadcrumbs from the two ring files the crashing * process appended one-at-a-time, merges them in timestamp order, keeps the - * newest `max_breadcrumbs`, and attaches them to `event`. This is what keeps - * breadcrumb persistence off the per-mutation scope-flush path - the app only - * ever appends a single breadcrumb, and the daemon does the assembly here. + * newest `max_breadcrumbs`, and attaches them to `event`. * Mirrors the crashpad backend's `report_to_envelope`. */ static void @@ -2184,11 +2181,6 @@ apply_breadcrumbs_from_ring_files(sentry_value_t event, } /** - * Build a native event from the scope-complete base event, adding the - * caller-specified framing (level, mechanism) plus threads, breadcrumbs (read - * from the ring files), and debug_meta. The base event (contexts, tags, user, - * ...) is identical regardless of event type; the caller states what this - * event is. * Build a native event and set the level, mechanism, and handled state * * @param ctx Crash context @@ -2223,8 +2215,6 @@ build_native_event(const sentry_crash_context_t *ctx, event = sentry_value_new_event(); } - // Assemble breadcrumbs from the ring files (the base event carries none - - // the app keeps them off the scope-flush hot path). apply_breadcrumbs_from_ring_files(event, run_folder, ctx); // Set platform to native @@ -2806,10 +2796,8 @@ write_envelope_with_minidump(const sentry_options_t *options, const char *event_msgpack_path, const char *minidump_path, sentry_path_t *run_folder) { - // Read the base event, merge in the breadcrumbs from the ring files (the - // base event carries none), and re-serialize. Unlike the native-stacktrace - // path this mode otherwise streams the event verbatim, so we have to - // round-trip through a value to attach breadcrumbs. + // Read the base event, merge in the breadcrumbs from the ring files, + // re-serialize. size_t event_size = 0; char *event_json = NULL; char *event_id = NULL; diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 682bdbbab4..df87cad8ed 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -870,9 +870,7 @@ native_backend_add_breadcrumb(sentry_backend_t *backend, // Append as msgpack, matching the crashpad backend. msgpack values are // self-delimiting, so the daemon can read the concatenated ring file back - // into a list via `sentry__value_from_msgpack`. This is the only breadcrumb - // persistence on the hot path: one serialize + one append per breadcrumb, - // never a full scope re-serialization. + // into a list via `sentry__value_from_msgpack`. size_t mpack_size = 0; char *mpack = sentry_value_to_msgpack(breadcrumb, &mpack_size); if (!mpack) { From 1326bc5307126d2e3c02f2dc67453b53dd2f396a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 16:42:41 +0200 Subject: [PATCH 09/26] bail with breadcrumbs disabled --- src/backends/native/sentry_crash_daemon.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 1873ed73ae..28fbda11dd 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2162,6 +2162,10 @@ static void apply_breadcrumbs_from_ring_files(sentry_value_t event, const sentry_path_t *run_folder, const sentry_crash_context_t *ctx) { + if (ctx && ctx->max_breadcrumbs == 0) { + return; + } + sentry_value_t b1 = read_breadcrumb_ring_file(run_folder, "__sentry-breadcrumb1"); sentry_value_t b2 From 4fb6895eda4bea329a980c4253d995a1d5f0fa81 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 17:26:31 +0200 Subject: [PATCH 10/26] fixed serialization regression --- src/backends/native/sentry_crash_daemon.c | 27 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 28fbda11dd..497a26889a 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2813,11 +2813,28 @@ write_envelope_with_minidump(const sentry_options_t *options, if (base_json && base_size > 0) { sentry_value_t event = sentry__value_from_json(base_json, base_size); - apply_breadcrumbs_from_ring_files(event, run_folder, ctx); - event_id = sentry__string_clone(sentry_value_as_string( - sentry_value_get_by_key(event, "event_id"))); - event_json = sentry__value_to_json(event, &event_size); - sentry_value_decref(event); + if (sentry_value_is_null(event)) { + // Parsing the base event failed (e.g. truncated buffer or + // OOM). Don't serialize the null into "null" and ship an + // invalid payload - fall back to streaming the raw event + // bytes verbatim so the crash report is preserved. + sentry_value_decref(event); + event_json = sentry__string_clone_n(base_json, base_size); + event_size = event_json ? base_size : 0; + } else { + apply_breadcrumbs_from_ring_files(event, run_folder, ctx); + event_id = sentry__string_clone(sentry_value_as_string( + sentry_value_get_by_key(event, "event_id"))); + event_json = sentry__value_to_json(event, &event_size); + sentry_value_decref(event); + if (!event_json) { + // Re-serialization failed (e.g. OOM). Fall back to the raw + // event bytes so the crash report is preserved, losing only + // the merged breadcrumbs rather than the whole event. + event_json = sentry__string_clone_n(base_json, base_size); + event_size = event_json ? base_size : 0; + } + } } sentry_free(base_json); } From 8a5a6beabd956714bd2c4c5caebde24de8521e81 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 17:37:21 +0200 Subject: [PATCH 11/26] fixed scope sync limit --- src/backends/sentry_backend_native.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index df87cad8ed..dec427d798 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -1014,10 +1014,11 @@ native_backend_except(sentry_backend_t *backend, const sentry_ucontext_t *uctx) } if (should_handle) { - // Apply scope to event including breadcrumbs + // Apply scope to the event. The daemon assembles breadcrumbs + // from the ring files SENTRY_WITH_SCOPE (scope) { sentry__scope_apply_to_event( - scope, options, event, SENTRY_SCOPE_BREADCRUMBS); + scope, options, event, SENTRY_SCOPE_NONE); } #if defined(SENTRY_PLATFORM_WINDOWS) ensure_device_arch(event); From 5676031dbe198fb47a53f96fdf31d8c261dd4e5f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 3 Jun 2026 18:48:32 +0200 Subject: [PATCH 12/26] added app hang feature, macOS only --- examples/example.c | 48 ++ include/sentry.h | 59 ++ src/CMakeLists.txt | 2 + src/backends/native/sentry_crash_context.h | 18 + src/backends/native/sentry_crash_daemon.c | 637 +++++++++++++++++++-- src/backends/sentry_backend_native.c | 28 +- src/sentry_app_hang.c | 159 +++++ src/sentry_app_hang.h | 77 +++ src/sentry_options.c | 2 + src/sentry_options.h | 2 + tests/test_integration_native.py | 70 +++ tests/unit/CMakeLists.txt | 1 + tests/unit/test_app_hang.c | 153 +++++ tests/unit/tests.inc | 11 + 14 files changed, 1227 insertions(+), 40 deletions(-) create mode 100644 src/sentry_app_hang.c create mode 100644 src/sentry_app_hang.h create mode 100644 tests/unit/test_app_hang.c diff --git a/examples/example.c b/examples/example.c index d3a0832800..d15494e226 100644 --- a/examples/example.c +++ b/examples/example.c @@ -612,6 +612,30 @@ run_threads(thread_func_t func) } #endif +#if defined(SENTRY_PLATFORM_MACOS) +static void * +app_hang_demo_thread(void *arg) +{ + (void)arg; + /* Latch this thread as the target once, then heartbeat for 500 ms so the + * daemon sees a healthy baseline before the freeze. */ + sentry_app_hang_set_target_thread(); + for (int i = 0; i < 10; i++) { + sentry_app_hang_heartbeat(); + usleep(50 * 1000); + } + /* Add a couple of breadcrumbs before freezing so the captured app-hang + * event carries them (the daemon reads the breadcrumb ring files the host + * writes on each sentry_add_breadcrumb). */ + sentry_add_breadcrumb( + sentry_value_new_breadcrumb(NULL, "app-hang demo: about to freeze")); + sentry_add_breadcrumb(create_debug_crumb("app-hang demo breadcrumb")); + /* Freeze for 3x the configured timeout (3000 ms). */ + usleep(3000 * 1000); + return NULL; +} +#endif + int main(int argc, char **argv) { @@ -879,6 +903,13 @@ main(int argc, char **argv) options, SENTRY_CRASH_UPLOAD_MODE_ASYNC); } +#if defined(SENTRY_PLATFORM_MACOS) + if (has_arg(argc, argv, "app-hang")) { + sentry_options_set_app_hang_enabled(options, 1); + sentry_options_set_app_hang_timeout_ms(options, 1000); + } +#endif + // E2E test mode: generate unique test ID for event correlation char e2e_test_id[37] = { 0 }; if (has_arg(argc, argv, "e2e-test")) { @@ -890,6 +921,23 @@ main(int argc, char **argv) return EXIT_FAILURE; } +#if defined(SENTRY_PLATFORM_MACOS) + /* app-hang: spawn the demo thread BEFORE any other post-init work so it + * begins heartbeating immediately. The thread freezes for 3x the timeout, + * giving the daemon time to detect the hang and ship the envelope. We wait + * for it here so main does not exit before the transport has flushed. + * NOTE: this mode is intentionally exclusive – do not combine with crash/ + * abort/etc. since those would terminate the process first. */ + if (has_arg(argc, argv, "app-hang")) { + pthread_t t; + if (0 == pthread_create(&t, NULL, app_hang_demo_thread, NULL)) { + pthread_join(t, NULL); + } + sentry_close(); + return EXIT_SUCCESS; + } +#endif + if (has_arg(argc, argv, "user-consent-revoke")) { sentry_user_consent_revoke(); } diff --git a/include/sentry.h b/include/sentry.h index 25416813e1..c7aede2410 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1697,6 +1697,65 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_attach_session_replay( SENTRY_EXPERIMENTAL_API void sentry_options_set_session_replay_duration( sentry_options_t *opts, uint32_t duration_ms); +/** + * Enable app-hang detection in the native crash backend. + * + * When enabled, the out-of-process daemon monitors a designated thread in the + * host via a shared-memory heartbeat. If the heartbeat goes stale for longer + * than the configured timeout, the daemon walks the thread's stack remotely and + * emits an `ApplicationNotResponding` event. The host process keeps running. + * + * Off by default. This setting only has an effect when using the `native` + * backend. In this initial release the feature is macOS-only; the call is a + * silent no-op on other platforms. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_app_hang_enabled( + sentry_options_t *opts, int enabled); + +/** + * Sets the heartbeat-staleness threshold (in milliseconds) used by the + * app-hang detector. Default 5000 ms. + * + * Read by the daemon once at startup; changes after `sentry_init` have no + * effect. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_app_hang_timeout_ms( + sentry_options_t *opts, uint64_t timeout_ms); + +/** + * Designate the calling thread as the one monitored by the app-hang detector. + * + * Call this once, from the thread you want monitored (typically the main / + * game thread), before the first heartbeat. The latch is sticky for the + * lifetime of the SDK session: subsequent calls from any other thread are + * dropped. Calling again from the same thread is a harmless no-op. + * + * Until this is called, `sentry_app_hang_heartbeat()` is a no-op — there is + * no implicit "first caller wins" latch, so a stray heartbeat from a worker + * thread during startup cannot accidentally claim the role and silently + * disable monitoring of the real main thread. + * + * No-op if app-hang detection is not enabled in options, or if the native + * backend is not active, or on non-macOS platforms. + */ +SENTRY_EXPERIMENTAL_API void sentry_app_hang_set_target_thread(void); + +/** + * Refresh the heartbeat for the monitored thread. + * + * Call this from the thread previously designated via + * `sentry_app_hang_set_target_thread()`. Calls from any other thread, or + * before a target has been set, are dropped — so a stray heartbeat from a + * worker thread cannot mask a frozen main thread. + * + * Cost: approximately one system call plus a relaxed 64-bit store. Safe to + * call from a per-frame hook in a game engine. + * + * No-op if app-hang detection is not enabled in options, or if the native + * backend is not active, or on non-macOS platforms. + */ +SENTRY_EXPERIMENTAL_API void sentry_app_hang_heartbeat(void); + /** * Sets the path to the crashpad handler if the crashpad backend is used. * diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6086dbaafb..a29f7e88b0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,8 @@ sentry_target_sources_cwd(sentry sentry_alloc.c sentry_alloc.h + sentry_app_hang.c + sentry_app_hang.h sentry_attachment.c sentry_attachment.h sentry_backend.c diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index b6c985cd3d..7ae3773499 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -326,6 +326,24 @@ typedef struct { uint32_t module_count; sentry_module_info_t modules[SENTRY_CRASH_MAX_MODULES]; + /* App-hang detection (macOS, native backend only). + * + * Sync model: + * - app_hang_enabled, app_hang_timeout_ms: written by host before daemon + * is signalled ready; read by daemon at startup. No further mutation. + * - app_hang_target_tid: latched once by host on first heartbeat via a + * compare-exchange (atomic_compare_exchange_strong). Daemon reads, never + * writes. + * - app_hang_last_heartbeat_ms: written on every heartbeat with a relaxed + * 64-bit store. Daemon reads with a relaxed load. Torn reads are not a + * correctness issue — the daemon compares against its remembered value + * from the previous tick. (On 64-bit macOS the aligned store is atomic.) + */ + bool app_hang_enabled; + uint64_t app_hang_timeout_ms; + volatile uint64_t app_hang_target_tid; + volatile uint64_t app_hang_last_heartbeat_ms; + } sentry_crash_context_t; // Shared memory size: calculated at compile-time based on actual struct size diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 497a26889a..eba91c9f10 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2,6 +2,7 @@ #include "minidump/sentry_minidump_writer.h" #include "sentry_alloc.h" +#include "sentry_app_hang.h" #include "sentry_attachment.h" #include "sentry_core.h" #include "sentry_crash_ipc.h" @@ -45,6 +46,9 @@ # if defined(SENTRY_PLATFORM_MACOS) # include # include +# include +# include +# include # include # endif #elif defined(SENTRY_PLATFORM_WINDOWS) @@ -2118,6 +2122,55 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) return build_stacktrace_for_thread(ctx, SIZE_MAX); } +/* Describes which kind of native event we are building. `s_crash_kind` + * drives the crash path; `s_app_hang_kind` drives the app-hang flow on macOS. + * + * Invariant: if `include_signal_meta` is true, `exception_type` must be NULL + * (the signal-derived path). Setting an override type AND requesting signal + * metadata is incoherent — there is no signal in the override case. + */ +typedef struct { + /* Override exception `type` string. NULL = derive from the crash signal + * (e.g. "SIGSEGV" on Unix, "EXCEPTION" on Windows). */ + const char *exception_type; + /* Override exception `value` string. Used only when `exception_type` is + * non-NULL; ignored otherwise. */ + const char *exception_value; + /* `mechanism.type` JSON value, e.g. "signalhandler" or "AppHang". */ + const char *mechanism_type; + /* `mechanism.handled` JSON value. false for fatal crashes, true for + * recoverable events like app hangs. */ + bool mechanism_handled; + /* Event `level` JSON value, e.g. "fatal" or "error". */ + const char *level; + /* Attach `mechanism.meta.signal` payload? Must be false when + * `exception_type` is non-NULL (see struct invariant). */ + bool include_signal_meta; +} sentry_native_event_kind_t; + +/* Crash-path event kind: signal-derived type/value, fatal level, unhandled. */ +static const sentry_native_event_kind_t s_crash_kind = { + .exception_type = NULL, + .exception_value = NULL, + .mechanism_type = "signalhandler", + .mechanism_handled = false, + .level = "fatal", + .include_signal_meta = true, +}; + +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) +/* App-hang event kind: ANR-style, handled, error level. The per-event + * `exception_value` (freeze duration message) is filled in at capture time. */ +static const sentry_native_event_kind_t s_app_hang_kind = { + .exception_type = "ApplicationNotResponding", + .exception_value = NULL, /* filled in per-event below */ + .mechanism_type = "AppHang", + .mechanism_handled = true, + .level = "error", + .include_signal_meta = false, +}; +#endif + /** * Reads one breadcrumb ring file the crashing process appended on its hot path * into a breadcrumb list. Returns null if the file is absent or empty. @@ -2190,14 +2243,12 @@ apply_breadcrumbs_from_ring_files(sentry_value_t event, * @param ctx Crash context * @param event_file_path Path to base event file from parent process * @param run_folder Run directory holding the breadcrumb ring files - * @param level Event level (e.g. "fatal") - * @param mechanism_type Exception mechanism type (e.g. "signalhandler") - * @param handled Whether the mechanism was handled + * @param kind Event-kind descriptor controlling exception/mechanism/level */ static sentry_value_t -build_native_event(const sentry_crash_context_t *ctx, +build_native_crash_event(const sentry_crash_context_t *ctx, const char *event_file_path, const sentry_path_t *run_folder, - const char *level, const char *mechanism_type, bool handled) + const sentry_native_event_kind_t *kind) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2225,50 +2276,69 @@ build_native_event(const sentry_crash_context_t *ctx, sentry_value_set_by_key( event, "platform", sentry_value_new_string("native")); - sentry_value_set_by_key(event, "level", sentry_value_new_string(level)); + // Set level (varies by event kind: "fatal" for crash, "error" for app hang) + sentry_value_set_by_key( + event, "level", sentry_value_new_string(kind->level)); // Build exception - const char *signal_name = "UNKNOWN"; + /* Function-scope so exc_value (which may point into this buffer) remains + * valid after the `else` block below. Previously declared inside the + * else: out of scope by the time exc_value is read -> UB per C99 6.2.4. */ + char crash_value_buf[128]; + const char *exc_type; + const char *exc_value; + + if (kind->exception_type) { + exc_type = kind->exception_type; + exc_value = kind->exception_value ? kind->exception_value : ""; + } else { + const char *signal_name; #if defined(SENTRY_PLATFORM_UNIX) - int signal_number = ctx->platform.signum; - signal_name = get_signal_name(signal_number); + signal_name = get_signal_name(ctx->platform.signum); #elif defined(SENTRY_PLATFORM_WINDOWS) - // Exception code is used directly below as unsigned - signal_name = "EXCEPTION"; + signal_name = "EXCEPTION"; +#else + signal_name = "UNKNOWN"; #endif + exc_type = signal_name; + snprintf(crash_value_buf, sizeof(crash_value_buf), "Fatal crash: %s", + signal_name); + exc_value = crash_value_buf; + } sentry_value_t exc = sentry_value_new_object(); - sentry_value_set_by_key(exc, "type", sentry_value_new_string(signal_name)); - - char value_buf[128]; - snprintf(value_buf, sizeof(value_buf), "Fatal crash: %s", signal_name); - sentry_value_set_by_key(exc, "value", sentry_value_new_string(value_buf)); + sentry_value_set_by_key(exc, "type", sentry_value_new_string(exc_type)); + sentry_value_set_by_key(exc, "value", sentry_value_new_string(exc_value)); // Add mechanism sentry_value_t mechanism = sentry_value_new_object(); - sentry_value_set_by_key( - mechanism, "type", sentry_value_new_string(mechanism_type)); + sentry_value_set_by_key(mechanism, "type", + sentry_value_new_string(kind->mechanism_type)); sentry_value_set_by_key( mechanism, "synthetic", sentry_value_new_bool(true)); - sentry_value_set_by_key( - mechanism, "handled", sentry_value_new_bool(handled)); + sentry_value_set_by_key(mechanism, "handled", + sentry_value_new_bool(kind->mechanism_handled)); - // Add signal metadata - sentry_value_t meta = sentry_value_new_object(); - sentry_value_t signal_info = sentry_value_new_object(); + // Add signal metadata (only relevant for signal-handler/crash events) + if (kind->include_signal_meta) { + sentry_value_t meta = sentry_value_new_object(); + sentry_value_t signal_info = sentry_value_new_object(); #if defined(SENTRY_PLATFORM_WINDOWS) - // Windows exception codes are unsigned 32-bit values (e.g., 0xC0000005) - // Use uint64 to preserve the unsigned value for the symbolicator - sentry_value_set_by_key(signal_info, "number", - sentry_value_new_uint64((uint64_t)ctx->platform.exception_code)); + // Windows exception codes are unsigned 32-bit values (e.g., 0xC0000005) + // Use uint64 to preserve the unsigned value for the symbolicator + sentry_value_set_by_key(signal_info, "number", + sentry_value_new_uint64((uint64_t)ctx->platform.exception_code)); #else - sentry_value_set_by_key( - signal_info, "number", sentry_value_new_int32(signal_number)); + sentry_value_set_by_key(signal_info, "number", + sentry_value_new_int32(ctx->platform.signum)); #endif - sentry_value_set_by_key( - signal_info, "name", sentry_value_new_string(signal_name)); - sentry_value_set_by_key(meta, "signal", signal_info); - sentry_value_set_by_key(mechanism, "meta", meta); + /* By the struct invariant, include_signal_meta is only true when + * exception_type is NULL, so exc_type holds the signal name here. */ + sentry_value_set_by_key( + signal_info, "name", sentry_value_new_string(exc_type)); + sentry_value_set_by_key(meta, "signal", signal_info); + sentry_value_set_by_key(mechanism, "meta", meta); + } sentry_value_set_by_key(exc, "mechanism", mechanism); @@ -2545,13 +2615,13 @@ static bool write_envelope_with_native_stacktrace(const sentry_options_t *options, const char *envelope_path, const sentry_crash_context_t *ctx, const char *event_file_path, const char *minidump_path, - sentry_path_t *run_folder) + sentry_path_t *run_folder, const sentry_native_event_kind_t *kind) { // Build native crash event (always include threads with names) SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); - sentry_value_t event = build_native_event( - ctx, event_file_path, run_folder, "fatal", "signalhandler", false); + sentry_value_t event = build_native_crash_event( + ctx, event_file_path, run_folder, kind); // Serialize event to JSON size_t event_size = 0; @@ -2790,6 +2860,449 @@ write_envelope_with_native_stacktrace(const sentry_options_t *options, return true; } +#if defined(SENTRY_PLATFORM_MACOS) + +/* Read `size` bytes at `addr` from another task into `buf`. Mirrors the + * minidump writer's read_task_memory (mach_vm_read_overwrite). */ +static kern_return_t +app_hang_read_task_memory( + task_t task, mach_vm_address_t addr, void *buf, mach_vm_size_t size) +{ + mach_vm_size_t got = 0; + kern_return_t kr + = mach_vm_read_overwrite(task, addr, size, (mach_vm_address_t)buf, &got); + if (kr == KERN_SUCCESS && got != size) { + return KERN_FAILURE; + } + return kr; +} + +/* Enumerate the host's loaded dyld images out-of-process via the donated/ + * task_for_pid task port and populate ctx->modules[] (base, __TEXT vmsize, + * UUID, name). This is the out-of-process analogue of the in-process + * _dyld_image_count() loop the crash signal handler runs — needed for app + * hangs because no signal handler runs to capture modules, and the daemon's + * own dyld images are unrelated to the host's. Best-effort: on any read + * failure we stop and keep whatever was gathered. */ +static void +app_hang_capture_modules(task_t task, sentry_crash_context_t *ctx) +{ + ctx->module_count = 0; + + /* Locate dyld_all_image_infos in the target task. */ + struct task_dyld_info dyld_info; + mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT; + if (task_info(task, TASK_DYLD_INFO, (task_info_t)&dyld_info, &count) + != KERN_SUCCESS) { + SENTRY_DEBUG("app-hang: task_info(TASK_DYLD_INFO) failed"); + return; + } + + struct dyld_all_image_infos all_infos; + if (app_hang_read_task_memory(task, + (mach_vm_address_t)dyld_info.all_image_info_addr, &all_infos, + sizeof(all_infos)) + != KERN_SUCCESS) { + SENTRY_DEBUG("app-hang: failed to read dyld_all_image_infos"); + return; + } + + uint32_t image_count = all_infos.infoArrayCount; + if (image_count > SENTRY_CRASH_MAX_MODULES) { + image_count = SENTRY_CRASH_MAX_MODULES; + } + + for (uint32_t i = 0; + i < image_count && ctx->module_count < SENTRY_CRASH_MAX_MODULES; i++) { + /* Read one dyld_image_info entry from the remote infoArray. */ + struct dyld_image_info info; + mach_vm_address_t entry_addr = (mach_vm_address_t)all_infos.infoArray + + (mach_vm_address_t)i * sizeof(struct dyld_image_info); + if (app_hang_read_task_memory(task, entry_addr, &info, sizeof(info)) + != KERN_SUCCESS) { + break; + } + + uint64_t base = (uint64_t)info.imageLoadAddress; + if (base == 0) { + continue; + } + + sentry_module_info_t *module = &ctx->modules[ctx->module_count]; + memset(module, 0, sizeof(*module)); + module->base_address = base; + + /* Read the image path from the remote address. */ + if (info.imageFilePath) { + char namebuf[SENTRY_CRASH_MAX_PATH]; + memset(namebuf, 0, sizeof(namebuf)); + /* Read in a bounded chunk; tolerate a short read at the tail. */ + for (size_t off = 0; off < sizeof(namebuf) - 1; off += 256) { + size_t chunk = sizeof(namebuf) - 1 - off; + if (chunk > 256) { + chunk = 256; + } + if (app_hang_read_task_memory(task, + (mach_vm_address_t)info.imageFilePath + off, + namebuf + off, chunk) + != KERN_SUCCESS) { + break; + } + if (memchr(namebuf + off, '\0', chunk)) { + break; + } + } + namebuf[sizeof(namebuf) - 1] = '\0'; + strncpy(module->name, namebuf, sizeof(module->name) - 1); + } + + /* Read the Mach-O header + load commands to get __TEXT vmsize and + * UUID, mirroring the in-process loop in the signal handler. */ + struct mach_header_64 header; + if (app_hang_read_task_memory( + task, (mach_vm_address_t)base, &header, sizeof(header)) + == KERN_SUCCESS + && (header.magic == MH_MAGIC_64 || header.magic == MH_CIGAM_64)) { + uint32_t ncmds = header.ncmds; + if (ncmds > 256) { + ncmds = 256; + } + /* Read the load-command region in one shot (capped). */ + uint32_t cmds_size = header.sizeofcmds; + if (cmds_size > 0 && cmds_size <= 64 * 1024) { + uint8_t *cmds = sentry_malloc(cmds_size); + if (cmds + && app_hang_read_task_memory(task, + (mach_vm_address_t)base + sizeof(header), cmds, + cmds_size) + == KERN_SUCCESS) { + const uint8_t *p = cmds; + const uint8_t *end = cmds + cmds_size; + bool has_size = false, has_uuid = false; + for (uint32_t j = 0; + j < ncmds && (!has_size || !has_uuid) + && p + sizeof(struct load_command) <= end; + j++) { + const struct load_command *lc + = (const struct load_command *)p; + if (lc->cmdsize == 0 + || p + lc->cmdsize > end) { + break; + } + if (lc->cmd == LC_SEGMENT_64 + && lc->cmdsize >= sizeof(struct segment_command_64)) { + const struct segment_command_64 *seg + = (const struct segment_command_64 *)lc; + if (memcmp(seg->segname, "__TEXT", 7) == 0) { + module->size = seg->vmsize; + has_size = true; + } + } else if (lc->cmd == LC_UUID + && lc->cmdsize >= sizeof(struct uuid_command)) { + const struct uuid_command *uc + = (const struct uuid_command *)lc; + memcpy(module->uuid, uc->uuid, 16); + has_uuid = true; + } + p += lc->cmdsize; + } + } + sentry_free(cmds); + } + } + + ctx->module_count++; + } + + SENTRY_DEBUGF( + "app-hang: captured %u modules out-of-process", ctx->module_count); +} + +/* Read the hung thread's stack memory (from SP upward) out-of-process and save + * it to a file, populating threads[0].stack_path / stack_size so the existing + * FP-unwinder in build_stacktrace_for_thread can walk real frames — the same + * file-backed mechanism the signal handler uses for crashes. Best-effort. */ +static void +app_hang_capture_stack( + task_t task, sentry_crash_context_t *ctx, uint64_t sp) +{ + ctx->platform.threads[0].stack_path[0] = '\0'; + ctx->platform.threads[0].stack_size = 0; + if (sp == 0) { + return; + } + + mach_vm_size_t want = SENTRY_CRASH_MAX_STACK_CAPTURE; + uint8_t *buf = sentry_malloc(want); + if (!buf) { + return; + } + + /* Shrink the read until it succeeds — the top of stack may be near a guard + * page, so a full-size read can straddle unmapped memory and fail. */ + mach_vm_size_t got = 0; + while (want >= 4096) { + if (app_hang_read_task_memory(task, (mach_vm_address_t)sp, buf, want) + == KERN_SUCCESS) { + got = want; + break; + } + want /= 2; + } + if (got == 0) { + SENTRY_DEBUG("app-hang: failed to read hung thread stack"); + sentry_free(buf); + return; + } + + char stack_path[SENTRY_CRASH_MAX_PATH]; + int n = snprintf(stack_path, sizeof(stack_path), + "%s/sentry-app-hang-stack-%lu.bin", ctx->database_path, + (unsigned long)ctx->crashed_pid); + if (n < 0 || n >= (int)sizeof(stack_path)) { + sentry_free(buf); + return; + } + int fd = open(stack_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + if (write(fd, buf, (size_t)got) == (ssize_t)got) { + strncpy(ctx->platform.threads[0].stack_path, stack_path, + sizeof(ctx->platform.threads[0].stack_path) - 1); + ctx->platform.threads[0].stack_size = got; + SENTRY_DEBUGF("app-hang: captured %llu bytes of stack", + (unsigned long long)got); + } + close(fd); + } + sentry_free(buf); +} + +/** + * App-hang capture path (macOS). The host is alive but frozen, so unlike a + * crash there is no in-process signal-handler snapshot to fall back on — the + * daemon must sample the hung thread itself. It does so out-of-process via + * `task_for_pid` (the same mechanism the crash "full path" minidump writer + * relies on): locate the Mach thread whose THREAD_IDENTIFIER_INFO.thread_id + * matches the latched target tid, suspend it just long enough to read its + * register state, then resume and build/submit an AppHang envelope using the + * same native-stacktrace path as crashes. + * + * Requires `task_for_pid` to be permitted (same-user, non-hardened local/dev + * builds). On a hardened release runtime without the debugger entitlement it + * is denied; the entitlement-free port-donation replacement is a separate + * follow-up. + */ +static void +capture_and_send_app_hang(const sentry_options_t *options, + sentry_crash_ipc_t *ipc, uint64_t freeze_ms) +{ + /* NOTE (race, experimental first cut): this function reads and mutates + * shmem fields (platform.mcontext, threads[0], crashed_tid, num_threads) + * that the host's signal handler also writes on a real crash. The daemon + * loop is single-threaded and processes a pending crash before reaching + * here, so the only remaining window is the host crashing mid-capture. + * Accepted for this initial cut; mitigation is tracked as follow-up. */ + sentry_crash_context_t *ctx = ipc->shmem; + + const uint64_t target_tid = ctx->app_hang_target_tid; + + /* Acquire the host task. No in-process snapshot exists for a hang, so a + * failure here means we simply cannot capture this hang. */ + task_t task = MACH_PORT_NULL; + kern_return_t kr + = task_for_pid(mach_task_self(), (int)ctx->crashed_pid, &task); + if (kr != KERN_SUCCESS) { + SENTRY_DEBUGF("app-hang: task_for_pid(%d) failed: %d (%s) — no " + "snapshot available for a hang", + (int)ctx->crashed_pid, kr, mach_error_string(kr)); + return; + } + + /* Enumerate the host's dyld modules out-of-process so debug_meta is + * populated and frames symbolicate server-side (the in-process signal + * handler that normally does this never runs for a hang). */ + app_hang_capture_modules(task, ctx); + + /* Enumerate threads and find the latched target by its portable tid. */ + thread_act_array_t threads = NULL; + mach_msg_type_number_t thread_count = 0; + kr = task_threads(task, &threads, &thread_count); + if (kr != KERN_SUCCESS) { + SENTRY_DEBUGF("app-hang: task_threads failed: %d (%s)", kr, + mach_error_string(kr)); + mach_port_deallocate(mach_task_self(), task); + return; + } + + thread_t target = MACH_PORT_NULL; + for (mach_msg_type_number_t i = 0; i < thread_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) { + target = threads[i]; + } else { + /* Deallocate the ports we are not keeping. */ + mach_port_deallocate(mach_task_self(), threads[i]); + } + } + + if (target == MACH_PORT_NULL) { + SENTRY_DEBUGF("app-hang: target thread tid=%llu not found among %u " + "threads", + (unsigned long long)target_tid, thread_count); + vm_deallocate(mach_task_self(), (vm_address_t)threads, + thread_count * sizeof(thread_t)); + mach_port_deallocate(mach_task_self(), task); + return; + } + + /* Suspend the target just long enough to read its register state. */ + kr = thread_suspend(target); + if (kr != KERN_SUCCESS) { + SENTRY_DEBUGF("app-hang: thread_suspend failed: %d (%s)", kr, + mach_error_string(kr)); + mach_port_deallocate(mach_task_self(), target); + vm_deallocate(mach_task_self(), (vm_address_t)threads, + thread_count * sizeof(thread_t)); + mach_port_deallocate(mach_task_self(), task); + return; + } + + /* Read the integer register set directly into mcontext.__ss. Use the + * arch-specific flavor (ARM_THREAD_STATE64 / x86_THREAD_STATE64) that + * matches __ss's layout — NOT MACHINE_THREAD_STATE, which is the tagged + * *unified* state (arm_unified_thread_state_t) and would land with the + * wrong layout, yielding garbage IP/FP/SP. */ + _STRUCT_MCONTEXT mcontext; + memset(&mcontext, 0, sizeof(mcontext)); +# if defined(__x86_64__) + mach_msg_type_number_t state_count = x86_THREAD_STATE64_COUNT; + kr = thread_get_state(target, x86_THREAD_STATE64, + (thread_state_t)&mcontext.__ss, &state_count); +# elif defined(__aarch64__) + mach_msg_type_number_t state_count = ARM_THREAD_STATE64_COUNT; + kr = thread_get_state(target, ARM_THREAD_STATE64, + (thread_state_t)&mcontext.__ss, &state_count); +# else + mach_msg_type_number_t state_count = MACHINE_THREAD_STATE_COUNT; + kr = thread_get_state( + target, MACHINE_THREAD_STATE, (thread_state_t)&mcontext.__ss, + &state_count); +# endif + + thread_resume(target); + + if (kr != KERN_SUCCESS) { + SENTRY_DEBUGF("app-hang: thread_get_state failed: %d (%s)", kr, + mach_error_string(kr)); + mach_port_deallocate(mach_task_self(), target); + vm_deallocate(mach_task_self(), (vm_address_t)threads, + thread_count * sizeof(thread_t)); + mach_port_deallocate(mach_task_self(), task); + return; + } + + /* Place the snapshot in the "crashed thread" slot of the context so the + * existing event builder pulls a stacktrace and register block out for the + * exception payload and the threads block. + * + * build_stacktrace_from_ctx() (thread_idx == SIZE_MAX) reads from + * ctx->platform.mcontext, while the per-thread register block reads from + * threads[0].state — populate both so the captured registers are used and + * not an all-zero context (PC=0 -> no frames). */ + ctx->platform.mcontext = mcontext; + ctx->crashed_tid = (pid_t)target_tid; + ctx->platform.num_threads = 1; + ctx->platform.threads[0].thread = target; /* port; valid only here */ + ctx->platform.threads[0].tid = target_tid; + ctx->platform.threads[0].state = mcontext; + ctx->platform.threads[0].stack_path[0] = '\0'; + ctx->platform.threads[0].stack_size = 0; + + /* Capture the hung thread's stack (from SP upward) out-of-process so the + * FP-unwinder can walk real frames instead of just the top PC. Must happen + * while we still hold the task port. */ + uint64_t target_sp = 0; +# if defined(__x86_64__) + target_sp = mcontext.__ss.__rsp; +# elif defined(__aarch64__) + target_sp = SENTRY__ARM64_GET_SP(mcontext.__ss); +# endif + app_hang_capture_stack(task, ctx, target_sp); + + /* Done reading from the host task; release the Mach ports. */ + mach_port_deallocate(mach_task_self(), target); + vm_deallocate(mach_task_self(), (vm_address_t)threads, + thread_count * sizeof(thread_t)); + mach_port_deallocate(mach_task_self(), task); + + /* Build the per-event value description with the freeze duration. */ + char value_buf[128]; + snprintf(value_buf, sizeof(value_buf), + "App hang detected. Main thread blocked for %llu ms.", + (unsigned long long)freeze_ms); + sentry_native_event_kind_t kind = s_app_hang_kind; + kind.exception_value = value_buf; + + /* Build an envelope path next to the crash one. */ + char envelope_path[SENTRY_CRASH_MAX_PATH]; + int path_len = snprintf(envelope_path, sizeof(envelope_path), + "%s/sentry-app-hang-%lu-%llu.env", ctx->database_path, + (unsigned long)ctx->crashed_pid, + (unsigned long long)ctx->app_hang_last_heartbeat_ms); + + if (path_len < 0 || path_len >= (int)sizeof(envelope_path)) { + SENTRY_WARN("app-hang: envelope path truncated or invalid"); + return; + } + + /* Reuse the scope file the host keeps up-to-date via flush_scope so the + * app-hang event carries the same scope context as a crash event: full + * contexts (os/device/gpu/app/runtime/...), user, tags, extra, fingerprint, + * release/dist/env, sdk metadata, and breadcrumbs. The base event JSON is + * at ctx->event_path; the sibling run folder holds the attachments + * manifest, scope attachments, screenshot, and session replay — all pulled + * in by write_envelope_with_native_stacktrace when run_folder is + * non-NULL. */ + const char *event_file_path + = ctx->event_path[0] ? ctx->event_path : NULL; + sentry_path_t *run_folder = NULL; + if (event_file_path) { + sentry_path_t *ev_path = sentry__path_from_str(event_file_path); + if (ev_path) { + run_folder = sentry__path_dir(ev_path); + sentry__path_free(ev_path); + } + } + + bool ok = write_envelope_with_native_stacktrace(options, envelope_path, + ctx, event_file_path, /*minidump_path=*/NULL, run_folder, &kind); + + if (run_folder) { + sentry__path_free(run_folder); + } + + if (!ok) { + SENTRY_WARN("app-hang: failed to write envelope"); + return; + } + + /* Read envelope from disk and hand to transport. */ + sentry_path_t *env_path = sentry__path_from_str(envelope_path); + if (env_path) { + sentry_envelope_t *envelope = sentry__envelope_from_path(env_path); + if (envelope && options && options->transport) { + sentry__capture_envelope(options->transport, envelope, options); + } + sentry__path_remove(env_path); + sentry__path_free(env_path); + } +} +#endif /* SENTRY_PLATFORM_MACOS */ + /** * Manually write a Sentry envelope with event, minidump, and attachments. * Format matches what Crashpad's Envelope class does. @@ -3299,7 +3812,8 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) minidump_path[0] ? minidump_path : "NULL"); envelope_written = write_envelope_with_native_stacktrace(options, envelope_path, ctx, event_path, - minidump_path[0] ? minidump_path : NULL, run_folder); + minidump_path[0] ? minidump_path : NULL, run_folder, + &s_crash_kind); } else { // Mode 0 (MINIDUMP only) SENTRY_DEBUG("Writing envelope with minidump"); @@ -3735,12 +4249,36 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, SENTRY_DEBUG("Entering main loop"); +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) + /* Pre-populate crashed_pid so the app-hang path can reach the host + * out-of-process via task_for_pid. ctx->crashed_pid is otherwise only set + * by the host's crash handler; the crash handler re-sets it from the host + * context on a real crash — a no-op (same value). */ + ipc->shmem->crashed_pid = (pid_t)app_pid; +#endif + // Daemon main loop bool crash_processed = false; + +#if defined(SENTRY_PLATFORM_MACOS) + /* App-hang detector state. Daemon-local; the timeout is cached here so it + * does not race the host on subsequent shmem mutations. When enabled, the + * loop polls on a short cadence (so it can evaluate the heartbeat each + * tick) instead of the longer health-check interval. */ + const bool app_hang_enabled = ipc->shmem->app_hang_enabled; + const uint64_t app_hang_timeout_ms = ipc->shmem->app_hang_timeout_ms; + uint64_t last_fired_hb = 0; + int consecutive_stale_ticks = 0; + const int wait_timeout_ms = app_hang_enabled + ? 500 + : SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; +#else + const int wait_timeout_ms = SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; +#endif + while (true) { // Wait for crash notification (with timeout to check parent health) - bool wait_result - = sentry__crash_ipc_wait(ipc, SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS); + bool wait_result = sentry__crash_ipc_wait(ipc, wait_timeout_ms); if (wait_result) { // Crash occurred! SENTRY_DEBUG("Event signaled, checking crash state"); @@ -3774,6 +4312,27 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, // If crash already processed, just ignore spurious notifications SENTRY_DEBUG("Spurious notification or already processed"); } +#if defined(SENTRY_PLATFORM_MACOS) + else if (app_hang_enabled && !crash_processed) { + /* No crash notification this wake (timeout or spurious) — evaluate + * the app-hang heartbeat with strike accumulation. */ + sentry_crash_context_t *shctx = ipc->shmem; + const uint64_t hb = shctx->app_hang_last_heartbeat_ms; + const uint64_t now = sentry__app_hang_now_ms(); + int new_strikes = 0; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + app_hang_enabled, hb, now, app_hang_timeout_ms, + last_fired_hb, consecutive_stale_ticks, &new_strikes); + consecutive_stale_ticks = new_strikes; + if (d == SENTRY_APP_HANG_FIRE) { + capture_and_send_app_hang(options, ipc, now - hb); + /* Always advance last_fired_hb, even if capture failed — + * prevents a retry storm against a wedged thread. The next + * heartbeat advance re-arms detection naturally. */ + last_fired_hb = hb; + } + } +#endif // Check if parent is still alive (only if no crash processed yet) if (!crash_processed && !is_parent_alive(ipc->parent_handle)) { diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index dec427d798..25a2319ffd 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -18,6 +18,7 @@ #include #include "sentry_alloc.h" +#include "sentry_app_hang.h" #include "sentry_backend.h" #include "sentry_core.h" #include "sentry_crash_context.h" @@ -313,6 +314,17 @@ native_backend_startup( sentry__atomic_store( &ctx->user_consent, sentry__atomic_fetch(&options->run->user_consent)); + /* App-hang detection configuration. + * + * NOTE: sentry__app_hang_set_shmem(ctx) is intentionally deferred until + * just before the function's successful `return 0;` below. If a later + * fallible call fails (e.g., daemon spawn) we free the IPC; registering + * the global pointer early would leave it dangling. */ + ctx->app_hang_enabled = options->app_hang_enabled; + ctx->app_hang_timeout_ms = options->app_hang_timeout_ms; + ctx->app_hang_target_tid = 0; + ctx->app_hang_last_heartbeat_ms = 0; + // Set up event and breadcrumb paths sentry_path_t *run_path = options->run->run_path; sentry_path_t *db_path = options->database_path; @@ -553,6 +565,14 @@ native_backend_startup( } #endif +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) + /* Make this shmem block visible to sentry_app_hang_heartbeat now that + * all fallible startup steps have succeeded. If any earlier step had + * failed we would have freed the IPC and returned without ever + * registering — keeping g_app_hang_shmem == NULL. */ + sentry__app_hang_set_shmem(ctx); +#endif + SENTRY_DEBUG("native backend started successfully"); return 0; } @@ -668,6 +688,11 @@ native_backend_shutdown(sentry_backend_t *backend) // Cleanup IPC if (state->ipc) { +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) + /* Clear the global heartbeat pointer before the shmem backing it goes + * away, so sentry_app_hang_heartbeat() cannot write to freed memory. */ + sentry__app_hang_set_shmem(NULL); +#endif sentry__crash_ipc_free(state->ipc); state->ipc = NULL; // Prevent use-after-free } @@ -818,7 +843,8 @@ native_backend_flush_scope( return; } - // Create event with current scope + // Create event with current scope. The daemon also reads this base event + // at app-hang time on macOS, so keep it current. sentry_value_t event = sentry_value_new_object(); sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c new file mode 100644 index 0000000000..aefb51ba65 --- /dev/null +++ b/src/sentry_app_hang.c @@ -0,0 +1,159 @@ +/* pthread_threadid_np() and CLOCK_UPTIME_RAW are Darwin extensions hidden when + * a strict POSIX feature macro (e.g. _XOPEN_SOURCE, set transitively by + * sentry_crash_context.h) is active. Re-expose them before any include. */ +#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) +# define _DARWIN_C_SOURCE +#endif + +#include "sentry_app_hang.h" + +#include "sentry_options.h" + +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) +# include +# include +# include +#endif + +sentry_app_hang_decision_t +sentry__app_hang_decide(bool enabled, uint64_t hb, uint64_t now, + uint64_t timeout_ms, uint64_t last_fired_hb, + int consecutive_stale_ticks, int *out_consecutive_stale_ticks) +{ + /* Fresh or disabled paths reset the counter. */ + if (!enabled || hb == 0) { + *out_consecutive_stale_ticks = 0; + return SENTRY_APP_HANG_NO_ACTION; + } + if (now < hb) { + /* Torn shmem read (possible on x86 for a non-atomic 64-bit load). + * Treat as fresh — daemon will see the real value on the next tick. */ + *out_consecutive_stale_ticks = 0; + return SENTRY_APP_HANG_NO_ACTION; + } + if ((now - hb) < timeout_ms) { + *out_consecutive_stale_ticks = 0; + return SENTRY_APP_HANG_NO_ACTION; + } + if (hb == last_fired_hb) { + /* Already fired for this freeze. Stay quiet and hold the counter at + * zero so we re-arm cleanly once the host heartbeats again. */ + *out_consecutive_stale_ticks = 0; + return SENTRY_APP_HANG_NO_ACTION; + } + /* Stale and not in cooldown — accumulate a strike. */ + int new_count = consecutive_stale_ticks + 1; + *out_consecutive_stale_ticks = new_count; + if (new_count >= SENTRY_APP_HANG_STRIKES_REQUIRED) { + return SENTRY_APP_HANG_FIRE; + } + return SENTRY_APP_HANG_NO_ACTION; +} + +/* Public setters (always compiled, no platform guard — they only mutate the + * options struct). */ +void +sentry_options_set_app_hang_enabled(sentry_options_t *opts, int enabled) +{ + if (opts) { + opts->app_hang_enabled = !!enabled; + } +} + +void +sentry_options_set_app_hang_timeout_ms( + sentry_options_t *opts, uint64_t timeout_ms) +{ + if (opts) { + opts->app_hang_timeout_ms = timeout_ms; + } +} + +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) + +static sentry_crash_context_t *volatile g_app_hang_shmem = NULL; + +void +sentry__app_hang_set_shmem(sentry_crash_context_t *ctx) +{ + g_app_hang_shmem = ctx; +} + +uint64_t +sentry__app_hang_now_ms(void) +{ + /* CLOCK_UPTIME_RAW is the macOS analogue of Windows' + * QueryUnbiasedInterruptTime: a monotonic clock that excludes time the + * system was asleep, read identically by host and daemon. */ + struct timespec ts; + if (clock_gettime(CLOCK_UPTIME_RAW, &ts) != 0) { + return 0; + } + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; +} + +void +sentry_app_hang_set_target_thread(void) +{ + sentry_crash_context_t *ctx = g_app_hang_shmem; + if (!ctx || !ctx->app_hang_enabled) { + return; + } + + /* Obtain the portable 64-bit Mach thread id of the current thread; this + * is the same value the daemon matches against via + * thread_info(THREAD_IDENTIFIER_INFO). */ + uint64_t current_tid = 0; + if (pthread_threadid_np(NULL, ¤t_tid) != 0 || current_tid == 0) { + return; + } + + /* CAS the current TID into the latch slot iff still unset — first caller + * wins, idempotent for that caller. The shmem field is declared + * `volatile uint64_t`; view it as an atomic for the compare-exchange. */ + _Atomic uint64_t *slot + = (_Atomic uint64_t *)(void *)&ctx->app_hang_target_tid; + uint64_t expected = 0; + atomic_compare_exchange_strong(slot, &expected, current_tid); +} + +void +sentry_app_hang_heartbeat(void) +{ + sentry_crash_context_t *ctx = g_app_hang_shmem; + if (!ctx || !ctx->app_hang_enabled) { + return; + } + + /* Refresh-only: requires a prior sentry_app_hang_set_target_thread() + * call from this thread. Drops the heartbeat if no target is latched, or + * if the latched thread is not us. */ + uint64_t current_tid = 0; + if (pthread_threadid_np(NULL, ¤t_tid) != 0 || current_tid == 0) { + return; + } + uint64_t latched = ctx->app_hang_target_tid; + if (latched == 0 || latched != current_tid) { + return; + } + + /* Relaxed 64-bit store; aligned on a 64-bit target so it is atomic and + * cannot tear. The daemon reads it with a relaxed load. */ + ctx->app_hang_last_heartbeat_ms = sentry__app_hang_now_ms(); +} + +#else /* host heartbeat not supported on this target */ + +void +sentry_app_hang_set_target_thread(void) +{ + /* No-op on non-macOS targets in this initial cut. */ +} + +void +sentry_app_hang_heartbeat(void) +{ + /* No-op on non-macOS targets in this initial cut. */ +} + +#endif diff --git a/src/sentry_app_hang.h b/src/sentry_app_hang.h new file mode 100644 index 0000000000..cf65fb88d1 --- /dev/null +++ b/src/sentry_app_hang.h @@ -0,0 +1,77 @@ +#ifndef SENTRY_APP_HANG_H_INCLUDED +#define SENTRY_APP_HANG_H_INCLUDED + +#include "sentry_boot.h" + +#include +#include + +/* The host-side heartbeat machinery (clock, latch, shmem registration) is + * available on the native backend on macOS. Windows, Linux, and other targets + * fall back to no-op stubs. */ +#if defined(SENTRY_PLATFORM_MACOS) && defined(SENTRY_BACKEND_NATIVE) +# define SENTRY_APP_HANG_HOST_SUPPORTED 1 +#endif + +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) +# include "sentry_crash_context.h" +#endif + +/** + * Decision returned by the pure decision function. Kept tiny so it can be + * exercised in unit tests without involving the daemon or shared memory. + */ +typedef enum { + SENTRY_APP_HANG_NO_ACTION = 0, + SENTRY_APP_HANG_FIRE = 1, +} sentry_app_hang_decision_t; + +/* Number of consecutive timer ticks the daemon must observe a stale + * heartbeat before firing. Smooths over brief hiccups (GC pauses, swap, OS + * scheduler quanta) at the cost of ~SENTRY_APP_HANG_STRIKES_REQUIRED-1 + * extra poll periods of detection latency. */ +#define SENTRY_APP_HANG_STRIKES_REQUIRED 3 + +/** + * Pure function: should we fire an app-hang event right now? + * + * - `enabled`: the host has app-hang detection turned on. + * - `hb`: last heartbeat timestamp (host clock; 0 means + * "never heartbeated yet"). + * - `now`: daemon's current observation of the same clock. + * - `timeout_ms`: staleness threshold. + * - `last_fired_hb`: the `hb` value the daemon last fired for; used + * as cooldown so a sustained freeze fires once. + * - `consecutive_stale_ticks`: caller-tracked count of consecutive ticks on + * which the heartbeat was observed stale. + * - `out_consecutive_stale_ticks` (out): updated counter the caller should + * store. 0 if reset, otherwise incremented. + * + * Returns SENTRY_APP_HANG_FIRE iff: enabled, hb != 0, (now - hb) >= timeout_ms, + * hb != last_fired_hb, AND the updated stale-tick counter reaches + * SENTRY_APP_HANG_STRIKES_REQUIRED. + */ +sentry_app_hang_decision_t sentry__app_hang_decide(bool enabled, uint64_t hb, + uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb, + int consecutive_stale_ticks, int *out_consecutive_stale_ticks); + +#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) +/** + * Called from the native backend startup path. Stores `ctx` so that + * subsequent `sentry_app_hang_heartbeat()` calls have somewhere to write. + * Passing NULL clears the registration on backend shutdown. + * + * The pointer is stored in a `volatile` global; ordering with shmem field + * initialization is the caller's responsibility (the backend writes options + * into shmem before calling this). + */ +void sentry__app_hang_set_shmem(sentry_crash_context_t *ctx); + +/** + * Return a millisecond-resolution unbiased timestamp shared between host and + * daemon. Exposed for the daemon to call as well. + */ +uint64_t sentry__app_hang_now_ms(void); +#endif + +#endif diff --git a/src/sentry_options.c b/src/sentry_options.c index cb5bb936a6..f43a82f113 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -67,6 +67,8 @@ sentry_options_new(void) opts->propagate_traceparent = false; opts->strict_trace_continuation = false; opts->crashpad_limit_stack_capture_to_sp = false; + opts->app_hang_enabled = false; + opts->app_hang_timeout_ms = 5000; opts->enable_metrics = true; opts->enable_logs = true; opts->cache_keep = SENTRY_CACHE_KEEP_NONE; diff --git a/src/sentry_options.h b/src/sentry_options.h index 6f64bba436..8696f0f051 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -51,6 +51,8 @@ struct sentry_options_s { bool propagate_traceparent; bool strict_trace_continuation; bool crashpad_limit_stack_capture_to_sp; + bool app_hang_enabled; + uint64_t app_hang_timeout_ms; sentry_cache_keep_t cache_keep; time_t cache_max_age; diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index a694d1848d..d5ba855889 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -1042,3 +1042,73 @@ def test_native_restart_on_crash(cmake, httpserver): for req in httpserver.log: envelope = Envelope.deserialize(req[0].get_data()) assert_native_crash(envelope) + + +@pytest.mark.skipif( + sys.platform != "darwin", + reason="app-hang detection is implemented on macOS", +) +def test_native_app_hang(cmake, httpserver): + """App hang detection emits exactly one ApplicationNotResponding event. + + On macOS the daemon samples the hung thread out-of-process via + ``task_for_pid``, which requires the example + daemon to be ad-hoc + codesigned with the debugger entitlement (same setup as the SMART-mode + heap test). If the capture still cannot acquire the task port in this + environment the daemon degrades gracefully and ships nothing — the test + skips rather than fails in that case. + """ + # macOS hardened-runtime self-signing needs a static build so the example + # can load itself without the dyld "different team IDs" check tripping on + # ad-hoc-signed dylibs (mirrors the SMART-mode heap test). + config = {"SENTRY_BACKEND": "native"} + if sys.platform == "darwin": + config["BUILD_SHARED_LIBS"] = "OFF" + tmp_path = cmake(["sentry_example"], config) + + if sys.platform == "darwin": + _codesign_for_task_for_pid( + str(tmp_path / "sentry_example"), + str(tmp_path / "sentry-crash"), + ) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( + "OK" + ) + + with httpserver.wait(timeout=20) as waiting: + # The example's app-hang mode heartbeats for 500 ms, then freezes for + # 3000 ms (3x the 1000 ms timeout). The daemon polls every 500 ms. + # `run` (not `run_crash`) because the example exits cleanly after the + # hang demonstration — `run_crash` expects abnormal exit. + run( + tmp_path, + "sentry_example", + ["log", "app-hang"], + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + ) + + if sys.platform == "darwin" and not waiting.result: + pytest.skip( + "no app-hang envelope received — task_for_pid is likely denied " + "in this environment (hardened-runtime/SIP); capture degraded " + "gracefully" + ) + assert waiting.result + + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + event = envelope.get_event() + assert event is not None + exc = event["exception"]["values"][0] + assert exc["type"] == "ApplicationNotResponding" + assert exc["mechanism"]["type"] == "AppHang" + assert exc["mechanism"]["handled"] is True + assert exc["mechanism"]["synthetic"] is True + assert "stacktrace" in exc + frames = exc["stacktrace"]["frames"] + assert isinstance(frames, list) + assert len(frames) > 0, "stacktrace is empty — capture path may be broken" + # At least one frame should have a non-zero instruction address. + assert any( + int(f.get("instruction_addr", "0"), 16) > 0 for f in frames + ), "no frame has a non-zero instruction_addr" 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..2ab96f6490 --- /dev/null +++ b/tests/unit/test_app_hang.c @@ -0,0 +1,153 @@ +#include "sentry_app_hang.h" +#include "sentry_testsupport.h" + +#include + +SENTRY_TEST(app_hang_decide_disabled_returns_no_action) +{ + int new_count = 99; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/false, /*hb=*/100, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/0, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + /* Disabled path resets the counter. */ + TEST_CHECK_INT_EQUAL(new_count, 0); +} + +SENTRY_TEST(app_hang_decide_no_heartbeat_yet_returns_no_action) +{ + int new_count = 99; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/0, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/0, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(new_count, 0); +} + +SENTRY_TEST(app_hang_decide_fresh_heartbeat_returns_no_action_and_resets) +{ + int new_count = 99; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/9500, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/2, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + /* Fresh heartbeat resets the strike counter even mid-accumulation. */ + TEST_CHECK_INT_EQUAL(new_count, 0); +} + +SENTRY_TEST(app_hang_decide_first_stale_tick_increments_does_not_fire) +{ + int new_count = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/5000, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/0, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(new_count, 1); +} + +SENTRY_TEST(app_hang_decide_second_stale_tick_increments_does_not_fire) +{ + int new_count = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/5000, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/1, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(new_count, 2); +} + +SENTRY_TEST(app_hang_decide_third_stale_tick_fires) +{ + int new_count = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/5000, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/2, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); + TEST_CHECK_INT_EQUAL(new_count, 3); +} + +SENTRY_TEST(app_hang_decide_brief_hiccup_resets_strike_count) +{ + /* Simulate: 2 stale ticks, then a fresh heartbeat (counter resets), + * then 1 stale tick → must NOT fire because we lost our accumulated + * strikes when the heartbeat refreshed. */ + int after_hiccup = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/9800, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/2, &after_hiccup); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(after_hiccup, 0); + + int after_one_stale = -1; + d = sentry__app_hang_decide(/*enabled=*/true, /*hb=*/9800, + /*now=*/11000, /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/after_hiccup, &after_one_stale); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(after_one_stale, 1); +} + +SENTRY_TEST(app_hang_decide_cooldown_holds_when_hb_unchanged) +{ + /* Already fired for hb=5000. Subsequent ticks must NOT re-fire even + * if 100 more stale ticks accumulate. Counter held at 0. */ + int new_count = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/5000, /*now=*/20000, + /*timeout_ms=*/1000, /*last_fired_hb=*/5000, + /*consecutive_stale_ticks=*/0, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(new_count, 0); +} + +SENTRY_TEST(app_hang_decide_re_arms_after_advance_then_stall) +{ + /* hb advanced past last_fired_hb → cooldown released; need 3 fresh + * strikes again. */ + int after_strike1 = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/7000, /*now=*/12000, + /*timeout_ms=*/1000, /*last_fired_hb=*/5000, + /*consecutive_stale_ticks=*/0, &after_strike1); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(after_strike1, 1); + + int after_strike3 = -1; + d = sentry__app_hang_decide(/*enabled=*/true, /*hb=*/7000, + /*now=*/12000, /*timeout_ms=*/1000, /*last_fired_hb=*/5000, + /*consecutive_stale_ticks=*/2, &after_strike3); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); + TEST_CHECK_INT_EQUAL(after_strike3, 3); +} + +SENTRY_TEST(app_hang_decide_exact_timeout_boundary_with_third_strike_fires) +{ + /* now - hb == timeout_ms is still stale (>= semantics) AND the third + * strike has accumulated — fires. */ + int new_count = -1; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/9000, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/2, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); + TEST_CHECK_INT_EQUAL(new_count, 3); +} + +SENTRY_TEST(app_hang_decide_torn_read_now_less_than_hb_resets) +{ + /* On x86 a non-atomic 64-bit load can tear, producing now < hb. The + * decision function treats this as fresh (no FIRE) and resets the + * strike counter so the next non-torn observation starts clean. */ + int new_count = 99; + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/10000, /*now=*/5000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0, + /*consecutive_stale_ticks=*/2, &new_count); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); + TEST_CHECK_INT_EQUAL(new_count, 0); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 9770f857f6..686f7e3e42 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -1,3 +1,14 @@ +XX(app_hang_decide_brief_hiccup_resets_strike_count) +XX(app_hang_decide_cooldown_holds_when_hb_unchanged) +XX(app_hang_decide_disabled_returns_no_action) +XX(app_hang_decide_exact_timeout_boundary_with_third_strike_fires) +XX(app_hang_decide_first_stale_tick_increments_does_not_fire) +XX(app_hang_decide_fresh_heartbeat_returns_no_action_and_resets) +XX(app_hang_decide_no_heartbeat_yet_returns_no_action) +XX(app_hang_decide_re_arms_after_advance_then_stall) +XX(app_hang_decide_second_stale_tick_increments_does_not_fire) +XX(app_hang_decide_third_stale_tick_fires) +XX(app_hang_decide_torn_read_now_less_than_hb_resets) XX(assert_sdk_name) XX(assert_sdk_user_agent) XX(assert_sdk_version) From 156bb8ba21cf206f867fe4235553a4a70dd6118d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 10:24:21 +0200 Subject: [PATCH 13/26] styling --- src/backends/native/sentry_crash_daemon.c | 52 ++++++++++------------- src/sentry_app_hang.c | 4 +- src/sentry_app_hang.h | 3 +- tests/test_integration_native.py | 4 +- 4 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index eba91c9f10..5cb5d19ff3 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2312,12 +2312,12 @@ build_native_crash_event(const sentry_crash_context_t *ctx, // Add mechanism sentry_value_t mechanism = sentry_value_new_object(); - sentry_value_set_by_key(mechanism, "type", - sentry_value_new_string(kind->mechanism_type)); + sentry_value_set_by_key( + mechanism, "type", sentry_value_new_string(kind->mechanism_type)); sentry_value_set_by_key( mechanism, "synthetic", sentry_value_new_bool(true)); - sentry_value_set_by_key(mechanism, "handled", - sentry_value_new_bool(kind->mechanism_handled)); + sentry_value_set_by_key( + mechanism, "handled", sentry_value_new_bool(kind->mechanism_handled)); // Add signal metadata (only relevant for signal-handler/crash events) if (kind->include_signal_meta) { @@ -2620,8 +2620,8 @@ write_envelope_with_native_stacktrace(const sentry_options_t *options, // Build native crash event (always include threads with names) SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); - sentry_value_t event = build_native_crash_event( - ctx, event_file_path, run_folder, kind); + sentry_value_t event + = build_native_crash_event(ctx, event_file_path, run_folder, kind); // Serialize event to JSON size_t event_size = 0; @@ -2869,8 +2869,8 @@ app_hang_read_task_memory( task_t task, mach_vm_address_t addr, void *buf, mach_vm_size_t size) { mach_vm_size_t got = 0; - kern_return_t kr - = mach_vm_read_overwrite(task, addr, size, (mach_vm_address_t)buf, &got); + kern_return_t kr = mach_vm_read_overwrite( + task, addr, size, (mach_vm_address_t)buf, &got); if (kr == KERN_SUCCESS && got != size) { return KERN_FAILURE; } @@ -2979,18 +2979,17 @@ app_hang_capture_modules(task_t task, sentry_crash_context_t *ctx) const uint8_t *p = cmds; const uint8_t *end = cmds + cmds_size; bool has_size = false, has_uuid = false; - for (uint32_t j = 0; - j < ncmds && (!has_size || !has_uuid) + for (uint32_t j = 0; j < ncmds && (!has_size || !has_uuid) && p + sizeof(struct load_command) <= end; j++) { const struct load_command *lc = (const struct load_command *)p; - if (lc->cmdsize == 0 - || p + lc->cmdsize > end) { + if (lc->cmdsize == 0 || p + lc->cmdsize > end) { break; } if (lc->cmd == LC_SEGMENT_64 - && lc->cmdsize >= sizeof(struct segment_command_64)) { + && lc->cmdsize + >= sizeof(struct segment_command_64)) { const struct segment_command_64 *seg = (const struct segment_command_64 *)lc; if (memcmp(seg->segname, "__TEXT", 7) == 0) { @@ -3023,8 +3022,7 @@ app_hang_capture_modules(task_t task, sentry_crash_context_t *ctx) * FP-unwinder in build_stacktrace_for_thread can walk real frames — the same * file-backed mechanism the signal handler uses for crashes. Best-effort. */ static void -app_hang_capture_stack( - task_t task, sentry_crash_context_t *ctx, uint64_t sp) +app_hang_capture_stack(task_t task, sentry_crash_context_t *ctx, uint64_t sp) { ctx->platform.threads[0].stack_path[0] = '\0'; ctx->platform.threads[0].stack_size = 0; @@ -3188,9 +3186,8 @@ capture_and_send_app_hang(const sentry_options_t *options, (thread_state_t)&mcontext.__ss, &state_count); # else mach_msg_type_number_t state_count = MACHINE_THREAD_STATE_COUNT; - kr = thread_get_state( - target, MACHINE_THREAD_STATE, (thread_state_t)&mcontext.__ss, - &state_count); + kr = thread_get_state(target, MACHINE_THREAD_STATE, + (thread_state_t)&mcontext.__ss, &state_count); # endif thread_resume(target); @@ -3267,8 +3264,7 @@ capture_and_send_app_hang(const sentry_options_t *options, * manifest, scope attachments, screenshot, and session replay — all pulled * in by write_envelope_with_native_stacktrace when run_folder is * non-NULL. */ - const char *event_file_path - = ctx->event_path[0] ? ctx->event_path : NULL; + const char *event_file_path = ctx->event_path[0] ? ctx->event_path : NULL; sentry_path_t *run_folder = NULL; if (event_file_path) { sentry_path_t *ev_path = sentry__path_from_str(event_file_path); @@ -3278,8 +3274,8 @@ capture_and_send_app_hang(const sentry_options_t *options, } } - bool ok = write_envelope_with_native_stacktrace(options, envelope_path, - ctx, event_file_path, /*minidump_path=*/NULL, run_folder, &kind); + bool ok = write_envelope_with_native_stacktrace(options, envelope_path, ctx, + event_file_path, /*minidump_path=*/NULL, run_folder, &kind); if (run_folder) { sentry__path_free(run_folder); @@ -3812,8 +3808,7 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) minidump_path[0] ? minidump_path : "NULL"); envelope_written = write_envelope_with_native_stacktrace(options, envelope_path, ctx, event_path, - minidump_path[0] ? minidump_path : NULL, run_folder, - &s_crash_kind); + minidump_path[0] ? minidump_path : NULL, run_folder, &s_crash_kind); } else { // Mode 0 (MINIDUMP only) SENTRY_DEBUG("Writing envelope with minidump"); @@ -4269,9 +4264,8 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, const uint64_t app_hang_timeout_ms = ipc->shmem->app_hang_timeout_ms; uint64_t last_fired_hb = 0; int consecutive_stale_ticks = 0; - const int wait_timeout_ms = app_hang_enabled - ? 500 - : SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; + const int wait_timeout_ms + = app_hang_enabled ? 500 : SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; #else const int wait_timeout_ms = SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; #endif @@ -4321,8 +4315,8 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, const uint64_t now = sentry__app_hang_now_ms(); int new_strikes = 0; sentry_app_hang_decision_t d = sentry__app_hang_decide( - app_hang_enabled, hb, now, app_hang_timeout_ms, - last_fired_hb, consecutive_stale_ticks, &new_strikes); + app_hang_enabled, hb, now, app_hang_timeout_ms, last_fired_hb, + consecutive_stale_ticks, &new_strikes); consecutive_stale_ticks = new_strikes; if (d == SENTRY_APP_HANG_FIRE) { capture_and_send_app_hang(options, ipc, now - hb); diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c index aefb51ba65..818f01e6c0 100644 --- a/src/sentry_app_hang.c +++ b/src/sentry_app_hang.c @@ -17,8 +17,8 @@ sentry_app_hang_decision_t sentry__app_hang_decide(bool enabled, uint64_t hb, uint64_t now, - uint64_t timeout_ms, uint64_t last_fired_hb, - int consecutive_stale_ticks, int *out_consecutive_stale_ticks) + uint64_t timeout_ms, uint64_t last_fired_hb, int consecutive_stale_ticks, + int *out_consecutive_stale_ticks) { /* Fresh or disabled paths reset the counter. */ if (!enabled || hb == 0) { diff --git a/src/sentry_app_hang.h b/src/sentry_app_hang.h index cf65fb88d1..7a20ce6009 100644 --- a/src/sentry_app_hang.h +++ b/src/sentry_app_hang.h @@ -38,7 +38,8 @@ typedef enum { * - `enabled`: the host has app-hang detection turned on. * - `hb`: last heartbeat timestamp (host clock; 0 means * "never heartbeated yet"). - * - `now`: daemon's current observation of the same clock. + * - `now`: daemon's current observation of the same + * clock. * - `timeout_ms`: staleness threshold. * - `last_fired_hb`: the `hb` value the daemon last fired for; used * as cooldown so a sustained freeze fires once. diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index d5ba855889..70effca544 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -1072,9 +1072,7 @@ def test_native_app_hang(cmake, httpserver): str(tmp_path / "sentry-crash"), ) - httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( - "OK" - ) + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") with httpserver.wait(timeout=20) as waiting: # The example's app-hang mode heartbeats for 500 ms, then freezes for From 1533aa01373548beb26e45ce4f25529caf095211 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 10:36:58 +0200 Subject: [PATCH 14/26] added tests --- tests/test_integration_native.py | 61 +++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index a694d1848d..ede68d73ac 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -167,18 +167,33 @@ def test_native_capture_minidump_generated(cmake, httpserver): assert version != 0, "Minidump should have non-zero version" -def test_native_breadcrumbs(cmake, httpserver): - """Test that breadcrumbs are captured before crash""" +# Both daemon envelope writers merge the breadcrumb ring files: the native +# stacktrace writer builds the event from scratch, while the minidump-only +# writer re-parses the parent's event JSON, merges, and re-serializes. Exercise +# both so the minidump path's extra parse/serialize roundtrip is covered too. +BREADCRUMB_CRASH_MODES = ["native", "minidump"] + + +@pytest.mark.parametrize("crash_mode", BREADCRUMB_CRASH_MODES) +def test_native_breadcrumbs(cmake, httpserver, crash_mode): + """Test that breadcrumbs survive the daemon's ring-file merge. + + The crashing process appends breadcrumbs as msgpack to the ring files; the + daemon reads, merges, and attaches them to the event. Asserting on the + default `debug crumb` verifies that whole roundtrip, not just that an event + arrived. + """ tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") - # Add breadcrumbs then crash (use stdout for initialization delay under sanitizers) + # The default setup block adds the `debug crumb`; crash so the daemon emits + # the event (use stdout for initialization delay under sanitizers). with httpserver.wait(timeout=10) as waiting: run_crash( tmp_path, "sentry_example", - ["log", "stdout", "breadcrumb-log", "crash"], + ["log", "stdout", "crash-mode", crash_mode, "crash"], env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), ) assert waiting.result @@ -186,7 +201,43 @@ def test_native_breadcrumbs(cmake, httpserver): # Verify breadcrumbs in envelope assert len(httpserver.log) >= 1 envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) - assert envelope.get_event() + assert_breadcrumb(envelope) + + +@pytest.mark.parametrize("crash_mode", BREADCRUMB_CRASH_MODES) +def test_native_overflow_breadcrumbs(cmake, httpserver, crash_mode): + """Test that the daemon caps merged breadcrumbs at max_breadcrumbs. + + The example adds 3 default crumbs plus 101 numbered crumbs ("0".."100"). + With the default max_breadcrumbs (100), the daemon keeps the newest 100, + so the count is capped and the most-recent crumb ("100") is retained. + """ + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run_crash( + tmp_path, + "sentry_example", + [ + "log", + "stdout", + "overflow-breadcrumbs", + "crash-mode", + crash_mode, + "crash", + ], + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + ) + assert waiting.result + + assert len(httpserver.log) >= 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + breadcrumbs = envelope.get_event()["breadcrumbs"] + + assert len(breadcrumbs) == 100 + assert any(b.get("message") == "100" for b in breadcrumbs) def test_native_session_tracking(cmake, httpserver): From f198020442b2bb3891613cc0201df12891e71773 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 11:19:54 +0200 Subject: [PATCH 15/26] collapsed heartbeat api into one --- examples/example.c | 6 +++--- include/sentry.h | 28 ++++++---------------------- src/sentry_app_hang.c | 36 ++++++++---------------------------- 3 files changed, 17 insertions(+), 53 deletions(-) diff --git a/examples/example.c b/examples/example.c index d15494e226..0a8414fe09 100644 --- a/examples/example.c +++ b/examples/example.c @@ -617,9 +617,9 @@ static void * app_hang_demo_thread(void *arg) { (void)arg; - /* Latch this thread as the target once, then heartbeat for 500 ms so the - * daemon sees a healthy baseline before the freeze. */ - sentry_app_hang_set_target_thread(); + /* The first heartbeat latches this thread as the monitored target; keep + * heartbeating for 500 ms so the daemon sees a healthy baseline before the + * freeze. */ for (int i = 0; i < 10; i++) { sentry_app_hang_heartbeat(); usleep(50 * 1000); diff --git a/include/sentry.h b/include/sentry.h index c7aede2410..6d96fc904f 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1722,31 +1722,15 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_app_hang_enabled( SENTRY_EXPERIMENTAL_API void sentry_options_set_app_hang_timeout_ms( sentry_options_t *opts, uint64_t timeout_ms); -/** - * Designate the calling thread as the one monitored by the app-hang detector. - * - * Call this once, from the thread you want monitored (typically the main / - * game thread), before the first heartbeat. The latch is sticky for the - * lifetime of the SDK session: subsequent calls from any other thread are - * dropped. Calling again from the same thread is a harmless no-op. - * - * Until this is called, `sentry_app_hang_heartbeat()` is a no-op — there is - * no implicit "first caller wins" latch, so a stray heartbeat from a worker - * thread during startup cannot accidentally claim the role and silently - * disable monitoring of the real main thread. - * - * No-op if app-hang detection is not enabled in options, or if the native - * backend is not active, or on non-macOS platforms. - */ -SENTRY_EXPERIMENTAL_API void sentry_app_hang_set_target_thread(void); - /** * Refresh the heartbeat for the monitored thread. * - * Call this from the thread previously designated via - * `sentry_app_hang_set_target_thread()`. Calls from any other thread, or - * before a target has been set, are dropped — so a stray heartbeat from a - * worker thread cannot mask a frozen main thread. + * The first thread to call this becomes the monitored target for the lifetime + * of the SDK session (first caller wins, latched atomically). Call it from the + * thread you want monitored (typically the main / game thread) and ensure that + * thread issues the first heartbeat. Subsequent calls from any other thread are + * dropped, so a stray heartbeat from a worker thread cannot mask a frozen + * monitored thread. * * Cost: approximately one system call plus a relaxed 64-bit store. Safe to * call from a per-frame hook in a game engine. diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c index 818f01e6c0..021798778b 100644 --- a/src/sentry_app_hang.c +++ b/src/sentry_app_hang.c @@ -93,7 +93,7 @@ sentry__app_hang_now_ms(void) } void -sentry_app_hang_set_target_thread(void) +sentry_app_hang_heartbeat(void) { sentry_crash_context_t *ctx = g_app_hang_shmem; if (!ctx || !ctx->app_hang_enabled) { @@ -108,32 +108,18 @@ sentry_app_hang_set_target_thread(void) return; } - /* CAS the current TID into the latch slot iff still unset — first caller - * wins, idempotent for that caller. The shmem field is declared - * `volatile uint64_t`; view it as an atomic for the compare-exchange. */ + /* Self-register on the first heartbeat: CAS the current TID into the latch + * slot iff still unset — the first thread to heartbeat wins and becomes the + * monitored target. The shmem field is declared `volatile uint64_t`; view + * it as an atomic for the compare-exchange. */ _Atomic uint64_t *slot = (_Atomic uint64_t *)(void *)&ctx->app_hang_target_tid; uint64_t expected = 0; atomic_compare_exchange_strong(slot, &expected, current_tid); -} -void -sentry_app_hang_heartbeat(void) -{ - sentry_crash_context_t *ctx = g_app_hang_shmem; - if (!ctx || !ctx->app_hang_enabled) { - return; - } - - /* Refresh-only: requires a prior sentry_app_hang_set_target_thread() - * call from this thread. Drops the heartbeat if no target is latched, or - * if the latched thread is not us. */ - uint64_t current_tid = 0; - if (pthread_threadid_np(NULL, ¤t_tid) != 0 || current_tid == 0) { - return; - } - uint64_t latched = ctx->app_hang_target_tid; - if (latched == 0 || latched != current_tid) { + /* Drop the heartbeat unless the latched thread is us, so a stray heartbeat + * from another thread cannot mask a frozen monitored thread. */ + if (ctx->app_hang_target_tid != current_tid) { return; } @@ -144,12 +130,6 @@ sentry_app_hang_heartbeat(void) #else /* host heartbeat not supported on this target */ -void -sentry_app_hang_set_target_thread(void) -{ - /* No-op on non-macOS targets in this initial cut. */ -} - void sentry_app_hang_heartbeat(void) { From bcb1bda3a21b1e962ef02e5e96c1e3f4b6a4d99a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 13:07:58 +0200 Subject: [PATCH 16/26] tightening --- examples/example.c | 8 +- include/sentry.h | 25 +++---- src/backends/native/sentry_crash_context.h | 12 +-- src/backends/native/sentry_crash_daemon.c | 86 +++++++--------------- 4 files changed, 43 insertions(+), 88 deletions(-) diff --git a/examples/example.c b/examples/example.c index 0a8414fe09..202e607d50 100644 --- a/examples/example.c +++ b/examples/example.c @@ -617,16 +617,12 @@ static void * app_hang_demo_thread(void *arg) { (void)arg; - /* The first heartbeat latches this thread as the monitored target; keep - * heartbeating for 500 ms so the daemon sees a healthy baseline before the - * freeze. */ + /* The first heartbeat latches this thread as the monitored target */ for (int i = 0; i < 10; i++) { sentry_app_hang_heartbeat(); usleep(50 * 1000); } - /* Add a couple of breadcrumbs before freezing so the captured app-hang - * event carries them (the daemon reads the breadcrumb ring files the host - * writes on each sentry_add_breadcrumb). */ + sentry_add_breadcrumb( sentry_value_new_breadcrumb(NULL, "app-hang demo: about to freeze")); sentry_add_breadcrumb(create_debug_crumb("app-hang demo breadcrumb")); diff --git a/include/sentry.h b/include/sentry.h index 6d96fc904f..23a580c36c 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1698,12 +1698,13 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_session_replay_duration( sentry_options_t *opts, uint32_t duration_ms); /** - * Enable app-hang detection in the native crash backend. + * Enable app-hang detection via the native crash backend. * - * When enabled, the out-of-process daemon monitors a designated thread in the - * host via a shared-memory heartbeat. If the heartbeat goes stale for longer - * than the configured timeout, the daemon walks the thread's stack remotely and - * emits an `ApplicationNotResponding` event. The host process keeps running. + * When enabled, the out-of-process daemon monitors the thread first emitting + * a heatbeat through `sentry_app_hang_heartbeat`. + * If the heartbeat goes stale for longer than the configured timeout, the + * daemon walks the thread's stack remotely and emits an `AppHang` event. + * The host process keeps running. * * Off by default. This setting only has an effect when using the `native` * backend. In this initial release the feature is macOS-only; the call is a @@ -1723,20 +1724,18 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_app_hang_timeout_ms( sentry_options_t *opts, uint64_t timeout_ms); /** - * Refresh the heartbeat for the monitored thread. + * Refresh the heartbeat. * * The first thread to call this becomes the monitored target for the lifetime * of the SDK session (first caller wins, latched atomically). Call it from the * thread you want monitored (typically the main / game thread) and ensure that * thread issues the first heartbeat. Subsequent calls from any other thread are - * dropped, so a stray heartbeat from a worker thread cannot mask a frozen - * monitored thread. + * dropped. * - * Cost: approximately one system call plus a relaxed 64-bit store. Safe to - * call from a per-frame hook in a game engine. - * - * No-op if app-hang detection is not enabled in options, or if the native - * backend is not active, or on non-macOS platforms. + * No-op if + * - app-hang detection is not enabled + * - the native backend is not active + * - the platform is not macOS */ SENTRY_EXPERIMENTAL_API void sentry_app_hang_heartbeat(void); diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index 7ae3773499..ff54e2543c 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -326,18 +326,14 @@ typedef struct { uint32_t module_count; sentry_module_info_t modules[SENTRY_CRASH_MAX_MODULES]; - /* App-hang detection (macOS, native backend only). + /* App-hang detection. * * Sync model: * - app_hang_enabled, app_hang_timeout_ms: written by host before daemon * is signalled ready; read by daemon at startup. No further mutation. - * - app_hang_target_tid: latched once by host on first heartbeat via a - * compare-exchange (atomic_compare_exchange_strong). Daemon reads, never - * writes. - * - app_hang_last_heartbeat_ms: written on every heartbeat with a relaxed - * 64-bit store. Daemon reads with a relaxed load. Torn reads are not a - * correctness issue — the daemon compares against its remembered value - * from the previous tick. (On 64-bit macOS the aligned store is atomic.) + * - app_hang_target_tid: latched once by host on first heartbeat. + * Daemon reads, never writes. + * - app_hang_last_heartbeat_ms: written on every heartbeat. */ bool app_hang_enabled; uint64_t app_hang_timeout_ms; diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 5cb5d19ff3..d9e08ad366 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2122,55 +2122,6 @@ build_stacktrace_from_ctx(const sentry_crash_context_t *ctx) return build_stacktrace_for_thread(ctx, SIZE_MAX); } -/* Describes which kind of native event we are building. `s_crash_kind` - * drives the crash path; `s_app_hang_kind` drives the app-hang flow on macOS. - * - * Invariant: if `include_signal_meta` is true, `exception_type` must be NULL - * (the signal-derived path). Setting an override type AND requesting signal - * metadata is incoherent — there is no signal in the override case. - */ -typedef struct { - /* Override exception `type` string. NULL = derive from the crash signal - * (e.g. "SIGSEGV" on Unix, "EXCEPTION" on Windows). */ - const char *exception_type; - /* Override exception `value` string. Used only when `exception_type` is - * non-NULL; ignored otherwise. */ - const char *exception_value; - /* `mechanism.type` JSON value, e.g. "signalhandler" or "AppHang". */ - const char *mechanism_type; - /* `mechanism.handled` JSON value. false for fatal crashes, true for - * recoverable events like app hangs. */ - bool mechanism_handled; - /* Event `level` JSON value, e.g. "fatal" or "error". */ - const char *level; - /* Attach `mechanism.meta.signal` payload? Must be false when - * `exception_type` is non-NULL (see struct invariant). */ - bool include_signal_meta; -} sentry_native_event_kind_t; - -/* Crash-path event kind: signal-derived type/value, fatal level, unhandled. */ -static const sentry_native_event_kind_t s_crash_kind = { - .exception_type = NULL, - .exception_value = NULL, - .mechanism_type = "signalhandler", - .mechanism_handled = false, - .level = "fatal", - .include_signal_meta = true, -}; - -#if defined(SENTRY_APP_HANG_HOST_SUPPORTED) -/* App-hang event kind: ANR-style, handled, error level. The per-event - * `exception_value` (freeze duration message) is filled in at capture time. */ -static const sentry_native_event_kind_t s_app_hang_kind = { - .exception_type = "ApplicationNotResponding", - .exception_value = NULL, /* filled in per-event below */ - .mechanism_type = "AppHang", - .mechanism_handled = true, - .level = "error", - .include_signal_meta = false, -}; -#endif - /** * Reads one breadcrumb ring file the crashing process appended on its hot path * into a breadcrumb list. Returns null if the file is absent or empty. @@ -2238,17 +2189,30 @@ apply_breadcrumbs_from_ring_files(sentry_value_t event, } /** - * Build a native event and set the level, mechanism, and handled state + * Build a native event and set the level, mechanism, and handled state. + * + * `exception_type` selects the path: + * - NULL: signal-derived crash. The exception type/value are taken from the + * crash signal (e.g. "SIGSEGV"/"Fatal crash: SIGSEGV"), and the + * `mechanism.meta.signal` payload is attached. `exception_value` is ignored. + * - non-NULL: override event (e.g. app hang). The given type/value are used + * verbatim and no signal metadata is attached (there is no signal). * * @param ctx Crash context * @param event_file_path Path to base event file from parent process * @param run_folder Run directory holding the breadcrumb ring files - * @param kind Event-kind descriptor controlling exception/mechanism/level + * @param exception_type Override exception `type`, or NULL to derive from signal + * @param exception_value Override exception `value` (used only when + * `exception_type` is non-NULL) + * @param mechanism_type `mechanism.type`, e.g. "signalhandler" or "AppHang" + * @param mechanism_handled `mechanism.handled` (false for fatal crashes) + * @param level Event `level`, e.g. "fatal" or "error" */ static sentry_value_t build_native_crash_event(const sentry_crash_context_t *ctx, const char *event_file_path, const sentry_path_t *run_folder, - const sentry_native_event_kind_t *kind) + const char *exception_type, const char *exception_value, + const char *mechanism_type, bool mechanism_handled, const char *level) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2277,8 +2241,7 @@ build_native_crash_event(const sentry_crash_context_t *ctx, event, "platform", sentry_value_new_string("native")); // Set level (varies by event kind: "fatal" for crash, "error" for app hang) - sentry_value_set_by_key( - event, "level", sentry_value_new_string(kind->level)); + sentry_value_set_by_key(event, "level", sentry_value_new_string(level)); // Build exception /* Function-scope so exc_value (which may point into this buffer) remains @@ -2288,9 +2251,9 @@ build_native_crash_event(const sentry_crash_context_t *ctx, const char *exc_type; const char *exc_value; - if (kind->exception_type) { - exc_type = kind->exception_type; - exc_value = kind->exception_value ? kind->exception_value : ""; + if (exception_type) { + exc_type = exception_type; + exc_value = exception_value ? exception_value : ""; } else { const char *signal_name; #if defined(SENTRY_PLATFORM_UNIX) @@ -2313,14 +2276,15 @@ build_native_crash_event(const sentry_crash_context_t *ctx, // Add mechanism sentry_value_t mechanism = sentry_value_new_object(); sentry_value_set_by_key( - mechanism, "type", sentry_value_new_string(kind->mechanism_type)); + mechanism, "type", sentry_value_new_string(mechanism_type)); sentry_value_set_by_key( mechanism, "synthetic", sentry_value_new_bool(true)); sentry_value_set_by_key( - mechanism, "handled", sentry_value_new_bool(kind->mechanism_handled)); + mechanism, "handled", sentry_value_new_bool(mechanism_handled)); - // Add signal metadata (only relevant for signal-handler/crash events) - if (kind->include_signal_meta) { + // Add signal metadata only for the signal-derived crash path (no override + // type). There is no signal in the override case (e.g. app hang). + if (!exception_type) { sentry_value_t meta = sentry_value_new_object(); sentry_value_t signal_info = sentry_value_new_object(); #if defined(SENTRY_PLATFORM_WINDOWS) From 741c6696e37b13fc073ddef365158d6bc2850bf8 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 13:24:11 +0200 Subject: [PATCH 17/26] reverted erronous name change --- src/backends/native/sentry_crash_daemon.c | 37 +++++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 886f01edb7..5426c9632a 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2314,7 +2314,7 @@ apply_breadcrumbs_from_ring_files(sentry_value_t event, * @param level Event `level`, e.g. "fatal" or "error" */ static sentry_value_t -build_native_crash_event(const sentry_crash_context_t *ctx, +build_native_event(const sentry_crash_context_t *ctx, const char *event_file_path, const sentry_path_t *run_folder, const char *exception_type, const char *exception_value, const char *mechanism_type, bool mechanism_handled, const char *level) @@ -2684,20 +2684,20 @@ build_native_crash_event(const sentry_crash_context_t *ctx, } /** - * Write envelope with native stacktrace event - * If minidump_path is provided, also attach it as an attachment + * Write envelope with a native stacktrace event built by the caller. + * + * The caller constructs the event at the point of capture (a crash event or an + * app-hang event) via build_native_event() and hands it to us; we take + * ownership and decref it once serialized. If minidump_path is provided, it is + * also attached as an attachment. */ static bool write_envelope_with_native_stacktrace(const sentry_options_t *options, const char *envelope_path, const sentry_crash_context_t *ctx, - const char *event_file_path, const char *minidump_path, - sentry_path_t *run_folder, const sentry_native_event_kind_t *kind) + sentry_value_t event, const char *minidump_path, sentry_path_t *run_folder) { - // Build native crash event (always include threads with names) SENTRY_DEBUGF("write_envelope_with_native_stacktrace: minidump_path=%s", minidump_path ? minidump_path : "(null)"); - sentry_value_t event - = build_native_crash_event(ctx, event_file_path, run_folder, kind); // Serialize event to JSON size_t event_size = 0; @@ -3317,8 +3317,6 @@ capture_and_send_app_hang(const sentry_options_t *options, snprintf(value_buf, sizeof(value_buf), "App hang detected. Main thread blocked for %llu ms.", (unsigned long long)freeze_ms); - sentry_native_event_kind_t kind = s_app_hang_kind; - kind.exception_value = value_buf; /* Build an envelope path next to the crash one. */ char envelope_path[SENTRY_CRASH_MAX_PATH]; @@ -3350,8 +3348,15 @@ capture_and_send_app_hang(const sentry_options_t *options, } } + /* App-hang event: ANR-style override (no signal), handled, error level. + * The per-event value carries the freeze duration computed above. */ + sentry_value_t event = build_native_event(ctx, event_file_path, + run_folder, /*exception_type=*/"ApplicationNotResponding", + /*exception_value=*/value_buf, /*mechanism_type=*/"AppHang", + /*mechanism_handled=*/true, /*level=*/"error"); + bool ok = write_envelope_with_native_stacktrace(options, envelope_path, ctx, - event_file_path, /*minidump_path=*/NULL, run_folder, &kind); + event, /*minidump_path=*/NULL, run_folder); if (run_folder) { sentry__path_free(run_folder); @@ -3882,9 +3887,15 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) SENTRY_DEBUGF("Writing envelope with native stacktrace, passing " "minidump_path=%s", minidump_path[0] ? minidump_path : "NULL"); + // Crash event: signal-derived type/value (with signal meta), fatal + // level, unhandled. + sentry_value_t event = build_native_event(ctx, event_path, + run_folder, /*exception_type=*/NULL, /*exception_value=*/NULL, + /*mechanism_type=*/"signalhandler", /*mechanism_handled=*/false, + /*level=*/"fatal"); envelope_written = write_envelope_with_native_stacktrace(options, - envelope_path, ctx, event_path, - minidump_path[0] ? minidump_path : NULL, run_folder, &s_crash_kind); + envelope_path, ctx, event, + minidump_path[0] ? minidump_path : NULL, run_folder); } else { // Mode 0 (MINIDUMP only) SENTRY_DEBUG("Writing envelope with minidump"); From f5c4eaa6fc84898e506cfcc05f195619e36c9da6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 13:50:42 +0200 Subject: [PATCH 18/26] cleanup --- src/backends/native/sentry_crash_daemon.c | 82 +++++++++++------------ src/backends/sentry_backend_native.c | 3 +- src/sentry_app_hang.c | 8 +-- src/sentry_app_hang.h | 5 +- tests/test_integration_native.py | 4 +- 5 files changed, 47 insertions(+), 55 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 5426c9632a..7fc17872be 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2306,18 +2306,19 @@ apply_breadcrumbs_from_ring_files(sentry_value_t event, * @param ctx Crash context * @param event_file_path Path to base event file from parent process * @param run_folder Run directory holding the breadcrumb ring files - * @param exception_type Override exception `type`, or NULL to derive from signal + * @param exception_type Override exception `type`, or NULL to derive from + * signal * @param exception_value Override exception `value` (used only when * `exception_type` is non-NULL) + * @param level Event level (e.g. "fatal") * @param mechanism_type `mechanism.type`, e.g. "signalhandler" or "AppHang" - * @param mechanism_handled `mechanism.handled` (false for fatal crashes) - * @param level Event `level`, e.g. "fatal" or "error" + * @param handled Whether the mechanism was handled */ static sentry_value_t build_native_event(const sentry_crash_context_t *ctx, const char *event_file_path, const sentry_path_t *run_folder, - const char *exception_type, const char *exception_value, - const char *mechanism_type, bool mechanism_handled, const char *level) + const char *exception_type, const char *exception_value, const char *level, + const char *mechanism_type, bool handled) { // Read base event from parent's file sentry_value_t event = sentry_value_new_null(); @@ -2345,13 +2346,9 @@ build_native_event(const sentry_crash_context_t *ctx, sentry_value_set_by_key( event, "platform", sentry_value_new_string("native")); - // Set level (varies by event kind: "fatal" for crash, "error" for app hang) sentry_value_set_by_key(event, "level", sentry_value_new_string(level)); // Build exception - /* Function-scope so exc_value (which may point into this buffer) remains - * valid after the `else` block below. Previously declared inside the - * else: out of scope by the time exc_value is read -> UB per C99 6.2.4. */ char crash_value_buf[128]; const char *exc_type; const char *exc_value; @@ -2382,13 +2379,16 @@ build_native_event(const sentry_crash_context_t *ctx, sentry_value_t mechanism = sentry_value_new_object(); sentry_value_set_by_key( mechanism, "type", sentry_value_new_string(mechanism_type)); + // The override path (exception_type != NULL, e.g. app hang) fabricates the + // exception, so it is synthetic. A signal-derived crash is a real + // exception and must not be marked synthetic. sentry_value_set_by_key( - mechanism, "synthetic", sentry_value_new_bool(true)); + mechanism, "synthetic", sentry_value_new_bool(exception_type != NULL)); sentry_value_set_by_key( - mechanism, "handled", sentry_value_new_bool(mechanism_handled)); + mechanism, "handled", sentry_value_new_bool(handled)); - // Add signal metadata only for the signal-derived crash path (no override - // type). There is no signal in the override case (e.g. app hang). + // Add signal metadata only for the signal-derived crash path. + // There is no signal for e.g. app hang. if (!exception_type) { sentry_value_t meta = sentry_value_new_object(); sentry_value_t signal_info = sentry_value_new_object(); @@ -2401,8 +2401,8 @@ build_native_event(const sentry_crash_context_t *ctx, sentry_value_set_by_key(signal_info, "number", sentry_value_new_int32(ctx->platform.signum)); #endif - /* By the struct invariant, include_signal_meta is only true when - * exception_type is NULL, so exc_type holds the signal name here. */ + // Include_signal_meta is only true when exception_type is NULL, so + // `exc_type` holds the signal name here. sentry_value_set_by_key( signal_info, "name", sentry_value_new_string(exc_type)); sentry_value_set_by_key(meta, "signal", signal_info); @@ -2688,8 +2688,9 @@ build_native_event(const sentry_crash_context_t *ctx, * * The caller constructs the event at the point of capture (a crash event or an * app-hang event) via build_native_event() and hands it to us; we take - * ownership and decref it once serialized. If minidump_path is provided, it is - * also attached as an attachment. + * ownership and decref it once serialized. + * + * If minidump_path is provided, it is also attached as an attachment. */ static bool write_envelope_with_native_stacktrace(const sentry_options_t *options, @@ -3162,9 +3163,7 @@ app_hang_capture_stack(task_t task, sentry_crash_context_t *ctx, uint64_t sp) * same native-stacktrace path as crashes. * * Requires `task_for_pid` to be permitted (same-user, non-hardened local/dev - * builds). On a hardened release runtime without the debugger entitlement it - * is denied; the entitlement-free port-donation replacement is a separate - * follow-up. + * builds). */ static void capture_and_send_app_hang(const sentry_options_t *options, @@ -3174,8 +3173,7 @@ capture_and_send_app_hang(const sentry_options_t *options, * shmem fields (platform.mcontext, threads[0], crashed_tid, num_threads) * that the host's signal handler also writes on a real crash. The daemon * loop is single-threaded and processes a pending crash before reaching - * here, so the only remaining window is the host crashing mid-capture. - * Accepted for this initial cut; mitigation is tracked as follow-up. */ + * here. The remaining window is the host crashing mid-capture. */ sentry_crash_context_t *ctx = ipc->shmem; const uint64_t target_tid = ctx->app_hang_target_tid; @@ -3331,13 +3329,11 @@ capture_and_send_app_hang(const sentry_options_t *options, } /* Reuse the scope file the host keeps up-to-date via flush_scope so the - * app-hang event carries the same scope context as a crash event: full - * contexts (os/device/gpu/app/runtime/...), user, tags, extra, fingerprint, - * release/dist/env, sdk metadata, and breadcrumbs. The base event JSON is - * at ctx->event_path; the sibling run folder holds the attachments - * manifest, scope attachments, screenshot, and session replay — all pulled - * in by write_envelope_with_native_stacktrace when run_folder is - * non-NULL. */ + * app-hang event carries the same scope context as a crash event. The + * base event JSON is at ctx->event_path; the sibling run folder holds + * the attachments manifest, scope attachments, screenshot, and + * session replay — all pulled in by write_envelope_with_native_stacktrace + * when run_folder is non-NULL. */ const char *event_file_path = ctx->event_path[0] ? ctx->event_path : NULL; sentry_path_t *run_folder = NULL; if (event_file_path) { @@ -3348,15 +3344,15 @@ capture_and_send_app_hang(const sentry_options_t *options, } } - /* App-hang event: ANR-style override (no signal), handled, error level. + /* App-hang event: overriding the exception type, handled, error level. * The per-event value carries the freeze duration computed above. */ - sentry_value_t event = build_native_event(ctx, event_file_path, - run_folder, /*exception_type=*/"ApplicationNotResponding", - /*exception_value=*/value_buf, /*mechanism_type=*/"AppHang", - /*mechanism_handled=*/true, /*level=*/"error"); + sentry_value_t event = build_native_event(ctx, event_file_path, run_folder, + /*exception_type=*/"AppHang", + /*exception_value=*/value_buf, /*level=*/"error", + /*mechanism_type=*/"AppHang", /*handled=*/true); - bool ok = write_envelope_with_native_stacktrace(options, envelope_path, ctx, - event, /*minidump_path=*/NULL, run_folder); + bool ok = write_envelope_with_native_stacktrace( + options, envelope_path, ctx, event, /*minidump_path=*/NULL, run_folder); if (run_folder) { sentry__path_free(run_folder); @@ -3889,13 +3885,13 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) minidump_path[0] ? minidump_path : "NULL"); // Crash event: signal-derived type/value (with signal meta), fatal // level, unhandled. - sentry_value_t event = build_native_event(ctx, event_path, - run_folder, /*exception_type=*/NULL, /*exception_value=*/NULL, - /*mechanism_type=*/"signalhandler", /*mechanism_handled=*/false, - /*level=*/"fatal"); - envelope_written = write_envelope_with_native_stacktrace(options, - envelope_path, ctx, event, - minidump_path[0] ? minidump_path : NULL, run_folder); + sentry_value_t event = build_native_event(ctx, event_path, run_folder, + /*exception_type=*/NULL, /*exception_value=*/NULL, + /*level=*/"fatal", /*mechanism_type=*/"signalhandler", + /*handled=*/false); + envelope_written + = write_envelope_with_native_stacktrace(options, envelope_path, ctx, + event, minidump_path[0] ? minidump_path : NULL, run_folder); } else { // Mode 0 (MINIDUMP only) SENTRY_DEBUG("Writing envelope with minidump"); diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 25a2319ffd..ce4466320a 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -843,8 +843,7 @@ native_backend_flush_scope( return; } - // Create event with current scope. The daemon also reads this base event - // at app-hang time on macOS, so keep it current. + // Create event with current scope sentry_value_t event = sentry_value_new_object(); sentry_value_set_by_key( event, "level", sentry__value_new_level(SENTRY_LEVEL_FATAL)); diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c index 021798778b..9ae18ebbb5 100644 --- a/src/sentry_app_hang.c +++ b/src/sentry_app_hang.c @@ -50,8 +50,7 @@ sentry__app_hang_decide(bool enabled, uint64_t hb, uint64_t now, return SENTRY_APP_HANG_NO_ACTION; } -/* Public setters (always compiled, no platform guard — they only mutate the - * options struct). */ +// Public setters void sentry_options_set_app_hang_enabled(sentry_options_t *opts, int enabled) { @@ -82,9 +81,8 @@ sentry__app_hang_set_shmem(sentry_crash_context_t *ctx) uint64_t sentry__app_hang_now_ms(void) { - /* CLOCK_UPTIME_RAW is the macOS analogue of Windows' - * QueryUnbiasedInterruptTime: a monotonic clock that excludes time the - * system was asleep, read identically by host and daemon. */ + /* CLOCK_UPTIME_RAW is a monotonic clock that excludes time the system + * was asleep. */ struct timespec ts; if (clock_gettime(CLOCK_UPTIME_RAW, &ts) != 0) { return 0; diff --git a/src/sentry_app_hang.h b/src/sentry_app_hang.h index 7a20ce6009..b2278d9a46 100644 --- a/src/sentry_app_hang.h +++ b/src/sentry_app_hang.h @@ -18,8 +18,7 @@ #endif /** - * Decision returned by the pure decision function. Kept tiny so it can be - * exercised in unit tests without involving the daemon or shared memory. + * Decision returned by the pure decision function. */ typedef enum { SENTRY_APP_HANG_NO_ACTION = 0, @@ -48,7 +47,7 @@ typedef enum { * - `out_consecutive_stale_ticks` (out): updated counter the caller should * store. 0 if reset, otherwise incremented. * - * Returns SENTRY_APP_HANG_FIRE iff: enabled, hb != 0, (now - hb) >= timeout_ms, + * Returns SENTRY_APP_HANG_FIRE if: enabled, hb != 0, (now - hb) >= timeout_ms, * hb != last_fired_hb, AND the updated stale-tick counter reaches * SENTRY_APP_HANG_STRIKES_REQUIRED. */ diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 88c3f4f89f..d0a696389c 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -1146,7 +1146,7 @@ def test_native_restart_on_crash(cmake, httpserver): reason="app-hang detection is implemented on macOS", ) def test_native_app_hang(cmake, httpserver): - """App hang detection emits exactly one ApplicationNotResponding event. + """App hang detection emits exactly one AppHang event. On macOS the daemon samples the hung thread out-of-process via ``task_for_pid``, which requires the example + daemon to be ad-hoc @@ -1195,7 +1195,7 @@ def test_native_app_hang(cmake, httpserver): event = envelope.get_event() assert event is not None exc = event["exception"]["values"][0] - assert exc["type"] == "ApplicationNotResponding" + assert exc["type"] == "AppHang" assert exc["mechanism"]["type"] == "AppHang" assert exc["mechanism"]["handled"] is True assert exc["mechanism"]["synthetic"] is True From ad72414af6a46901e3dda65f3ddcb091b2bb7d6b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 17:25:56 +0200 Subject: [PATCH 19/26] get rid of strikes, update event message --- src/backends/native/sentry_crash_daemon.c | 21 ++-- src/sentry_app_hang.c | 20 +--- src/sentry_app_hang.h | 31 ++---- tests/unit/test_app_hang.c | 119 ++++------------------ tests/unit/tests.inc | 11 +- 5 files changed, 50 insertions(+), 152 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 7fc17872be..74e5c9cf42 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3310,10 +3310,11 @@ capture_and_send_app_hang(const sentry_options_t *options, thread_count * sizeof(thread_t)); mach_port_deallocate(mach_task_self(), task); - /* Build the per-event value description with the freeze duration. */ + /* Build the per-event description with the freeze duration. `freeze_ms` is + * the time since the last heartbeat at detection, which is necessarily at + * least the configured timeout — hence "at least". */ char value_buf[128]; - snprintf(value_buf, sizeof(value_buf), - "App hang detected. Main thread blocked for %llu ms.", + snprintf(value_buf, sizeof(value_buf), "App hung for at least %llu ms.", (unsigned long long)freeze_ms); /* Build an envelope path next to the crash one. */ @@ -3351,6 +3352,12 @@ capture_and_send_app_hang(const sentry_options_t *options, /*exception_value=*/value_buf, /*level=*/"error", /*mechanism_type=*/"AppHang", /*handled=*/true); + /* Surface the freeze duration as the event message too, so the issue + * title/summary reads "App hung for at least X ms." rather than the + * exception type alone. */ + sentry_value_set_by_key( + event, "message", sentry_value_new_string(value_buf)); + bool ok = write_envelope_with_native_stacktrace( options, envelope_path, ctx, event, /*minidump_path=*/NULL, run_folder); @@ -4346,7 +4353,6 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, const bool app_hang_enabled = ipc->shmem->app_hang_enabled; const uint64_t app_hang_timeout_ms = ipc->shmem->app_hang_timeout_ms; uint64_t last_fired_hb = 0; - int consecutive_stale_ticks = 0; const int wait_timeout_ms = app_hang_enabled ? 500 : SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; #else @@ -4392,15 +4398,12 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, #if defined(SENTRY_PLATFORM_MACOS) else if (app_hang_enabled && !crash_processed) { /* No crash notification this wake (timeout or spurious) — evaluate - * the app-hang heartbeat with strike accumulation. */ + * the app-hang heartbeat. */ sentry_crash_context_t *shctx = ipc->shmem; const uint64_t hb = shctx->app_hang_last_heartbeat_ms; const uint64_t now = sentry__app_hang_now_ms(); - int new_strikes = 0; sentry_app_hang_decision_t d = sentry__app_hang_decide( - app_hang_enabled, hb, now, app_hang_timeout_ms, last_fired_hb, - consecutive_stale_ticks, &new_strikes); - consecutive_stale_ticks = new_strikes; + app_hang_enabled, hb, now, app_hang_timeout_ms, last_fired_hb); if (d == SENTRY_APP_HANG_FIRE) { capture_and_send_app_hang(options, ipc, now - hb); /* Always advance last_fired_hb, even if capture failed — diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c index 9ae18ebbb5..74575db88f 100644 --- a/src/sentry_app_hang.c +++ b/src/sentry_app_hang.c @@ -17,37 +17,25 @@ sentry_app_hang_decision_t sentry__app_hang_decide(bool enabled, uint64_t hb, uint64_t now, - uint64_t timeout_ms, uint64_t last_fired_hb, int consecutive_stale_ticks, - int *out_consecutive_stale_ticks) + uint64_t timeout_ms, uint64_t last_fired_hb) { - /* Fresh or disabled paths reset the counter. */ if (!enabled || hb == 0) { - *out_consecutive_stale_ticks = 0; return SENTRY_APP_HANG_NO_ACTION; } if (now < hb) { /* Torn shmem read (possible on x86 for a non-atomic 64-bit load). * Treat as fresh — daemon will see the real value on the next tick. */ - *out_consecutive_stale_ticks = 0; return SENTRY_APP_HANG_NO_ACTION; } if ((now - hb) < timeout_ms) { - *out_consecutive_stale_ticks = 0; return SENTRY_APP_HANG_NO_ACTION; } if (hb == last_fired_hb) { - /* Already fired for this freeze. Stay quiet and hold the counter at - * zero so we re-arm cleanly once the host heartbeats again. */ - *out_consecutive_stale_ticks = 0; + /* Already fired for this freeze. Stay quiet until the host heartbeats + * again, which advances `hb` and re-arms detection. */ return SENTRY_APP_HANG_NO_ACTION; } - /* Stale and not in cooldown — accumulate a strike. */ - int new_count = consecutive_stale_ticks + 1; - *out_consecutive_stale_ticks = new_count; - if (new_count >= SENTRY_APP_HANG_STRIKES_REQUIRED) { - return SENTRY_APP_HANG_FIRE; - } - return SENTRY_APP_HANG_NO_ACTION; + return SENTRY_APP_HANG_FIRE; } // Public setters diff --git a/src/sentry_app_hang.h b/src/sentry_app_hang.h index b2278d9a46..a0795ad79a 100644 --- a/src/sentry_app_hang.h +++ b/src/sentry_app_hang.h @@ -25,35 +25,22 @@ typedef enum { SENTRY_APP_HANG_FIRE = 1, } sentry_app_hang_decision_t; -/* Number of consecutive timer ticks the daemon must observe a stale - * heartbeat before firing. Smooths over brief hiccups (GC pauses, swap, OS - * scheduler quanta) at the cost of ~SENTRY_APP_HANG_STRIKES_REQUIRED-1 - * extra poll periods of detection latency. */ -#define SENTRY_APP_HANG_STRIKES_REQUIRED 3 - /** * Pure function: should we fire an app-hang event right now? * - * - `enabled`: the host has app-hang detection turned on. - * - `hb`: last heartbeat timestamp (host clock; 0 means - * "never heartbeated yet"). - * - `now`: daemon's current observation of the same - * clock. - * - `timeout_ms`: staleness threshold. - * - `last_fired_hb`: the `hb` value the daemon last fired for; used - * as cooldown so a sustained freeze fires once. - * - `consecutive_stale_ticks`: caller-tracked count of consecutive ticks on - * which the heartbeat was observed stale. - * - `out_consecutive_stale_ticks` (out): updated counter the caller should - * store. 0 if reset, otherwise incremented. + * - `enabled`: the host has app-hang detection turned on. + * - `hb`: last heartbeat timestamp (host clock; 0 means + * "never heartbeated yet"). + * - `now`: daemon's current observation of the same clock. + * - `timeout_ms`: staleness threshold. + * - `last_fired_hb`: the `hb` value the daemon last fired for; used as + * cooldown so a sustained freeze fires once. * * Returns SENTRY_APP_HANG_FIRE if: enabled, hb != 0, (now - hb) >= timeout_ms, - * hb != last_fired_hb, AND the updated stale-tick counter reaches - * SENTRY_APP_HANG_STRIKES_REQUIRED. + * and hb != last_fired_hb. */ sentry_app_hang_decision_t sentry__app_hang_decide(bool enabled, uint64_t hb, - uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb, - int consecutive_stale_ticks, int *out_consecutive_stale_ticks); + uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb); #if defined(SENTRY_APP_HANG_HOST_SUPPORTED) /** diff --git a/tests/unit/test_app_hang.c b/tests/unit/test_app_hang.c index 2ab96f6490..b11ee8fdf1 100644 --- a/tests/unit/test_app_hang.c +++ b/tests/unit/test_app_hang.c @@ -5,149 +5,72 @@ SENTRY_TEST(app_hang_decide_disabled_returns_no_action) { - int new_count = 99; sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/false, /*hb=*/100, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/0, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/0); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - /* Disabled path resets the counter. */ - TEST_CHECK_INT_EQUAL(new_count, 0); } SENTRY_TEST(app_hang_decide_no_heartbeat_yet_returns_no_action) { - int new_count = 99; sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/true, /*hb=*/0, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/0, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/0); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(new_count, 0); } -SENTRY_TEST(app_hang_decide_fresh_heartbeat_returns_no_action_and_resets) +SENTRY_TEST(app_hang_decide_fresh_heartbeat_returns_no_action) { - int new_count = 99; sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/true, /*hb=*/9500, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/2, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/0); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - /* Fresh heartbeat resets the strike counter even mid-accumulation. */ - TEST_CHECK_INT_EQUAL(new_count, 0); } -SENTRY_TEST(app_hang_decide_first_stale_tick_increments_does_not_fire) +SENTRY_TEST(app_hang_decide_stale_heartbeat_fires) { - int new_count = -1; sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/true, /*hb=*/5000, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/0, &new_count); - TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(new_count, 1); -} - -SENTRY_TEST(app_hang_decide_second_stale_tick_increments_does_not_fire) -{ - int new_count = -1; - sentry_app_hang_decision_t d = sentry__app_hang_decide( - /*enabled=*/true, /*hb=*/5000, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/1, &new_count); - TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(new_count, 2); -} - -SENTRY_TEST(app_hang_decide_third_stale_tick_fires) -{ - int new_count = -1; - sentry_app_hang_decision_t d = sentry__app_hang_decide( - /*enabled=*/true, /*hb=*/5000, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/2, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/0); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); - TEST_CHECK_INT_EQUAL(new_count, 3); } -SENTRY_TEST(app_hang_decide_brief_hiccup_resets_strike_count) +SENTRY_TEST(app_hang_decide_exact_timeout_boundary_fires) { - /* Simulate: 2 stale ticks, then a fresh heartbeat (counter resets), - * then 1 stale tick → must NOT fire because we lost our accumulated - * strikes when the heartbeat refreshed. */ - int after_hiccup = -1; + /* now - hb == timeout_ms is still stale (>= semantics) — fires. */ sentry_app_hang_decision_t d = sentry__app_hang_decide( - /*enabled=*/true, /*hb=*/9800, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/2, &after_hiccup); - TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(after_hiccup, 0); - - int after_one_stale = -1; - d = sentry__app_hang_decide(/*enabled=*/true, /*hb=*/9800, - /*now=*/11000, /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/after_hiccup, &after_one_stale); - TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(after_one_stale, 1); + /*enabled=*/true, /*hb=*/9000, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); } SENTRY_TEST(app_hang_decide_cooldown_holds_when_hb_unchanged) { - /* Already fired for hb=5000. Subsequent ticks must NOT re-fire even - * if 100 more stale ticks accumulate. Counter held at 0. */ - int new_count = -1; + /* Already fired for hb=5000. A sustained freeze must NOT re-fire while the + * heartbeat stays at the same value. */ sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/true, /*hb=*/5000, /*now=*/20000, - /*timeout_ms=*/1000, /*last_fired_hb=*/5000, - /*consecutive_stale_ticks=*/0, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/5000); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(new_count, 0); } SENTRY_TEST(app_hang_decide_re_arms_after_advance_then_stall) { - /* hb advanced past last_fired_hb → cooldown released; need 3 fresh - * strikes again. */ - int after_strike1 = -1; + /* hb advanced past last_fired_hb → cooldown released; a fresh stall fires + * again. */ sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/true, /*hb=*/7000, /*now=*/12000, - /*timeout_ms=*/1000, /*last_fired_hb=*/5000, - /*consecutive_stale_ticks=*/0, &after_strike1); - TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(after_strike1, 1); - - int after_strike3 = -1; - d = sentry__app_hang_decide(/*enabled=*/true, /*hb=*/7000, - /*now=*/12000, /*timeout_ms=*/1000, /*last_fired_hb=*/5000, - /*consecutive_stale_ticks=*/2, &after_strike3); - TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); - TEST_CHECK_INT_EQUAL(after_strike3, 3); -} - -SENTRY_TEST(app_hang_decide_exact_timeout_boundary_with_third_strike_fires) -{ - /* now - hb == timeout_ms is still stale (>= semantics) AND the third - * strike has accumulated — fires. */ - int new_count = -1; - sentry_app_hang_decision_t d = sentry__app_hang_decide( - /*enabled=*/true, /*hb=*/9000, /*now=*/10000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/2, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/5000); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); - TEST_CHECK_INT_EQUAL(new_count, 3); } -SENTRY_TEST(app_hang_decide_torn_read_now_less_than_hb_resets) +SENTRY_TEST(app_hang_decide_torn_read_now_less_than_hb_returns_no_action) { /* On x86 a non-atomic 64-bit load can tear, producing now < hb. The - * decision function treats this as fresh (no FIRE) and resets the - * strike counter so the next non-torn observation starts clean. */ - int new_count = 99; + * decision function treats this as fresh (no FIRE); the daemon sees the + * real value on the next tick. */ sentry_app_hang_decision_t d = sentry__app_hang_decide( /*enabled=*/true, /*hb=*/10000, /*now=*/5000, - /*timeout_ms=*/1000, /*last_fired_hb=*/0, - /*consecutive_stale_ticks=*/2, &new_count); + /*timeout_ms=*/1000, /*last_fired_hb=*/0); TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); - TEST_CHECK_INT_EQUAL(new_count, 0); } diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 8ff0befb52..57c3736b9d 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -1,14 +1,11 @@ -XX(app_hang_decide_brief_hiccup_resets_strike_count) XX(app_hang_decide_cooldown_holds_when_hb_unchanged) XX(app_hang_decide_disabled_returns_no_action) -XX(app_hang_decide_exact_timeout_boundary_with_third_strike_fires) -XX(app_hang_decide_first_stale_tick_increments_does_not_fire) -XX(app_hang_decide_fresh_heartbeat_returns_no_action_and_resets) +XX(app_hang_decide_exact_timeout_boundary_fires) +XX(app_hang_decide_fresh_heartbeat_returns_no_action) XX(app_hang_decide_no_heartbeat_yet_returns_no_action) XX(app_hang_decide_re_arms_after_advance_then_stall) -XX(app_hang_decide_second_stale_tick_increments_does_not_fire) -XX(app_hang_decide_third_stale_tick_fires) -XX(app_hang_decide_torn_read_now_less_than_hb_resets) +XX(app_hang_decide_stale_heartbeat_fires) +XX(app_hang_decide_torn_read_now_less_than_hb_returns_no_action) XX(assert_sdk_name) XX(assert_sdk_user_agent) XX(assert_sdk_version) From 55ee3a22bf5589816d00878a0e4ad6de031d5290 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 17:31:28 +0200 Subject: [PATCH 20/26] updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd23b274d..3df61405c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Apple: use `os_sync_wait_on_address` for the level-triggered waitable flag in the batcher on modern macOS(14.4+) and iOS(17.4+). ([#1765](https://github.com/getsentry/sentry-native/pull/1765)) - Native/macOS: add thread names. ([#1766](https://github.com/getsentry/sentry-native/pull/1766)) - Add Upload-Metadata header to TUS requests. ([#1795](https://github.com/getsentry/sentry-native/pull/1795)) +- Native/macOS: add opt-in app-hang detection. When enabled, the out-of-process crash daemon monitors a heartbeat emitted via `sentry_app_hang_heartbeat()` and captures an `AppHang` event with a full stack trace if the monitored thread stops responding for longer than the configured timeout. Configure with `sentry_options_set_app_hang_enabled()` and `sentry_options_set_app_hang_timeout_ms()`. ([#1780](https://github.com/getsentry/sentry-native/pull/1780)) **Fixes**: From edeb2aa4cdfc9469892db4067297fb6d7bec1a07 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 19:14:43 +0200 Subject: [PATCH 21/26] consent --- src/backends/native/sentry_crash_daemon.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 74e5c9cf42..65b130f498 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3370,12 +3370,23 @@ capture_and_send_app_hang(const sentry_options_t *options, return; } + /* Sync the latest user consent from shmem (the host updates it on consent + * changes) into the run state before sending, mirroring the crash path, so + * sentry__capture_envelope honors a revoke/grant for app-hang events too. */ + if (options->run) { + sentry__atomic_store(&options->run->user_consent, + sentry__atomic_fetch(&ctx->user_consent)); + } + /* Read envelope from disk and hand to transport. */ sentry_path_t *env_path = sentry__path_from_str(envelope_path); if (env_path) { sentry_envelope_t *envelope = sentry__envelope_from_path(env_path); - if (envelope && options && options->transport) { + if (envelope && options && options->transport && options->run) { sentry__capture_envelope(options->transport, envelope, options); + } else if (envelope) { + /* No transport/run available: capture would not free it. */ + sentry_envelope_free(envelope); } sentry__path_remove(env_path); sentry__path_free(env_path); From a09d42a9bf8cd0e320ad3fc0345e59e061f6afcd Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 8 Jun 2026 19:23:14 +0200 Subject: [PATCH 22/26] addressed bot review --- src/backends/sentry_backend_native.c | 13 ++++++-- src/sentry_app_hang.c | 45 +++++++++++++++++++++++----- src/sentry_app_hang.h | 17 +++++++++-- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index ce4466320a..069afa71ab 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -689,12 +689,19 @@ native_backend_shutdown(sentry_backend_t *backend) // Cleanup IPC if (state->ipc) { #if defined(SENTRY_APP_HANG_HOST_SUPPORTED) - /* Clear the global heartbeat pointer before the shmem backing it goes - * away, so sentry_app_hang_heartbeat() cannot write to freed memory. */ + /* Hold the app-hang lock across BOTH clearing the registration and + * freeing the shmem mapping, so an in-flight sentry_app_hang_heartbeat() + * on another thread cannot write to memory that crash_ipc_free unmaps. + * The lock is recursive, so set_shmem may re-acquire it safely. */ + sentry__app_hang_lock(); sentry__app_hang_set_shmem(NULL); -#endif sentry__crash_ipc_free(state->ipc); state->ipc = NULL; // Prevent use-after-free + sentry__app_hang_unlock(); +#else + sentry__crash_ipc_free(state->ipc); + state->ipc = NULL; // Prevent use-after-free +#endif } #if !defined(SENTRY_PLATFORM_WINDOWS) && !defined(SENTRY_PLATFORM_IOS) diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c index 74575db88f..d492a74b3d 100644 --- a/src/sentry_app_hang.c +++ b/src/sentry_app_hang.c @@ -10,6 +10,8 @@ #include "sentry_options.h" #if defined(SENTRY_APP_HANG_HOST_SUPPORTED) +# include "sentry_sync.h" + # include # include # include @@ -58,12 +60,29 @@ sentry_options_set_app_hang_timeout_ms( #if defined(SENTRY_APP_HANG_HOST_SUPPORTED) -static sentry_crash_context_t *volatile g_app_hang_shmem = NULL; +/* Recursive (see SENTRY__MUTEX_INIT). Serializes the heartbeat body against + * shutdown clearing the registration and unmapping the shmem behind it. */ +static sentry_mutex_t g_app_hang_lock = SENTRY__MUTEX_INIT; +static sentry_crash_context_t *g_app_hang_shmem = NULL; + +void +sentry__app_hang_lock(void) +{ + sentry__mutex_lock(&g_app_hang_lock); +} + +void +sentry__app_hang_unlock(void) +{ + sentry__mutex_unlock(&g_app_hang_lock); +} void sentry__app_hang_set_shmem(sentry_crash_context_t *ctx) { + sentry__mutex_lock(&g_app_hang_lock); g_app_hang_shmem = ctx; + sentry__mutex_unlock(&g_app_hang_lock); } uint64_t @@ -78,14 +97,9 @@ sentry__app_hang_now_ms(void) return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; } -void -sentry_app_hang_heartbeat(void) +static void +app_hang_record_heartbeat(sentry_crash_context_t *ctx) { - sentry_crash_context_t *ctx = g_app_hang_shmem; - if (!ctx || !ctx->app_hang_enabled) { - return; - } - /* Obtain the portable 64-bit Mach thread id of the current thread; this * is the same value the daemon matches against via * thread_info(THREAD_IDENTIFIER_INFO). */ @@ -114,6 +128,21 @@ sentry_app_hang_heartbeat(void) ctx->app_hang_last_heartbeat_ms = sentry__app_hang_now_ms(); } +void +sentry_app_hang_heartbeat(void) +{ + /* Hold the lock across the whole body: it pins the shmem mapping so backend + * shutdown cannot unmap it (in sentry__crash_ipc_free) while we dereference + * `ctx`. The body is bounded and non-blocking, so shutdown waits at most + * for one in-flight heartbeat. */ + sentry__app_hang_lock(); + sentry_crash_context_t *ctx = g_app_hang_shmem; + if (ctx && ctx->app_hang_enabled) { + app_hang_record_heartbeat(ctx); + } + sentry__app_hang_unlock(); +} + #else /* host heartbeat not supported on this target */ void diff --git a/src/sentry_app_hang.h b/src/sentry_app_hang.h index a0795ad79a..b2c95f5266 100644 --- a/src/sentry_app_hang.h +++ b/src/sentry_app_hang.h @@ -48,12 +48,23 @@ sentry_app_hang_decision_t sentry__app_hang_decide(bool enabled, uint64_t hb, * subsequent `sentry_app_hang_heartbeat()` calls have somewhere to write. * Passing NULL clears the registration on backend shutdown. * - * The pointer is stored in a `volatile` global; ordering with shmem field - * initialization is the caller's responsibility (the backend writes options - * into shmem before calling this). + * Access to the stored pointer is serialized by the app-hang lock (see + * `sentry__app_hang_lock`); ordering with shmem field initialization is the + * caller's responsibility (the backend writes options into shmem before + * calling this). */ void sentry__app_hang_set_shmem(sentry_crash_context_t *ctx); +/** + * Serialize heartbeat access against teardown. The backend must hold this lock + * across BOTH clearing the shmem registration (`sentry__app_hang_set_shmem( + * NULL)`) AND freeing the underlying mapping, so that an in-flight + * `sentry_app_hang_heartbeat()` on another thread cannot write to memory that + * is about to be unmapped. The lock is recursive. + */ +void sentry__app_hang_lock(void); +void sentry__app_hang_unlock(void); + /** * Return a millisecond-resolution unbiased timestamp shared between host and * daemon. Exposed for the daemon to call as well. From 103c9d3524d577aaf4836f61b5dd0eca2ad0b43d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 9 Jun 2026 11:15:52 +0200 Subject: [PATCH 23/26] pretty --- src/backends/native/sentry_crash_daemon.c | 3 ++- src/backends/sentry_backend_native.c | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 65b130f498..4fac1e56c9 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3372,7 +3372,8 @@ capture_and_send_app_hang(const sentry_options_t *options, /* Sync the latest user consent from shmem (the host updates it on consent * changes) into the run state before sending, mirroring the crash path, so - * sentry__capture_envelope honors a revoke/grant for app-hang events too. */ + * sentry__capture_envelope honors a revoke/grant for app-hang events too. + */ if (options->run) { sentry__atomic_store(&options->run->user_consent, sentry__atomic_fetch(&ctx->user_consent)); diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 069afa71ab..8284a4feca 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -690,9 +690,10 @@ native_backend_shutdown(sentry_backend_t *backend) if (state->ipc) { #if defined(SENTRY_APP_HANG_HOST_SUPPORTED) /* Hold the app-hang lock across BOTH clearing the registration and - * freeing the shmem mapping, so an in-flight sentry_app_hang_heartbeat() - * on another thread cannot write to memory that crash_ipc_free unmaps. - * The lock is recursive, so set_shmem may re-acquire it safely. */ + * freeing the shmem mapping, so an in-flight + * sentry_app_hang_heartbeat() on another thread cannot write to memory + * that crash_ipc_free unmaps. The lock is recursive, so set_shmem may + * re-acquire it safely. */ sentry__app_hang_lock(); sentry__app_hang_set_shmem(NULL); sentry__crash_ipc_free(state->ipc); From b28784fb8f504c0c65b59454e4383b4f7821855d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 9 Jun 2026 12:35:56 +0200 Subject: [PATCH 24/26] skip 0 timeout --- src/sentry_app_hang.c | 6 +++++- tests/unit/test_app_hang.c | 10 ++++++++++ tests/unit/tests.inc | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/sentry_app_hang.c b/src/sentry_app_hang.c index d492a74b3d..27162ebfff 100644 --- a/src/sentry_app_hang.c +++ b/src/sentry_app_hang.c @@ -21,7 +21,11 @@ sentry_app_hang_decision_t sentry__app_hang_decide(bool enabled, uint64_t hb, uint64_t now, uint64_t timeout_ms, uint64_t last_fired_hb) { - if (!enabled || hb == 0) { + if (!enabled || hb == 0 || timeout_ms == 0) { + /* A zero timeout would treat every poll as stale (now - hb is always + * >= 0 once we pass the torn-read guard below), firing a fresh AppHang + * on each heartbeat advance of a perfectly healthy app. Treat it as + * "no detection" rather than a hang storm. */ return SENTRY_APP_HANG_NO_ACTION; } if (now < hb) { diff --git a/tests/unit/test_app_hang.c b/tests/unit/test_app_hang.c index b11ee8fdf1..ac7110610a 100644 --- a/tests/unit/test_app_hang.c +++ b/tests/unit/test_app_hang.c @@ -64,6 +64,16 @@ SENTRY_TEST(app_hang_decide_re_arms_after_advance_then_stall) TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); } +SENTRY_TEST(app_hang_decide_zero_timeout_returns_no_action) +{ + /* A zero timeout must not turn a healthy, heartbeating app into a stream of + * spurious AppHang events — it is treated as "detection off". */ + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/9999, /*now=*/10000, + /*timeout_ms=*/0, /*last_fired_hb=*/0); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); +} + SENTRY_TEST(app_hang_decide_torn_read_now_less_than_hb_returns_no_action) { /* On x86 a non-atomic 64-bit load can tear, producing now < hb. The diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 57c3736b9d..53f0e5fb8b 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -6,6 +6,7 @@ XX(app_hang_decide_no_heartbeat_yet_returns_no_action) XX(app_hang_decide_re_arms_after_advance_then_stall) XX(app_hang_decide_stale_heartbeat_fires) XX(app_hang_decide_torn_read_now_less_than_hb_returns_no_action) +XX(app_hang_decide_zero_timeout_returns_no_action) XX(assert_sdk_name) XX(assert_sdk_user_agent) XX(assert_sdk_version) From bbf0c1e1947ebfd7b9bc4a7d3832a2bc8b7c01cc Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 9 Jun 2026 12:38:37 +0200 Subject: [PATCH 25/26] sigterm handling --- src/backends/native/sentry_crash_daemon.c | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 4fac1e56c9..10670e2251 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -4124,6 +4124,22 @@ daemon_file_logger( fflush(log_file); // Flush immediately to ensure logs are written } +#if defined(SENTRY_PLATFORM_UNIX) +/* Set when the host asks the daemon to stop (sentry_close sends SIGTERM, then + * waitpid()s — see native_backend_shutdown). Without this the default SIGTERM + * disposition kills the daemon outright, skipping the loop-exit transport flush + * below. This is fine for crashes, but it silently drops any envelope that was + * queued while the host kept running (e.g. an app-hang upload). */ +static volatile sig_atomic_t g_daemon_terminate = 0; + +static void +daemon_sigterm_handler(int signum) +{ + (void)signum; + g_daemon_terminate = 1; +} +#endif + #if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) int sentry__crash_daemon_main( @@ -4371,6 +4387,22 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, const int wait_timeout_ms = SENTRY_CRASH_DAEMON_WAIT_TIMEOUT_MS; #endif +#if defined(SENTRY_PLATFORM_UNIX) + /* Catch the SIGTERM that sentry_close sends on clean shutdown so the daemon + * exits through the cleanup below and flushes any queued upload instead of + * dying instantly (see g_daemon_terminate). Deliberately no SA_RESTART: we + * want the signal to interrupt the blocking IPC wait so the loop observes + * the flag and breaks immediately, rather than stalling shutdown until the + * next wait timeout. An interrupted wait simply returns "no event" and the + * loop re-checks; when nothing is queued this just turns an instant kill + * into an equally quick clean exit. */ + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = daemon_sigterm_handler; + sigemptyset(&sa.sa_mask); + sigaction(SIGTERM, &sa, NULL); +#endif + while (true) { // Wait for crash notification (with timeout to check parent health) bool wait_result = sentry__crash_ipc_wait(ipc, wait_timeout_ms); @@ -4431,6 +4463,14 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, SENTRY_DEBUG("Parent process exited without crash"); break; } + +#if defined(SENTRY_PLATFORM_UNIX) + // Host asked us to stop (sentry_close). Allow for cleanup. + if (g_daemon_terminate) { + SENTRY_DEBUG("SIGTERM received, daemon exiting cleanly"); + break; + } +#endif } SENTRY_DEBUG("Daemon exiting"); From b1674db47eeece3971eee92d46820daf9a443dba Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 9 Jun 2026 14:25:14 +0200 Subject: [PATCH 26/26] review --- src/backends/native/sentry_crash_daemon.c | 32 +++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 10670e2251..9d56de75f4 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3152,6 +3152,25 @@ app_hang_capture_stack(task_t task, sentry_crash_context_t *ctx, uint64_t sp) sentry_free(buf); } +/* Remove the temporary stack snapshot written by app_hang_capture_stack once it + * has been consumed, so it does not accumulate (up to SENTRY_CRASH_MAX_STACK_- + * CAPTURE each) in the database dir across hangs. No-op if no file was written + * (capture skipped or failed — stack_path stays empty). */ +static void +app_hang_remove_stack_file(sentry_crash_context_t *ctx) +{ + if (!ctx->platform.threads[0].stack_path[0]) { + return; + } + sentry_path_t *p + = sentry__path_from_str(ctx->platform.threads[0].stack_path); + if (p) { + sentry__path_remove(p); + sentry__path_free(p); + } + ctx->platform.threads[0].stack_path[0] = '\0'; +} + /** * App-hang capture path (macOS). The host is alive but frozen, so unlike a * crash there is no in-process signal-handler snapshot to fall back on — the @@ -3210,10 +3229,15 @@ capture_and_send_app_hang(const sentry_options_t *options, for (mach_msg_type_number_t i = 0; i < thread_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) + if (target == MACH_PORT_NULL + && thread_info(threads[i], THREAD_IDENTIFIER_INFO, + (thread_info_t)&id_info, &id_count) == KERN_SUCCESS && id_info.thread_id == target_tid) { + /* First match wins; keep its port. The `target == MACH_PORT_NULL` + * guard means any later duplicate match falls through to the + * deallocate branch instead of overwriting (and leaking) the kept + * port. */ target = threads[i]; } else { /* Deallocate the ports we are not keeping. */ @@ -3326,6 +3350,7 @@ capture_and_send_app_hang(const sentry_options_t *options, if (path_len < 0 || path_len >= (int)sizeof(envelope_path)) { SENTRY_WARN("app-hang: envelope path truncated or invalid"); + app_hang_remove_stack_file(ctx); return; } @@ -3365,6 +3390,9 @@ capture_and_send_app_hang(const sentry_options_t *options, sentry__path_free(run_folder); } + /* The envelope writer has consumed the stack snapshot. */ + app_hang_remove_stack_file(ctx); + if (!ok) { SENTRY_WARN("app-hang: failed to write envelope"); return;