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**: diff --git a/examples/example.c b/examples/example.c index d7f80d7a79..fc3e10ea53 100644 --- a/examples/example.c +++ b/examples/example.c @@ -612,6 +612,26 @@ run_threads(thread_func_t func) } #endif +#if defined(SENTRY_PLATFORM_MACOS) +static void * +app_hang_demo_thread(void *arg) +{ + (void)arg; + /* The first heartbeat latches this thread as the monitored target */ + for (int i = 0; i < 10; i++) { + sentry_app_hang_heartbeat(); + usleep(50 * 1000); + } + + 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) { @@ -863,6 +883,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")) { @@ -874,6 +901,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 75d5ebd88a..d4b9c5a40e 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1699,6 +1699,48 @@ 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 via the native crash backend. + * + * 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 + * 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); + +/** + * 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. + * + * 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); + /** * Sets the path to the crashpad handler if the crashpad backend is used. * diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 317f7b3ea9..81503a6518 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 754dd64978..9968afcbb3 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -327,6 +327,20 @@ typedef struct { uint32_t module_count; sentry_module_info_t modules[SENTRY_CRASH_MAX_MODULES]; + /* 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. + * Daemon reads, never writes. + * - app_hang_last_heartbeat_ms: written on every heartbeat. + */ + 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 8e940d473e..9d56de75f4 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" @@ -46,6 +47,9 @@ # if defined(SENTRY_PLATFORM_MACOS) # include # include +# include +# include +# include # include # endif # if defined(SENTRY_PLATFORM_LINUX) @@ -2290,19 +2294,31 @@ 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 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 Exception mechanism type (e.g. "signalhandler") + * @param mechanism_type `mechanism.type`, e.g. "signalhandler" or "AppHang" * @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 *level, const char *mechanism_type, bool handled) + 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(); @@ -2333,47 +2349,65 @@ build_native_event(const sentry_crash_context_t *ctx, sentry_value_set_by_key(event, "level", sentry_value_new_string(level)); // Build exception - const char *signal_name = "UNKNOWN"; + char crash_value_buf[128]; + const char *exc_type; + const char *exc_value; + + if (exception_type) { + exc_type = exception_type; + exc_value = exception_value ? 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)); + // 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(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 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(); #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); + // 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); @@ -2650,20 +2684,21 @@ build_native_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) + 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_event( - ctx, event_file_path, run_folder, "fatal", "signalhandler", false); // Serialize event to JSON size_t event_size = 0; @@ -2902,6 +2937,492 @@ 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); +} + +/* 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 + * 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). + */ +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. 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; + + /* 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 (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. */ + 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 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 hung for at least %llu ms.", + (unsigned long long)freeze_ms); + + /* 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"); + app_hang_remove_stack_file(ctx); + 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. 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); + } + } + + /* 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=*/"AppHang", + /*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); + + if (run_folder) { + 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; + } + + /* 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 && 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); + } +} +#endif /* SENTRY_PLATFORM_MACOS */ + /** * Manually write a Sentry envelope with event, minidump, and attachments. * Format matches what Crashpad's Envelope class does. @@ -3409,9 +3930,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"); - envelope_written = write_envelope_with_native_stacktrace(options, - envelope_path, ctx, event_path, - minidump_path[0] ? minidump_path : NULL, run_folder); + // 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, + /*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"); @@ -3625,6 +4152,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( @@ -3847,12 +4390,50 @@ 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; + 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 + +#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, 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"); @@ -3886,12 +4467,38 @@ 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. */ + 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(); + sentry_app_hang_decision_t d = sentry__app_hang_decide( + 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 — + * 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)) { 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"); diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index dec427d798..8284a4feca 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,8 +688,21 @@ native_backend_shutdown(sentry_backend_t *backend) // Cleanup IPC 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. */ + sentry__app_hang_lock(); + sentry__app_hang_set_shmem(NULL); 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 new file mode 100644 index 0000000000..27162ebfff --- /dev/null +++ b/src/sentry_app_hang.c @@ -0,0 +1,158 @@ +/* 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 "sentry_sync.h" + +# 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) +{ + 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) { + /* 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. */ + return SENTRY_APP_HANG_NO_ACTION; + } + if ((now - hb) < timeout_ms) { + return SENTRY_APP_HANG_NO_ACTION; + } + if (hb == last_fired_hb) { + /* 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; + } + return SENTRY_APP_HANG_FIRE; +} + +// Public setters +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) + +/* 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 +sentry__app_hang_now_ms(void) +{ + /* 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; + } + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; +} + +static void +app_hang_record_heartbeat(sentry_crash_context_t *ctx) +{ + /* 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; + } + + /* 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); + + /* 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; + } + + /* 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(); +} + +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 +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..b2c95f5266 --- /dev/null +++ b/src/sentry_app_hang.h @@ -0,0 +1,75 @@ +#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. + */ +typedef enum { + SENTRY_APP_HANG_NO_ACTION = 0, + SENTRY_APP_HANG_FIRE = 1, +} sentry_app_hang_decision_t; + +/** + * 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. + * + * Returns SENTRY_APP_HANG_FIRE if: enabled, hb != 0, (now - hb) >= timeout_ms, + * 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); + +#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. + * + * 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. + */ +uint64_t sentry__app_hang_now_ms(void); +#endif + +#endif diff --git a/src/sentry_options.c b/src/sentry_options.c index 4fcc9970d7..09e68bd0a7 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -81,6 +81,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 7c81390dd9..d0a696389c 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -1139,3 +1139,71 @@ 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 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 + 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"] == "AppHang" + 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..ac7110610a --- /dev/null +++ b/tests/unit/test_app_hang.c @@ -0,0 +1,86 @@ +#include "sentry_app_hang.h" +#include "sentry_testsupport.h" + +#include + +SENTRY_TEST(app_hang_decide_disabled_returns_no_action) +{ + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/false, /*hb=*/100, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); +} + +SENTRY_TEST(app_hang_decide_no_heartbeat_yet_returns_no_action) +{ + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/0, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); +} + +SENTRY_TEST(app_hang_decide_fresh_heartbeat_returns_no_action) +{ + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/9500, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); +} + +SENTRY_TEST(app_hang_decide_stale_heartbeat_fires) +{ + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*enabled=*/true, /*hb=*/5000, /*now=*/10000, + /*timeout_ms=*/1000, /*last_fired_hb=*/0); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_FIRE); +} + +SENTRY_TEST(app_hang_decide_exact_timeout_boundary_fires) +{ + /* now - hb == timeout_ms is still stale (>= semantics) — fires. */ + sentry_app_hang_decision_t d = sentry__app_hang_decide( + /*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. 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); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); +} + +SENTRY_TEST(app_hang_decide_re_arms_after_advance_then_stall) +{ + /* 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); + 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 + * 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); + TEST_CHECK_INT_EQUAL(d, SENTRY_APP_HANG_NO_ACTION); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index ea810758b8..53f0e5fb8b 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -1,3 +1,12 @@ +XX(app_hang_decide_cooldown_holds_when_hb_unchanged) +XX(app_hang_decide_disabled_returns_no_action) +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_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)