From a30cda0f115e0eb53ad3a278780164ff6634e138 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Wed, 10 Jun 2026 11:10:33 -0400 Subject: [PATCH 1/8] Enable C++ ExtraAppNativeSources in AndroidAppBuilder CMake template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- .../Templates/CMakeLists-android.txt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/tasks/AndroidAppBuilder/Templates/CMakeLists-android.txt b/src/tasks/AndroidAppBuilder/Templates/CMakeLists-android.txt index 15eab832eb9ec7..f8aa4801e5201c 100644 --- a/src/tasks/AndroidAppBuilder/Templates/CMakeLists-android.txt +++ b/src/tasks/AndroidAppBuilder/Templates/CMakeLists-android.txt @@ -2,13 +2,25 @@ cmake_minimum_required(VERSION 3.10) project(monodroid) -enable_language(C ASM) +enable_language(C CXX ASM) if(ANDROID_NDK_MAJOR VERSION_LESS "23") message(FATAL_ERROR "Error: need at least Android NDK 23, got ${ANDROID_NDK_REVISION}!") endif() -add_compile_options(-Werror=missing-prototypes -Werror=missing-declarations -Wall -std=c99) +add_compile_options(-Wall) + +# These warnings describe C function declarations and can be rejected by C++ compilers. +add_compile_options($<$:-Werror=missing-prototypes>) +add_compile_options($<$:-Werror=missing-declarations>) +add_compile_options($<$:-std=c99>) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ANDROID_STL=none does not provide the C++ ABI support needed by RTTI or exceptions. +add_compile_options($<$:-fno-rtti>) +add_compile_options($<$:-fno-exceptions>) add_library( monodroid From 50b48353fd25aaa9b2d2b2131c9871aced4f3966 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Thu, 17 Sep 2026 13:51:00 -0400 Subject: [PATCH 2/8] Add cross-platform RichSigsegv crash reporter tests Link the production PAL/reporter archive, initialize a private PAL instance, and validate deterministic JSON and compact output on desktop Unix and Android. Introduce isolated desktop runners, invocation-owned output, and bounded failure diagnostics in the shared harness. Retain failed outputs and collect Android archives through XHarness. Use standard C++ support and route native Mono/NativeAOT exclusions before PAL dependency checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- src/libraries/sendtohelix-mobile.targets | 5 + src/libraries/tests.proj | 11 + ....InProcCrashReport.RichSigsegv.Test.csproj | 12 + .../Shared/InProcCrashReport.Common.props | 57 +++ .../Shared/android_log_interpose.c | 75 ++++ .../Shared/android_log_interpose.h | 27 ++ .../InProcCrashReport/Shared/config.h | 31 ++ .../exceptions/inproccrashreport/README.md | 43 ++ .../InProcCrashReport.RichSigsegv.csproj | 7 + .../inproccrashreport/Shared/CMakeLists.txt | 99 +++++ .../Shared/InProcCrashReport.Unix.props | 17 + .../inproccrashreport/Shared/Program.cs | 316 +++++++++++++++ .../Shared/inproccrashreport_test_driver.cpp | 375 ++++++++++++++++++ src/tests/build.sh | 2 +- 14 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/RichSigsegv/Android.Device_Emulator.InProcCrashReport.RichSigsegv.Test.csproj create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/InProcCrashReport.Common.props create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.c create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.h create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/config.h create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/README.md create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/RichSigsegv/InProcCrashReport.RichSigsegv.csproj create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/Shared/CMakeLists.txt create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/Shared/InProcCrashReport.Unix.props create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp diff --git a/src/libraries/sendtohelix-mobile.targets b/src/libraries/sendtohelix-mobile.targets index 4616a1776e3dd7..ba176a73a45dc5 100644 --- a/src/libraries/sendtohelix-mobile.targets +++ b/src/libraries/sendtohelix-mobile.targets @@ -140,6 +140,11 @@ 42 + + 100 + /sdcard/Android/data/%(AndroidPackageName)/files/inproccrashreport.zip + diff --git a/src/libraries/tests.proj b/src/libraries/tests.proj index 0dd94483146f90..c2e2851576147a 100644 --- a/src/libraries/tests.proj +++ b/src/libraries/tests.proj @@ -220,6 +220,10 @@ + + + + @@ -685,6 +689,13 @@ Condition="'$(TestAssemblies)' == 'true'" BuildInParallel="$(BuildTestInParallel)" /> + + diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/RichSigsegv/Android.Device_Emulator.InProcCrashReport.RichSigsegv.Test.csproj b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/RichSigsegv/Android.Device_Emulator.InProcCrashReport.RichSigsegv.Test.csproj new file mode 100644 index 00000000000000..9920d3606aa66e --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/RichSigsegv/Android.Device_Emulator.InProcCrashReport.RichSigsegv.Test.csproj @@ -0,0 +1,12 @@ + + + + Android.Device_Emulator.InProcCrashReport.RichSigsegv.Test + Android.Device_Emulator.InProcCrashReport.RichSigsegv.Test.dll + $(DefineConstants);INPROC_SCENARIO_RICHSIGSEGV + + + diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/InProcCrashReport.Common.props b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/InProcCrashReport.Common.props new file mode 100644 index 00000000000000..e10a287d106fd4 --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/InProcCrashReport.Common.props @@ -0,0 +1,57 @@ + + + Exe + false + true + $(NetCoreAppCurrent) + 100 + $(DefineConstants);INPROC_ANDROID + + false + /sdcard/Android/data/net.dot.$(AssemblyName)/files/inproccrashreport.zip + $(AdditionalXHarnessArguments) --device-out-folder=$(CrashReportArtifactPath) + + + + $(MSBuildThisFileDirectory) + $([MSBuild]::NormalizeDirectory('$(RepoRoot)', 'src', 'tests', 'baseservices', 'exceptions', 'inproccrashreport', 'Shared')) + $([MSBuild]::NormalizeDirectory('$(RepoRoot)', 'src', 'coreclr', 'debug', 'crashreport')) + + + + + + + + + + + + + + + + + + + + + + $([MSBuild]::NormalizePath('$(CoreCLRArtifactsPath)', 'lib', 'libcoreclrpal.a')) + $([MSBuild]::NormalizePath('$(ArtifactsObjDir)', 'coreclr', '$(TargetOS).$(TargetArchitecture).$(CoreCLRConfiguration)', 'shared_minipal', 'libminipal.a')) + + + + + + + + + + + + + + + + diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.c b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.c new file mode 100644 index 00000000000000..746b4c7c0c2de7 --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.c @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Captures the in-proc crash reporter's compact console report in-process. +// +// On Android the reporter emits each console line via __android_log_write under +// CRASHREPORT_LOG_TAG ("DOTNET_CRASH"), routed to logcat rather than a file +// descriptor -- so it cannot be captured by redirecting stdout/stderr. This +// translation unit is linked into the same shared library (libmonodroid.so) as +// the PAL-linked reporter, so the reporter's __android_log_write calls bind to +// this definition. Lines tagged DOTNET_CRASH are accumulated for later +// validation; every call is also forwarded to the real liblog implementation so +// the report still appears in logcat for debugging. + +#define _GNU_SOURCE +#include "android_log_interpose.h" + +#include +#include +#include +#include +#include + +// Must match CRASHREPORT_LOG_TAG in inproccrashreporter.h. +static const char s_crashTag[] = "DOTNET_CRASH"; + +// The fixed scenarios currently produce at most 1 KiB. Keep ample headroom +// without allocating from the signal-shaped reporting path. +static char s_capture[16 * 1024]; +static size_t s_captureLen; +static bool s_overflowed; + +typedef int (*android_log_write_fn)(int prio, const char* tag, const char* text); + +int __android_log_write(int prio, const char* tag, const char* text) +{ + if (tag != NULL && text != NULL && strcmp(tag, s_crashTag) == 0) + { + size_t textLen = strlen(text); + if (textLen < sizeof(s_capture) - s_captureLen - 1) + { + memcpy(s_capture + s_captureLen, text, textLen); + s_captureLen += textLen; + s_capture[s_captureLen++] = '\n'; + s_capture[s_captureLen] = '\0'; + } + else + { + s_overflowed = true; + } + } + + static android_log_write_fn s_real = NULL; + if (s_real == NULL) + { + s_real = (android_log_write_fn)dlsym(RTLD_NEXT, "__android_log_write"); + } + if (s_real != NULL) + { + return s_real(prio, tag, text); + } + return 0; +} + +const char* InProcCrashReportTest_GetConsoleCapture(void) +{ + return s_overflowed ? NULL : s_capture; +} + +void InProcCrashReportTest_ResetConsoleCapture(void) +{ + s_captureLen = 0; + s_capture[0] = '\0'; + s_overflowed = false; +} diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.h b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.h new file mode 100644 index 00000000000000..8d15b389b184b9 --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/android_log_interpose.h @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Shared declarations for the synthetic in-proc crash reporter test's console +// capture. The compact console report is written by the reporter via +// __android_log_write (Android logcat); android_log_interpose.c intercepts those +// writes so the report can be validated in-process alongside the JSON file. + +#ifndef ANDROID_LOG_INTERPOSE_H +#define ANDROID_LOG_INTERPOSE_H + +#ifdef __cplusplus +extern "C" +{ +#endif + +// Returns captured lines since the last reset, or NULL if the buffer overflowed. +const char* InProcCrashReportTest_GetConsoleCapture(void); + +// Clears the captured console-report text. +void InProcCrashReportTest_ResetConsoleCapture(void); + +#ifdef __cplusplus +} +#endif + +#endif // ANDROID_LOG_INTERPOSE_H diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/config.h b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/config.h new file mode 100644 index 00000000000000..59f3355593955f --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Shared/config.h @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma once + +// AndroidAppBuilder does not supply CoreCLR's platform/architecture definitions. +#define HOST_UNIX 1 +#define TARGET_UNIX 1 +#define PLATFORM_UNIX 1 +#define TARGET_ANDROID 1 + +#if defined(__x86_64__) +#define HOST_AMD64 1 +#define TARGET_AMD64 1 +#elif defined(__aarch64__) +#define HOST_ARM64 1 +#define TARGET_ARM64 1 +#elif defined(__arm__) +#define HOST_ARM 1 +#define TARGET_ARM 1 +#elif defined(__i386__) +#define HOST_X86 1 +#define TARGET_X86 1 +#else +#error Unsupported Android architecture +#endif + +#if defined(__LP64__) +#define HOST_64BIT 1 +#define TARGET_64BIT 1 +#endif diff --git a/src/tests/baseservices/exceptions/inproccrashreport/README.md b/src/tests/baseservices/exceptions/inproccrashreport/README.md new file mode 100644 index 00000000000000..c271a27ab8062a --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/README.md @@ -0,0 +1,43 @@ +# In-process crash reporter tests + +Under normal use, the in-process crash reporter writes diagnostics while handling a fatal signal and the application then terminates. These tests invoke the reporter with fixed crash data without raising a real signal, which keeps the process alive so managed assertions can inspect the generated report. + +The test-native library links the production formatter, writers, lifecycle, reporter, and watchdog from CoreCLR's built PAL archive. The native driver initializes its own PAL instance and supplies deterministic threads, stack frames, modules, exception details, signal information, and register state, then calls `InProcCrashReportSignalDispatcher` directly. This validates report construction and formatting, but not PAL signal dispatch, process termination, real VM stack walking, module lookup, or watchdog timing. + +The same managed validation and native driver run as regular CoreCLR tests on desktop Unix and through AndroidAppBuilder on Android. The Android projects are separate adapters because they need APK packaging and logcat interception. Apple mobile targets do not currently have an equivalent adapter for these shared native sources. + +| Scenario | Expected output | +| --- | --- | +| `RichSigsegv` | One completed JSON file and a compact report: three thread records, interleaved managed/native frames, generic names, exact registers and frame metadata, exception details, and module associations. | + +Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. The additional threads are fixed callback records, not real concurrent threads. + +Each invocation owns a unique output directory. Successful runs delete it; failed runs print its location and retain the files, including incomplete reports. Bounded report contents and native I/O diagnostics also appear in the test log. + +Desktop Helix runs write beneath `HELIX_WORKITEM_UPLOAD_ROOT` so failed outputs are collected automatically; local runs use the temporary directory. Android failures package the files as `inproccrashreport.zip` in the app's external files directory. The local runner and Helix use XHarness's existing `--device-out-folder` option to retrieve this archive before uninstalling the app. An empty archive remains on success because XHarness requires the configured artifact to exist. A single archive also works with XHarness's Android 11 file-copy fallback, which cannot copy a directory. Archiving requires the managed failure handler to run; an unexpected native crash or forced termination may leave only the empty archive and runner logs. + +## Layout + +The projects under this directory run on desktop Unix CoreCLR. Matching projects under `src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport` package the same test sources for Android. The Android projects are excluded from Mono discovery because the reporter is a CoreCLR component. + +`Shared/Program.cs` runs the scenario and validates its outputs. `Shared/inproccrashreport_test_driver.cpp` initializes PAL, checks its process ID, and supplies deterministic callback data and register state. `Shared/InProcCrashReport.Unix.props` connects the non-mobile Unix projects to `CMakeLists.txt`; the Android projects use `InProcCrashReport.Common.props`. Both adapters link the driver to the built PAL archive and minipal. PAL symbols stay private to the test library, separate from the PAL inside the hosting CoreCLR. + +The driver uses `PAL_InitializeDLL`, which does not install fatal-signal handlers. Reporter callback registration uses real PAL code, but the tests invoke the dispatcher themselves. The real watchdog is linked but disabled in the service settings; PAL and reporter state remain alive until the isolated process exits. + +The Android `Shared` directory contains `android_log_interpose.c/.h` to capture compact reports from Android logging, and `config.h` to supply CoreCLR platform/architecture definitions missing from AndroidAppBuilder. Desktop tests redirect stderr and get those definitions from the native test build, without a test-specific configuration header. + +AndroidAppBuilder normally uses `ANDROID_STL=none` because its app-launcher sources are C. The Android test props explicitly add the NDK C++ headers and statically link `libc++_static` and `libc++abi`, keeping those symbols private. No replacement C++ runtime is needed, and no `libc++_shared.so` needs to be packaged. Desktop builds use the normal C++ runtime. + +Real fatal-signal and real-runtime callback coverage requires an external process to inspect output after the crashing process exits and is intentionally separate from these deterministic fidelity tests. Watchdog behavior, concurrent crashes, frame limits/truncation, and lifecycle retention or disk failures also need separate coverage. + +## CI coverage + +Desktop tests use normal CoreCLR test discovery. Android tests are included in full-suite CoreCLR archive builds, not the default PR smoke selection, to keep APK packaging and device execution off that path. They run in the daily `runtime-extra-platforms` lanes and explicitly requested `/azp run runtime-android` or `/azp run runtime-androidemulator` PR builds. Selection uses `RunSmokeTestsOnly=false` and `ArchiveTests=true`, not an outerloop category; Mono and NativeAOT remain excluded. + +## Run locally + +Build CoreCLR for the matching platform, architecture, and configuration before building these tests. Both use its installed PAL archive; Android also uses minipal from the native build intermediates. Rebuild CoreCLR after changing reporter implementation code; rebuilding only the test does not rebuild the reporter. + +For a desktop product in a different location or configuration, pass `-cmakeargs "-DCRASH_REPORT_PAL_LIBRARY="` to the native test build. Custom system-libunwind products also require the matching `CLR_CMAKE_USE_SYSTEM_LIBUNWIND` CMake setting. + +Run a desktop scenario with the normal CoreCLR test command for its project. For Android, build a CoreCLR Android test environment and start an emulator as described in the [CoreCLR Android documentation](../../../../../docs/workflow/building/coreclr/android.md), then run the matching Android project through its `Test` target. diff --git a/src/tests/baseservices/exceptions/inproccrashreport/RichSigsegv/InProcCrashReport.RichSigsegv.csproj b/src/tests/baseservices/exceptions/inproccrashreport/RichSigsegv/InProcCrashReport.RichSigsegv.csproj new file mode 100644 index 00000000000000..e599d952014768 --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/RichSigsegv/InProcCrashReport.RichSigsegv.csproj @@ -0,0 +1,7 @@ + + + $(DefineConstants);INPROC_SCENARIO_RICHSIGSEGV + + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/CMakeLists.txt b/src/tests/baseservices/exceptions/inproccrashreport/Shared/CMakeLists.txt new file mode 100644 index 00000000000000..80cf8914417805 --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/CMakeLists.txt @@ -0,0 +1,99 @@ +project(InProcCrashReportNative) + +# Android builds these sources through AndroidAppBuilder instead. +# Apple mobile does not yet have an adapter for these tests. +if(CLR_CMAKE_TARGET_WIN32 OR + CLR_CMAKE_TARGET_ANDROID OR + CLR_CMAKE_TARGET_APPLE_MOBILE OR + CLR_CMAKE_TARGET_ARCH_WASM) + return() +endif() + +# Native discovery runs independently of the managed project's runtime exclusions. +if(CLR_CMAKE_TEST_RUNTIME_FLAVOR STREQUAL "mono" OR CLR_CMAKE_TEST_BUILD_MODE STREQUAL "nativeaot") + return() +endif() + +set(CRASH_REPORT_SOURCE_DIR "${CLR_REPO_ROOT_DIR}/src/coreclr/debug/crashreport") + +# build.sh installs native tests to /tests/coreclr/... +# Reuse the matching product build, including when -bindir relocates the artifacts. +# A different product configuration/location can be selected with -cmakeargs +# -DCRASH_REPORT_PAL_LIBRARY=. +if(NOT DEFINED CRASH_REPORT_PAL_LIBRARY) + get_filename_component(CRASH_REPORT_PRODUCT_CONFIG "${CMAKE_INSTALL_PREFIX}" NAME) + get_filename_component(CRASH_REPORT_ARTIFACTS_DIR "${CMAKE_INSTALL_PREFIX}/../../.." ABSOLUTE) + set(CRASH_REPORT_PAL_LIBRARY "${CRASH_REPORT_ARTIFACTS_DIR}/bin/coreclr/${CRASH_REPORT_PRODUCT_CONFIG}/lib/libcoreclrpal.a") +endif() + +if(NOT EXISTS "${CRASH_REPORT_PAL_LIBRARY}") + message(FATAL_ERROR "Missing ${CRASH_REPORT_PAL_LIBRARY}. Build CoreCLR first, or set CRASH_REPORT_PAL_LIBRARY to the matching installed libcoreclrpal.a.") +endif() + +# coreclrpal already contains the production reporter, writers, lifecycle and watchdog. +# CI builds this library in the product job, then transfers it as a native-test artifact. +# The test-running job copies the linked library; it does not need to relink the PAL. +add_library(InProcCrashReportNative SHARED inproccrashreport_test_driver.cpp) + +target_include_directories(InProcCrashReportNative PRIVATE + ${CRASH_REPORT_SOURCE_DIR} + ${CLR_REPO_ROOT_DIR}/src/coreclr/pal/inc) + +# configurecompiler.cmake supplies the HOST_*/TARGET_* architecture and OS definitions. +target_compile_definitions(InProcCrashReportNative PRIVATE PLATFORM_UNIX) + +target_link_libraries(InProcCrashReportNative PRIVATE + "${CRASH_REPORT_PAL_LIBRARY}" + ${LINK_LIBRARIES_ADDITIONAL} + m) + +# The installed archive does not carry coreclrpal's CMake usage requirements. +# Keep these dependencies in sync with src/coreclr/pal/src/CMakeLists.txt. +# Pass CLR_CMAKE_USE_SYSTEM_LIBUNWIND when the product was built with it. + +if(CLR_CMAKE_TARGET_APPLE) + foreach(framework IN ITEMS CoreFoundation CoreServices Security System) + find_library(CRASH_REPORT_${framework} NAMES ${framework} REQUIRED) + target_link_libraries(InProcCrashReportNative PRIVATE "${CRASH_REPORT_${framework}}") + endforeach() +elseif(CLR_CMAKE_TARGET_LINUX) + target_link_libraries(InProcCrashReportNative PRIVATE gcc_s pthread rt dl) + if(CLR_CMAKE_TARGET_LINUX_MUSL AND (CLR_CMAKE_TARGET_ARCH_I386 OR CLR_CMAKE_TARGET_ARCH_POWERPC64)) + target_link_libraries(InProcCrashReportNative PRIVATE ucontext) + endif() +elseif(CLR_CMAKE_TARGET_FREEBSD OR CLR_CMAKE_TARGET_NETBSD OR CLR_CMAKE_TARGET_SUNOS) + target_link_libraries(InProcCrashReportNative PRIVATE pthread rt) +elseif(CLR_CMAKE_TARGET_OPENBSD) + target_link_libraries(InProcCrashReportNative PRIVATE pthread) +elseif(CLR_CMAKE_TARGET_HAIKU) + target_link_libraries(InProcCrashReportNative PRIVATE bsd) +endif() + +if(CLR_CMAKE_TARGET_FREEBSD OR CLR_CMAKE_TARGET_OPENBSD OR CLR_CMAKE_TARGET_HAIKU OR + (CLR_CMAKE_TARGET_LINUX AND CLR_CMAKE_USE_SYSTEM_LIBUNWIND)) + find_unwind_libs(CRASH_REPORT_UNWIND_LIBS) + target_link_libraries(InProcCrashReportNative PRIVATE ${CRASH_REPORT_UNWIND_LIBS}) +endif() + +if(CLR_CMAKE_TARGET_NETBSD) + find_library(CRASH_REPORT_KVM NAMES kvm REQUIRED) + target_link_libraries(InProcCrashReportNative PRIVATE "${CRASH_REPORT_KVM}") + if(CLR_CMAKE_USE_SYSTEM_LIBUNWIND) + find_library(CRASH_REPORT_UNWIND NAMES unwind REQUIRED) + target_link_libraries(InProcCrashReportNative PRIVATE "${CRASH_REPORT_UNWIND}") + endif() +endif() + +# Keep this PAL private rather than interposing with the hosting CoreCLR's PAL. +set(CRASH_REPORT_EXPORTS_FILE "${CMAKE_CURRENT_BINARY_DIR}/InProcCrashReportNative.exports") +if(CLR_CMAKE_TARGET_APPLE) + file(GENERATE OUTPUT "${CRASH_REPORT_EXPORTS_FILE}" CONTENT "_InProcCrashReportTest_*\n") +else() + file(GENERATE OUTPUT "${CRASH_REPORT_EXPORTS_FILE}" CONTENT "{ global: InProcCrashReportTest_*; local: *; };\n") + target_link_options(InProcCrashReportNative PRIVATE "LINKER:-z,defs") +endif() +set_exports_linker_option("${CRASH_REPORT_EXPORTS_FILE}") +target_link_options(InProcCrashReportNative PRIVATE "${EXPORTS_LINKER_OPTION}") +set_property(TARGET InProcCrashReportNative APPEND PROPERTY LINK_DEPENDS "${CRASH_REPORT_EXPORTS_FILE}") + +install(TARGETS InProcCrashReportNative DESTINATION bin) diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/InProcCrashReport.Unix.props b/src/tests/baseservices/exceptions/inproccrashreport/Shared/InProcCrashReport.Unix.props new file mode 100644 index 00000000000000..06997645efc192 --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/InProcCrashReport.Unix.props @@ -0,0 +1,17 @@ + + + + true + true + true + true + true + true + + + + + + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs new file mode 100644 index 00000000000000..0c8c26739fa61f --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +#if INPROC_ANDROID +using System.IO.Compression; +#endif +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; + +public static class Program +{ +#if INPROC_SCENARIO_RICHSIGSEGV + private const int ScenarioId = 0; +#else +#error Define an INPROC_SCENARIO_* symbol for this test. +#endif + +#if INPROC_ANDROID + private const string NativeLib = "libmonodroid"; +#else + private const string NativeLib = "InProcCrashReportNative"; +#endif + + private const string ModuleGuid = "{11111111-2222-3333-4455-66778899aabb}"; + private const string Separator = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***"; + + [DllImport(NativeLib)] + private static extern int InProcCrashReportTest_DriveScenario( + int scenario, string reportRootPath, string consoleCapturePath); + +#if INPROC_ANDROID + public static int Main() +#else + [Xunit.Fact] + public static int TestEntryPoint() +#endif + { + return RunTest(RunScenario); + } + + private static int RunTest(Action scenario) + { +#if INPROC_ANDROID + string outputRoot = Path.GetTempPath(); + string? archivePath = null; +#else + string? outputRoot = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT"); + if (string.IsNullOrEmpty(outputRoot)) + { + outputRoot = Path.GetTempPath(); + } +#endif + string outputDirectory = Directory.CreateDirectory( + Path.Combine(outputRoot, $"inproccrashreport-{Guid.NewGuid():N}")).FullName; + Stopwatch timer = Stopwatch.StartNew(); + Console.WriteLine($"InProcCrashReport: starting {typeof(Program).Assembly.GetName().Name}; output={outputDirectory}"); + Console.Out.Flush(); + + try + { +#if INPROC_ANDROID + archivePath = Environment.GetEnvironmentVariable("DOTNET_InProcCrashReportTestArchive") ?? + throw new InvalidOperationException("DOTNET_InProcCrashReportTestArchive was not configured"); + // XHarness expects the configured artifact to exist even on success. + WriteArtifactArchive(archivePath); +#endif + scenario(outputDirectory); + Directory.Delete(outputDirectory, recursive: true); + Console.WriteLine($"PASS: crash report assertions completed in {timer.ElapsedMilliseconds} ms"); + return 100; + } + catch (Exception ex) + { + Console.WriteLine($"FAIL after {timer.ElapsedMilliseconds} ms: {ex}"); + DumpOutputs(outputDirectory); +#if INPROC_ANDROID + if (archivePath is not null) + { + try + { + WriteArtifactArchive(archivePath, outputDirectory); + Console.WriteLine($"Android failure artifacts: {archivePath}"); + } + catch (Exception artifactException) when (artifactException is IOException or UnauthorizedAccessException) + { + Console.WriteLine($"Could not archive failure artifacts: {artifactException}"); + } + } +#endif + Console.WriteLine($"Retained crash report outputs: {outputDirectory}"); + return 1; + } + } + +#if INPROC_ANDROID + private static void WriteArtifactArchive(string archivePath, string? outputDirectory = null) + { + using ZipArchive archive = new ZipArchive(File.Create(archivePath), ZipArchiveMode.Create); + if (outputDirectory is not null) + { + foreach (string path in Directory.EnumerateFiles(outputDirectory, "*", SearchOption.AllDirectories)) + { + string entryName = Path.GetRelativePath(outputDirectory, path).Replace(Path.DirectorySeparatorChar, '/'); + archive.CreateEntryFromFile(path, entryName); + } + } + } +#endif + + private static void RunScenario(string outputDirectory) + { + string consolePath = Path.Combine(outputDirectory, "console.txt"); + Stopwatch timer = Stopwatch.StartNew(); + int result = InProcCrashReportTest_DriveScenario(ScenarioId, outputDirectory, consolePath); + Console.WriteLine($"Native scenario {ScenarioId} returned {result} in {timer.ElapsedMilliseconds} ms"); + Check(result == 0, "native driver failed; see its diagnostics"); + + string reportDirectory = Path.Combine(outputDirectory, ".dotnet", "crash-reports"); + string[] reports = Directory.GetFiles(reportDirectory); + Check(reports.Length == 1 && reports[0].EndsWith(".crashreport.json", StringComparison.Ordinal), + $"expected one completed report and no temporary files, found: {string.Join(", ", reports)}"); + ValidateJson(reports[0]); + ValidateConsole(consolePath); + } + + private static void ValidateJson(string path) + { + Console.WriteLine($"Validating JSON: {Path.GetFileName(path)}"); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement root = document.RootElement; + JsonElement payload = root.GetProperty("payload"); + CheckString(payload, "protocol_version", "1.0.0"); + CheckString(payload.GetProperty("configuration"), "architecture", GetArchitecture()); + CheckString(payload, "pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + Check(!string.IsNullOrEmpty(payload.GetProperty("process_name").GetString()), "missing process name"); + CheckString(root.GetProperty("parameters"), "signal", "11"); + + JsonElement threads = GetArray(payload, "threads", 3); + ulong crashTid = ReadHex(threads[0], "native_thread_id"); + Check(crashTid != 0, "crashing thread ID is zero"); + for (int i = 0; i < threads.GetArrayLength(); i++) + { + CheckString(threads[i], "crashed", i == 0 ? "true" : "false"); + CheckHex(threads[i], "native_thread_id", crashTid + (ulong)i); + if (i != 0) + { + CheckAbsent(threads[i], "managed_exception_type"); + CheckAbsent(threads[i], "managed_exception_hresult"); + } + } + + ValidateRichJson(threads); + } + + private static void ValidateRichJson(JsonElement threads) + { + JsonElement crashed = threads[0]; + CheckString(crashed, "managed_exception_type", "System.NullReferenceException"); + CheckString(crashed, "managed_exception_hresult", "0x80004003"); + CheckRegisters(crashed); + JsonElement frames = GetArray(crashed, "stack_frames", 5); + CheckContextFrame(frames[0]); + CheckManagedFrame(frames[1], 0x40aaaa, "Synthetic.App.Worker`1[System.Int32].DoWork", 0x06000001); + CheckNativeFrame(frames[2], 0x40bbbb, "libsynthetic.so"); + CheckManagedFrame(frames[3], 0x40cccc, "Synthetic.App.Dictionary`2[System.String,System.Int32].Insert", 0x06000002); + CheckNativeFrame(frames[4], 0x40dddd, "libnative2.so"); + CheckManagedFrame(GetArray(threads[1], "stack_frames", 1)[0], 0x40eeee, "Synthetic.App.Server.Listen", 0x06000003); + CheckNativeFrame(GetArray(threads[2], "stack_frames", 1)[0], 0x40ffff, "libsynthetic.so"); + } + + private static void ValidateConsole(string path) + { + Console.WriteLine($"Validating compact report: {Path.GetFileName(path)}"); + string console = File.ReadAllText(path); + string[] lines = console.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => line.Trim()).ToArray(); + Check(lines.Length != 0 && lines[0] == Separator && lines[^1] == Separator, "missing report delimiters"); + Check(lines.Count(line => line == ".NET Crash Report v1.0.0") == 1, "expected one protocol header"); + Check(lines.Contains($"ABI: {GetArchitecture()}"), "incorrect ABI"); + Check(lines.Count(line => line.StartsWith("signal ", StringComparison.Ordinal)) == 1, "expected one signal line"); + Check(lines.Contains("signal 11 (SIGSEGV)"), "incorrect signal"); + Check(!lines.Any(line => line == "(no managed frames)" || line.StartsWith("... +", StringComparison.Ordinal)), + "compact report unexpectedly omitted frames"); + + string[][] expectedThreads = + [ + [ + "managed exception: System.NullReferenceException (0x80004003)", + "#00 [0] Synthetic.App.Worker`1[System.Int32].DoWork + 0x10 (token=0x6000001)", + "#01 [1] 0x40bbbb (libsynthetic.so + 0x40)", + "#02 [0] Synthetic.App.Dictionary`2[System.String,System.Int32].Insert + 0x10 (token=0x6000002)", + "#03 [2] 0x40dddd (libnative2.so + 0x40)", + ], + ["#00 [0] Synthetic.App.Server.Listen + 0x10 (token=0x6000003)"], + ["#00 [1] 0x40ffff (libsynthetic.so + 0x40)"], + ]; + + string[] blocks = console.Split("--- thread ", StringSplitOptions.None); + Check(blocks.Length == expectedThreads.Length + 1, $"expected {expectedThreads.Length} console threads, got {blocks.Length - 1}"); + for (int i = 0; i < expectedThreads.Length; i++) + { + string[] blockLines = blocks[i + 1].Split('\n').Select(line => line.Trim()).ToArray(); + Check(blockLines[0].Contains("(crashed)", StringComparison.Ordinal) == (i == 0), $"thread {i}: incorrect crashed marker"); + string[] actual = blockLines.Skip(1).Where(line => + line.StartsWith('#') || line.StartsWith("managed exception:", StringComparison.Ordinal)).ToArray(); + Check(actual.SequenceEqual(expectedThreads[i]), $"thread {i}: expected compact lines:\n{string.Join("\n", expectedThreads[i])}\nactual:\n{string.Join("\n", actual)}"); + } + + string[] modules = ["synthetic.managed.dll", "libsynthetic.so", "libnative2.so"]; + string[] actualModules = lines.Where(line => line.StartsWith('[')).ToArray(); + string[] expectedModules = modules.Select((module, index) => $"[{index}] {module} {ModuleGuid}").ToArray(); + Check(actualModules.SequenceEqual(expectedModules), "compact module table mismatch"); + Check(lines.Count(line => line == "modules:") == (modules.Length == 0 ? 0 : 1), "incorrect module table header count"); + } + + private static void CheckRegisters(JsonElement thread) + { + JsonElement context = thread.GetProperty("ctx"); + CheckHex(context, "IP", 0x40aaaa); + CheckHex(context, "SP", IntPtr.Size == 8 ? 0x7fff0000aaaaUL : 0x7fffaaaaUL); + CheckHex(context, "BP", IntPtr.Size == 8 ? 0x7fff0000aab0UL : 0x7fffaab0UL); + } + + private static void CheckContextFrame(JsonElement frame) + { + CheckString(frame, "is_managed", "false"); + CheckHex(frame, "native_address", 0x40aaaa); + CheckHex(frame, "stack_pointer", IntPtr.Size == 8 ? 0x7fff0000aaaaUL : 0x7fffaaaaUL); + } + + private static void CheckManagedFrame(JsonElement frame, ulong ip, string method, uint token) + { + CheckString(frame, "is_managed", "true"); + CheckString(frame, "method_name", method); + CheckString(frame, "filename", "synthetic.managed.dll"); + CheckString(frame, "guid", ModuleGuid); + CheckHex(frame, "native_address", ip); + CheckHex(frame, "stack_pointer", ip + 0x1000); + CheckHex(frame, "native_offset", 0x20); + CheckHex(frame, "token", token); + CheckHex(frame, "il_offset", 0x10); + CheckHex(frame, "timestamp", 0x600dcafe); + CheckHex(frame, "sizeofimage", 0x10000); + } + + private static void CheckNativeFrame(JsonElement frame, ulong ip, string module) + { + CheckString(frame, "is_managed", "false"); + CheckString(frame, "native_module", module); + CheckHex(frame, "native_address", ip); + CheckHex(frame, "stack_pointer", ip + 0x1000); + CheckHex(frame, "native_offset", 0x40); + CheckAbsent(frame, "method_name"); + CheckAbsent(frame, "token"); + } + + private static JsonElement GetArray(JsonElement parent, string property, int count) + { + JsonElement array = parent.GetProperty(property); + Check(array.GetArrayLength() == count, $"{property}: expected {count} entries, actual {array.GetArrayLength()}"); + return array; + } + + private static string GetArchitecture() => + RuntimeInformation.ProcessArchitecture == Architecture.X64 ? "amd64" : RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + + private static ulong ReadHex(JsonElement parent, string property) => + ulong.Parse(parent.GetProperty(property).GetString()!.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + + private static void CheckHex(JsonElement parent, string property, ulong expected) => + CheckString(parent, property, $"0x{expected:x}"); + + private static void CheckString(JsonElement parent, string property, string expected) + { + string? actual = parent.GetProperty(property).GetString(); + Check(actual == expected, $"{property}: expected '{expected}', actual '{actual}'"); + } + + private static void CheckAbsent(JsonElement parent, string property) => + Check(!parent.TryGetProperty(property, out _), $"unexpected property '{property}'"); + + private static void Check(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + + private static void DumpOutputs(string outputDirectory) + { + try + { + char[] buffer = new char[16 * 1024]; + foreach (string path in Directory.EnumerateFiles(outputDirectory, "*", SearchOption.AllDirectories).Take(8)) + { + Console.WriteLine($"--- {path} ---"); + using StreamReader reader = File.OpenText(path); + int length = reader.ReadBlock(buffer, 0, buffer.Length); + Console.WriteLine(buffer.AsSpan(0, length)); + if (!reader.EndOfStream) + { + Console.WriteLine("[diagnostic output truncated]"); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.WriteLine($"Could not read diagnostic files: {ex}"); + } + } +} diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp new file mode 100644 index 00000000000000..1d7230011d7f67 --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp @@ -0,0 +1,375 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Invokes the production reporter with fixed callback data. This tests report +// fidelity, not real signal dispatch, VM stack walking, or crash-time safety. + +#ifndef PLATFORM_UNIX +#include "config.h" +#endif +#include "pal.h" +#include "inproccrashreporter.h" +#if defined(TARGET_ANDROID) +#include "android_log_interpose.h" +#endif + +#include + +#include +#include +#include +#include +#include +#include +#if !defined(TARGET_ANDROID) +#include +#include +#endif + +// Normally published to PAL via its callback setter, not the reporter header. +void InProcCrashReportSignalDispatcher(int signal, void* siginfo, void* context); + +#define INPROC_TEST_EXPORT __attribute__((visibility("default"))) + +namespace +{ + // Scenario ids -- must match the managed harness (Program.cs). + const int kScenarioRichSigsegv = 0; + + // Synthetic module handles, resolved by ModuleInfoCallback below. + const void* const kManagedModule = reinterpret_cast(0x1000); + const void* const kNativeModule = reinterpret_cast(0x2000); + const void* const kNativeModule2 = reinterpret_cast(0x3000); + + const GUID kSyntheticGuid = + { 0x11111111, 0x2222, 0x3333, { 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb } }; + + bool Check(bool condition, const char* message) + { + if (!condition) + { + printf("FAIL: %s\n", message); + fflush(stdout); + } + return condition; + } + + bool CheckIo(bool succeeded, const char* operation, const char* path) + { + if (!succeeded) + { + int error = errno; + printf("FAIL: %s('%s'): errno %d (%s)\n", operation, path, error, strerror(error)); + fflush(stdout); + } + return succeeded; + } + + bool InitializePal() + { + // PAL and reporter state live until the isolated test process exits. + int result = PAL_InitializeDLL(); + if (result != 0) + { + printf("FAIL: PAL_InitializeDLL returned %d\n", result); + fflush(stdout); + return false; + } + + return Check(GetCurrentProcessId() == static_cast(getpid()), + "PAL process ID does not match the host process"); + } + + void InitializeServices(const char* reportRootPath, bool enableLifecycle) + { + InProcCrashReporterServicesSettings services = {}; + services.enableCreateCrashDump = true; + services.enableLifecycle = enableLifecycle; + services.reportRootPath = reportRootPath; + services.maxFileCount = CRASHREPORT_DEFAULT_MAX_FILE_COUNT; + InProcCrashReportInitializeServices(services); + } + + struct SyntheticContext + { + ucontext_t context; +#if defined(TARGET_APPLE) + struct __darwin_mcontext64 machineContext; +#endif + }; + + bool IsManagedThreadCallback() + { + return true; + } + + bool ModuleInfoCallback(const void* moduleHandle, const char** moduleName, GUID* moduleGuid) + { + if (moduleGuid != nullptr) + { + *moduleGuid = kSyntheticGuid; + } + if (moduleHandle == kManagedModule) + { + if (moduleName != nullptr) + { + *moduleName = "synthetic.managed.dll"; + } + return true; + } + if (moduleHandle == kNativeModule) + { + if (moduleName != nullptr) + { + *moduleName = "libsynthetic.so"; + } + return true; + } + if (moduleHandle == kNativeModule2) + { + if (moduleName != nullptr) + { + *moduleName = "libnative2.so"; + } + return true; + } + return false; + } + + // Appends a managed frame (HasManagedIdentity true: methodName + token present). + void EmitManagedFrame( + InProcCrashReportFrameCallback frameCallback, + uint64_t ip, + const char* methodName, + const char* className, + uint32_t token, + void* ctx) + { + frameCallback( + ip, /*stackPointer*/ ip + 0x1000, + methodName, className, + /*moduleName*/ "synthetic.managed.dll", /*moduleHandle*/ kManagedModule, + /*moduleTimestamp*/ 0x600dcafe, /*moduleSize*/ 0x00010000, /*moduleGuid*/ &kSyntheticGuid, + /*nativeOffset*/ 0x20, token, /*ilOffset*/ 0x10, ctx); + } + + // Appends a native frame (no managed identity; native_module set). + void EmitNativeFrame( + InProcCrashReportFrameCallback frameCallback, + uint64_t ip, + const char* moduleName, + const void* moduleHandle, + void* ctx) + { + frameCallback( + ip, /*stackPointer*/ ip + 0x1000, + /*methodName*/ nullptr, /*className*/ nullptr, + moduleName, moduleHandle, + /*moduleTimestamp*/ 0x12345678, /*moduleSize*/ 0x00020000, /*moduleGuid*/ &kSyntheticGuid, + /*nativeOffset*/ 0x40, /*token*/ 0, /*ilOffset*/ 0, ctx); + } + + // Three thread records: a mixed crash stack with generic names, a managed-only + // stack, and a native-only stack. No real background threads are created. + void EnumerateThreadsRichSigsegv( + uint64_t crashingTid, + InProcCrashReportThreadCallback threadCallback, + InProcCrashReportFrameCallback frameCallback, + void* ctx) + { + threadCallback(crashingTid, /*isCrashThread*/ true, "System.NullReferenceException", 0x80004003, ctx); + EmitManagedFrame(frameCallback, 0x000000000040aaaa, + "DoWork", "Synthetic.App.Worker`1[System.Int32]", 0x06000001, ctx); + EmitNativeFrame(frameCallback, 0x000000000040bbbb, "libsynthetic.so", kNativeModule, ctx); + EmitManagedFrame(frameCallback, 0x000000000040cccc, + "Insert", "Synthetic.App.Dictionary`2[System.String,System.Int32]", 0x06000002, ctx); + EmitNativeFrame(frameCallback, 0x000000000040dddd, "libnative2.so", kNativeModule2, ctx); + + threadCallback(crashingTid + 1, /*isCrashThread*/ false, nullptr, 0, ctx); + EmitManagedFrame(frameCallback, 0x000000000040eeee, + "Listen", "Synthetic.App.Server", 0x06000003, ctx); + + threadCallback(crashingTid + 2, /*isCrashThread*/ false, nullptr, 0, ctx); + EmitNativeFrame(frameCallback, 0x000000000040ffff, "libsynthetic.so", kNativeModule, ctx); + } + + // Deterministic synthetic register state for the crash thread. + void FillSyntheticContext(SyntheticContext* syntheticContext) + { + memset(syntheticContext, 0, sizeof(*syntheticContext)); + ucontext_t* uc = &syntheticContext->context; +#if defined(TARGET_APPLE) + uc->uc_mcontext = &syntheticContext->machineContext; +#endif +#if defined(TARGET_AMD64) + #if defined(TARGET_APPLE) + uc->uc_mcontext->__ss.__rip = 0x000000000040aaaa; + uc->uc_mcontext->__ss.__rsp = 0x00007fff0000aaaa; + uc->uc_mcontext->__ss.__rbp = 0x00007fff0000aab0; + #elif defined(TARGET_HAIKU) + uc->uc_mcontext.rip = 0x000000000040aaaa; + uc->uc_mcontext.rsp = 0x00007fff0000aaaa; + uc->uc_mcontext.rbp = 0x00007fff0000aab0; + #elif defined(TARGET_OPENBSD) + uc->sc_rip = 0x000000000040aaaa; + uc->sc_rsp = 0x00007fff0000aaaa; + uc->sc_rbp = 0x00007fff0000aab0; + #elif defined(TARGET_FREEBSD) + uc->uc_mcontext.mc_rip = 0x000000000040aaaa; + uc->uc_mcontext.mc_rsp = 0x00007fff0000aaaa; + uc->uc_mcontext.mc_rbp = 0x00007fff0000aab0; + #else + uc->uc_mcontext.gregs[REG_RIP] = 0x000000000040aaaa; + uc->uc_mcontext.gregs[REG_RSP] = 0x00007fff0000aaaa; + uc->uc_mcontext.gregs[REG_RBP] = 0x00007fff0000aab0; + #endif +#elif defined(TARGET_ARM64) + #if defined(TARGET_APPLE) + uc->uc_mcontext->__ss.__pc = 0x000000000040aaaa; + uc->uc_mcontext->__ss.__sp = 0x00007fff0000aaaa; + uc->uc_mcontext->__ss.__fp = 0x00007fff0000aab0; + #elif defined(TARGET_FREEBSD) + uc->uc_mcontext.mc_gpregs.gp_elr = 0x000000000040aaaa; + uc->uc_mcontext.mc_gpregs.gp_sp = 0x00007fff0000aaaa; + uc->uc_mcontext.mc_gpregs.gp_x[29] = 0x00007fff0000aab0; + #else + uc->uc_mcontext.pc = 0x000000000040aaaa; + uc->uc_mcontext.sp = 0x00007fff0000aaaa; + uc->uc_mcontext.regs[29] = 0x00007fff0000aab0; + #endif +#elif defined(TARGET_ARM) + uc->uc_mcontext.arm_pc = 0x0040aaaa; + uc->uc_mcontext.arm_sp = 0x7fffaaaa; + uc->uc_mcontext.arm_fp = 0x7fffaab0; +#elif defined(TARGET_X86) + uc->uc_mcontext.gregs[REG_EIP] = 0x0040aaaa; + uc->uc_mcontext.gregs[REG_ESP] = 0x7fffaaaa; + uc->uc_mcontext.gregs[REG_EBP] = 0x7fffaab0; +#elif defined(TARGET_LOONGARCH64) + uc->uc_mcontext.__pc = 0x000000000040aaaa; + uc->uc_mcontext.__gregs[3] = 0x00007fff0000aaaa; + uc->uc_mcontext.__gregs[22] = 0x00007fff0000aab0; +#elif defined(TARGET_RISCV64) + uc->uc_mcontext.__gregs[0] = 0x000000000040aaaa; + uc->uc_mcontext.__gregs[2] = 0x00007fff0000aaaa; + uc->uc_mcontext.__gregs[8] = 0x00007fff0000aab0; +#elif defined(TARGET_S390X) + uc->uc_mcontext.psw.addr = 0x000000000040aaaa; + uc->uc_mcontext.gregs[15] = 0x00007fff0000aaaa; + uc->uc_mcontext.gregs[11] = 0x00007fff0000aab0; +#elif defined(TARGET_POWERPC64) + uc->uc_mcontext.gp_regs[32] = 0x000000000040aaaa; + uc->uc_mcontext.gp_regs[1] = 0x00007fff0000aaaa; + uc->uc_mcontext.gp_regs[31] = 0x00007fff0000aab0; +#else +#error Unsupported architecture +#endif + } + +#if defined(TARGET_ANDROID) + bool WriteConsoleCapture(const char* consoleCapturePath) + { + const char* console = InProcCrashReportTest_GetConsoleCapture(); + if (!Check(console != nullptr, "Android console capture overflowed")) + { + return false; + } + + FILE* file = fopen(consoleCapturePath, "w"); + if (!CheckIo(file != nullptr, "fopen", consoleCapturePath)) + { + return false; + } + size_t length = strlen(console); + bool written = CheckIo(fwrite(console, 1, length, file) == length, "fwrite", consoleCapturePath); + bool closed = CheckIo(fclose(file) == 0, "fclose", consoleCapturePath); + return written && closed; + } +#else + bool BeginConsoleCapture(const char* consoleCapturePath, int* savedStderr) + { + int capture = open(consoleCapturePath, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (!CheckIo(capture != -1, "open", consoleCapturePath)) + { + return false; + } + + *savedStderr = dup(STDERR_FILENO); + bool redirected = CheckIo(*savedStderr != -1, "dup", "stderr") && + CheckIo(dup2(capture, STDERR_FILENO) != -1, "dup2", consoleCapturePath); + close(capture); + if (!redirected && *savedStderr != -1) + { + close(*savedStderr); + } + + return redirected; + } + + bool EndConsoleCapture(int savedStderr) + { + bool restored = CheckIo(dup2(savedStderr, STDERR_FILENO) != -1, "dup2", "stderr"); + close(savedStderr); + return restored; + } +#endif +} + +// One fatal-shaped scenario per process: the reporter retains its in-flight guard. +extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( + int scenario, + const char* reporterRootPath, + const char* consoleCapturePath) +{ + if (!InitializePal()) + { + return -1; + } + +#if defined(TARGET_ANDROID) + InProcCrashReportTest_ResetConsoleCapture(); +#endif + + InProcCrashReporterSettings settings = {}; + settings.isManagedThreadCallback = &IsManagedThreadCallback; + settings.walkStackCallback = nullptr; + settings.moduleInfoCallback = &ModuleInfoCallback; + settings.frameLimitPerThread = 0; + + int signalNumber = SIGSEGV; + if (!Check(scenario == kScenarioRichSigsegv, "unknown scenario ID")) + { + return -1; + } + settings.enumerateThreadsCallback = &EnumerateThreadsRichSigsegv; + + InProcCrashReportInitialize(settings); + + InitializeServices(reporterRootPath, /*enableLifecycle*/ true); + + SyntheticContext syntheticContext; + FillSyntheticContext(&syntheticContext); + + siginfo_t si; + memset(&si, 0, sizeof(si)); + si.si_signo = signalNumber; + +#if !defined(TARGET_ANDROID) + int savedStderr; + if (!BeginConsoleCapture(consoleCapturePath, &savedStderr)) + { + return -1; + } +#endif + + errno = EDOM; + InProcCrashReportSignalDispatcher(signalNumber, &si, &syntheticContext.context); + bool errnoPreserved = Check(errno == EDOM, "signal dispatcher changed errno"); + +#if defined(TARGET_ANDROID) + bool captured = WriteConsoleCapture(consoleCapturePath); +#else + bool captured = EndConsoleCapture(savedStderr); +#endif + return errnoPreserved && captured ? 0 : -1; +} diff --git a/src/tests/build.sh b/src/tests/build.sh index c01059729fbab9..86507fbcfe95c1 100755 --- a/src/tests/build.sh +++ b/src/tests/build.sh @@ -60,7 +60,7 @@ build_Tests() if [[ "$__SkipNative" != 1 && "$__GenerateLayoutOnly" != 1 && "$__CopyNativeTestBinaries" != 1 && \ "$__TargetOS" != "android" && "$__TargetOS" != "ios" && "$__TargetOS" != "iossimulator" && "$__TargetOS" != "tvos" && "$__TargetOS" != "tvossimulator" ]]; then - build_native "$__TargetOS" "$__TargetArch" "$__TestDir" "$__NativeTestIntermediatesDir" "install" "$__CMakeArgs" "CoreCLR test component" + build_native "$__TargetOS" "$__TargetArch" "$__TestDir" "$__NativeTestIntermediatesDir" "install" "$__CMakeArgs -DCLR_CMAKE_TEST_RUNTIME_FLAVOR=$__RuntimeFlavor -DCLR_CMAKE_TEST_BUILD_MODE=$__TestBuildMode" "CoreCLR test component" if [[ "$?" -ne 0 ]]; then echo "${__ErrMsgPrefix}${__MsgPrefix}Error: native test build failed. Refer to the build log files for details (above)" From ef75bd21e75b1b8788b478ef25cae954200a6971 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Thu, 17 Sep 2026 14:36:58 -0400 Subject: [PATCH 3/8] Add cross-platform Abort and StackOverflow crash reporter tests Extend the shared fixtures and assertions with additional fatal-report shapes: native-only SIGABRT and a supplied compressed stack-overflow trace. Add matching desktop and Android projects and document their output expectations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- ...ulator.InProcCrashReport.Abort.Test.csproj | 12 ++ ...nProcCrashReport.StackOverflow.Test.csproj | 13 ++ .../Abort/InProcCrashReport.Abort.csproj | 7 ++ .../exceptions/inproccrashreport/README.md | 2 + .../inproccrashreport/Shared/Program.cs | 119 ++++++++++++++---- .../Shared/inproccrashreport_test_driver.cpp | 51 +++++++- .../InProcCrashReport.StackOverflow.csproj | 7 ++ 7 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Abort/Android.Device_Emulator.InProcCrashReport.Abort.Test.csproj create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/StackOverflow/Android.Device_Emulator.InProcCrashReport.StackOverflow.Test.csproj create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/Abort/InProcCrashReport.Abort.csproj create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/StackOverflow/InProcCrashReport.StackOverflow.csproj diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Abort/Android.Device_Emulator.InProcCrashReport.Abort.Test.csproj b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Abort/Android.Device_Emulator.InProcCrashReport.Abort.Test.csproj new file mode 100644 index 00000000000000..11627a52e5355b --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/Abort/Android.Device_Emulator.InProcCrashReport.Abort.Test.csproj @@ -0,0 +1,12 @@ + + + + Android.Device_Emulator.InProcCrashReport.Abort.Test + Android.Device_Emulator.InProcCrashReport.Abort.Test.dll + $(DefineConstants);INPROC_SCENARIO_ABORT + + + + diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/StackOverflow/Android.Device_Emulator.InProcCrashReport.StackOverflow.Test.csproj b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/StackOverflow/Android.Device_Emulator.InProcCrashReport.StackOverflow.Test.csproj new file mode 100644 index 00000000000000..2d107c25528625 --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/StackOverflow/Android.Device_Emulator.InProcCrashReport.StackOverflow.Test.csproj @@ -0,0 +1,13 @@ + + + + Android.Device_Emulator.InProcCrashReport.StackOverflow.Test + Android.Device_Emulator.InProcCrashReport.StackOverflow.Test.dll + $(DefineConstants);INPROC_SCENARIO_STACKOVERFLOW + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Abort/InProcCrashReport.Abort.csproj b/src/tests/baseservices/exceptions/inproccrashreport/Abort/InProcCrashReport.Abort.csproj new file mode 100644 index 00000000000000..d380c967858695 --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/Abort/InProcCrashReport.Abort.csproj @@ -0,0 +1,7 @@ + + + $(DefineConstants);INPROC_SCENARIO_ABORT + + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/README.md b/src/tests/baseservices/exceptions/inproccrashreport/README.md index c271a27ab8062a..684ad03f8af9ab 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/README.md +++ b/src/tests/baseservices/exceptions/inproccrashreport/README.md @@ -9,6 +9,8 @@ The same managed validation and native driver run as regular CoreCLR tests on de | Scenario | Expected output | | --- | --- | | `RichSigsegv` | One completed JSON file and a compact report: three thread records, interleaved managed/native frames, generic names, exact registers and frame metadata, exception details, and module associations. | +| `Abort` | One completed JSON file and a compact report: two thread records, `SIGABRT`, a native-only crash stack, and no managed exception fields. | +| `StackOverflow` | One completed JSON file and a compact report: a managed stack-overflow exception, 42 total frames represented by three trace entries, and a recursive entry repeated 40 times. Tests emission, not stack exhaustion or trace compression. | Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. The additional threads are fixed callback records, not real concurrent threads. diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs index 0c8c26739fa61f..54331a7a245d16 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -14,7 +14,11 @@ public static class Program { -#if INPROC_SCENARIO_RICHSIGSEGV +#if INPROC_SCENARIO_ABORT + private const int ScenarioId = 1; +#elif INPROC_SCENARIO_STACKOVERFLOW + private const int ScenarioId = 2; +#elif INPROC_SCENARIO_RICHSIGSEGV private const int ScenarioId = 0; #else #error Define an INPROC_SCENARIO_* symbol for this test. @@ -124,11 +128,11 @@ private static void RunScenario(string outputDirectory) string[] reports = Directory.GetFiles(reportDirectory); Check(reports.Length == 1 && reports[0].EndsWith(".crashreport.json", StringComparison.Ordinal), $"expected one completed report and no temporary files, found: {string.Join(", ", reports)}"); - ValidateJson(reports[0]); - ValidateConsole(consolePath); + ValidateJson(reports[0], ScenarioId); + ValidateConsole(consolePath, ScenarioId); } - private static void ValidateJson(string path) + private static void ValidateJson(string path, int scenario) { Console.WriteLine($"Validating JSON: {Path.GetFileName(path)}"); using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); @@ -138,23 +142,34 @@ private static void ValidateJson(string path) CheckString(payload.GetProperty("configuration"), "architecture", GetArchitecture()); CheckString(payload, "pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); Check(!string.IsNullOrEmpty(payload.GetProperty("process_name").GetString()), "missing process name"); - CheckString(root.GetProperty("parameters"), "signal", "11"); + CheckString(root.GetProperty("parameters"), "signal", scenario == 1 ? "6" : "11"); - JsonElement threads = GetArray(payload, "threads", 3); + JsonElement threads = GetArray(payload, "threads", scenario switch { 1 => 2, 2 => 1, _ => 3 }); ulong crashTid = ReadHex(threads[0], "native_thread_id"); Check(crashTid != 0, "crashing thread ID is zero"); for (int i = 0; i < threads.GetArrayLength(); i++) { CheckString(threads[i], "crashed", i == 0 ? "true" : "false"); CheckHex(threads[i], "native_thread_id", crashTid + (ulong)i); - if (i != 0) + if (i != 0 || scenario == 1) { CheckAbsent(threads[i], "managed_exception_type"); CheckAbsent(threads[i], "managed_exception_hresult"); } } - ValidateRichJson(threads); + switch (scenario) + { + case 1: + ValidateAbortJson(threads); + break; + case 2: + ValidateStackOverflowJson(threads[0]); + break; + default: + ValidateRichJson(threads); + break; + } } private static void ValidateRichJson(JsonElement threads) @@ -173,7 +188,42 @@ private static void ValidateRichJson(JsonElement threads) CheckNativeFrame(GetArray(threads[2], "stack_frames", 1)[0], 0x40ffff, "libsynthetic.so"); } - private static void ValidateConsole(string path) + private static void ValidateAbortJson(JsonElement threads) + { + CheckRegisters(threads[0]); + JsonElement frames = GetArray(threads[0], "stack_frames", 3); + CheckContextFrame(frames[0]); + CheckNativeFrame(frames[1], 0x40aaaa, "libsynthetic.so"); + CheckNativeFrame(frames[2], 0x40bbbb, "libnative2.so"); + CheckManagedFrame(GetArray(threads[1], "stack_frames", 1)[0], 0x40cccc, "Synthetic.App.Server.Listen", 0x06000001); + } + + private static void ValidateStackOverflowJson(JsonElement crashed) + { + CheckString(crashed, "is_managed", "true"); + CheckString(crashed, "managed_exception_type", "System.StackOverflowException"); + CheckString(crashed, "managed_exception_hresult", "0x800703e9"); + CheckString(crashed, "stack_overflow_total_frames", "42"); + CheckAbsent(crashed, "stack_frames_unavailable_reason"); + CheckAbsent(crashed, "stack_overflow_trace_truncated_frames"); + JsonElement frames = GetArray(crashed, "stack_frames", 3); + CheckString(frames[0], "method_name", "Synthetic.App.Program.Main"); + CheckString(frames[1], "method_name", "Synthetic.App.Recurse.Down"); + CheckString(frames[1], "stack_overflow_repeat_count", "40"); + CheckString(frames[1], "stack_overflow_repeat_sequence_length", "1"); + CheckString(frames[2], "method_name", "Synthetic.App.Recurse.Bottom"); + foreach (JsonElement frame in frames.EnumerateArray()) + { + CheckString(frame, "is_managed", "true"); + } + foreach (int index in new[] { 0, 2 }) + { + CheckAbsent(frames[index], "stack_overflow_repeat_count"); + CheckAbsent(frames[index], "stack_overflow_repeat_sequence_length"); + } + } + + private static void ValidateConsole(string path, int scenario) { Console.WriteLine($"Validating compact report: {Path.GetFileName(path)}"); string console = File.ReadAllText(path); @@ -182,22 +232,41 @@ private static void ValidateConsole(string path) Check(lines.Count(line => line == ".NET Crash Report v1.0.0") == 1, "expected one protocol header"); Check(lines.Contains($"ABI: {GetArchitecture()}"), "incorrect ABI"); Check(lines.Count(line => line.StartsWith("signal ", StringComparison.Ordinal)) == 1, "expected one signal line"); - Check(lines.Contains("signal 11 (SIGSEGV)"), "incorrect signal"); + Check(lines.Contains($"signal {(scenario == 1 ? "6 (SIGABRT)" : "11 (SIGSEGV)")}"), "incorrect signal"); Check(!lines.Any(line => line == "(no managed frames)" || line.StartsWith("... +", StringComparison.Ordinal)), "compact report unexpectedly omitted frames"); - string[][] expectedThreads = - [ + string[][] expectedThreads = scenario switch + { + 1 => [ - "managed exception: System.NullReferenceException (0x80004003)", - "#00 [0] Synthetic.App.Worker`1[System.Int32].DoWork + 0x10 (token=0x6000001)", - "#01 [1] 0x40bbbb (libsynthetic.so + 0x40)", - "#02 [0] Synthetic.App.Dictionary`2[System.String,System.Int32].Insert + 0x10 (token=0x6000002)", - "#03 [2] 0x40dddd (libnative2.so + 0x40)", + ["#00 [0] 0x40aaaa (libsynthetic.so + 0x40)", "#01 [1] 0x40bbbb (libnative2.so + 0x40)"], + ["#00 [2] Synthetic.App.Server.Listen + 0x10 (token=0x6000001)"], ], - ["#00 [0] Synthetic.App.Server.Listen + 0x10 (token=0x6000003)"], - ["#00 [1] 0x40ffff (libsynthetic.so + 0x40)"], - ]; + 2 => + [ + [ + "managed exception: System.StackOverflowException (0x800703e9)", + "stack overflow frames: 42", + "#00 Synthetic.App.Program.Main", + "repeated 40 times:", + "#01 Synthetic.App.Recurse.Down", + "#02 Synthetic.App.Recurse.Bottom", + ], + ], + _ => + [ + [ + "managed exception: System.NullReferenceException (0x80004003)", + "#00 [0] Synthetic.App.Worker`1[System.Int32].DoWork + 0x10 (token=0x6000001)", + "#01 [1] 0x40bbbb (libsynthetic.so + 0x40)", + "#02 [0] Synthetic.App.Dictionary`2[System.String,System.Int32].Insert + 0x10 (token=0x6000002)", + "#03 [2] 0x40dddd (libnative2.so + 0x40)", + ], + ["#00 [0] Synthetic.App.Server.Listen + 0x10 (token=0x6000003)"], + ["#00 [1] 0x40ffff (libsynthetic.so + 0x40)"], + ], + }; string[] blocks = console.Split("--- thread ", StringSplitOptions.None); Check(blocks.Length == expectedThreads.Length + 1, $"expected {expectedThreads.Length} console threads, got {blocks.Length - 1}"); @@ -206,11 +275,17 @@ private static void ValidateConsole(string path) string[] blockLines = blocks[i + 1].Split('\n').Select(line => line.Trim()).ToArray(); Check(blockLines[0].Contains("(crashed)", StringComparison.Ordinal) == (i == 0), $"thread {i}: incorrect crashed marker"); string[] actual = blockLines.Skip(1).Where(line => - line.StartsWith('#') || line.StartsWith("managed exception:", StringComparison.Ordinal)).ToArray(); + line.StartsWith('#') || line.StartsWith("managed exception:", StringComparison.Ordinal) || + line.StartsWith("stack overflow ", StringComparison.Ordinal) || line.StartsWith("repeated ", StringComparison.Ordinal)).ToArray(); Check(actual.SequenceEqual(expectedThreads[i]), $"thread {i}: expected compact lines:\n{string.Join("\n", expectedThreads[i])}\nactual:\n{string.Join("\n", actual)}"); } - string[] modules = ["synthetic.managed.dll", "libsynthetic.so", "libnative2.so"]; + string[] modules = scenario switch + { + 1 => ["libsynthetic.so", "libnative2.so", "synthetic.managed.dll"], + 2 => [], + _ => ["synthetic.managed.dll", "libsynthetic.so", "libnative2.so"], + }; string[] actualModules = lines.Where(line => line.StartsWith('[')).ToArray(); string[] expectedModules = modules.Select((module, index) => $"[{index}] {module} {ModuleGuid}").ToArray(); Check(actualModules.SequenceEqual(expectedModules), "compact module table mismatch"); diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp index 1d7230011d7f67..5e3a625cc94bfb 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp @@ -35,6 +35,8 @@ namespace { // Scenario ids -- must match the managed harness (Program.cs). const int kScenarioRichSigsegv = 0; + const int kScenarioAbort = 1; + const int kScenarioStackOverflow = 2; // Synthetic module handles, resolved by ModuleInfoCallback below. const void* const kManagedModule = reinterpret_cast(0x1000); @@ -193,6 +195,23 @@ namespace EmitNativeFrame(frameCallback, 0x000000000040ffff, "libsynthetic.so", kNativeModule, ctx); } + // A native-only crash stack without a managed exception, plus a second record + // with a managed frame, exercises SIGABRT and the null-exception path. + void EnumerateThreadsAbort( + uint64_t crashingTid, + InProcCrashReportThreadCallback threadCallback, + InProcCrashReportFrameCallback frameCallback, + void* ctx) + { + threadCallback(crashingTid, /*isCrashThread*/ true, /*exceptionType*/ nullptr, 0, ctx); + EmitNativeFrame(frameCallback, 0x000000000040aaaa, "libsynthetic.so", kNativeModule, ctx); + EmitNativeFrame(frameCallback, 0x000000000040bbbb, "libnative2.so", kNativeModule2, ctx); + + threadCallback(crashingTid + 1, /*isCrashThread*/ false, nullptr, 0, ctx); + EmitManagedFrame(frameCallback, 0x000000000040cccc, + "Listen", "Synthetic.App.Server", 0x06000001, ctx); + } + // Deterministic synthetic register state for the crash thread. void FillSyntheticContext(SyntheticContext* syntheticContext) { @@ -337,16 +356,42 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( settings.frameLimitPerThread = 0; int signalNumber = SIGSEGV; - if (!Check(scenario == kScenarioRichSigsegv, "unknown scenario ID")) + switch (scenario) { - return -1; + case kScenarioRichSigsegv: + signalNumber = SIGSEGV; + settings.enumerateThreadsCallback = &EnumerateThreadsRichSigsegv; + break; + case kScenarioAbort: + signalNumber = SIGABRT; + settings.enumerateThreadsCallback = &EnumerateThreadsAbort; + break; + case kScenarioStackOverflow: + signalNumber = SIGSEGV; + settings.enumerateThreadsCallback = nullptr; // SO path does not enumerate threads + break; + default: + Check(false, "unknown scenario ID"); + return -1; } - settings.enumerateThreadsCallback = &EnumerateThreadsRichSigsegv; InProcCrashReportInitialize(settings); InitializeServices(reporterRootPath, /*enableLifecycle*/ true); + if (scenario == kScenarioStackOverflow) + { + // Drive the captured-stack-overflow-trace path: the runtime SO helper + // would have recorded a compressed managed stack (with a repeated + // recursive sequence) for the reporter to emit later. + InProcCrashReportSetCrashKind(InProcCrashReportCrashKind::StackOverflow); + InProcCrashReportBeginStackOverflowTrace(/*crashingTid*/ 0, /*totalFrameCount*/ 42); + InProcCrashReportAddStackOverflowTraceFrame("Synthetic.App.Program.Main", 1, 0); + InProcCrashReportAddStackOverflowTraceFrame("Synthetic.App.Recurse.Down", 40, 1); + InProcCrashReportAddStackOverflowTraceFrame("Synthetic.App.Recurse.Bottom", 1, 0); + InProcCrashReportEndStackOverflowTrace(); + } + SyntheticContext syntheticContext; FillSyntheticContext(&syntheticContext); diff --git a/src/tests/baseservices/exceptions/inproccrashreport/StackOverflow/InProcCrashReport.StackOverflow.csproj b/src/tests/baseservices/exceptions/inproccrashreport/StackOverflow/InProcCrashReport.StackOverflow.csproj new file mode 100644 index 00000000000000..f74aeabafc2f72 --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/StackOverflow/InProcCrashReport.StackOverflow.csproj @@ -0,0 +1,7 @@ + + + $(DefineConstants);INPROC_SCENARIO_STACKOVERFLOW + + + + From a9d0b98e6849f612a0f0fad610b4077f6ddde3f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Thu, 17 Sep 2026 14:36:59 -0400 Subject: [PATCH 4/8] Add cross-platform console-only crash reporter tests Reuse the rich fixture to verify compact output remains available when lifecycle file output is disabled, without creating a report directory. Add matching desktop and Android projects and document the output expectation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- ...ce_Emulator.InProcCrashReport.ConsoleOnly.Test.csproj | 9 +++++++++ .../ConsoleOnly/InProcCrashReport.ConsoleOnly.csproj | 7 +++++++ .../baseservices/exceptions/inproccrashreport/README.md | 1 + .../exceptions/inproccrashreport/Shared/Program.cs | 6 ++++++ .../Shared/inproccrashreport_test_driver.cpp | 4 +++- 5 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/ConsoleOnly/Android.Device_Emulator.InProcCrashReport.ConsoleOnly.Test.csproj create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/ConsoleOnly/InProcCrashReport.ConsoleOnly.csproj diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/ConsoleOnly/Android.Device_Emulator.InProcCrashReport.ConsoleOnly.Test.csproj b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/ConsoleOnly/Android.Device_Emulator.InProcCrashReport.ConsoleOnly.Test.csproj new file mode 100644 index 00000000000000..af718a3fb49120 --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/ConsoleOnly/Android.Device_Emulator.InProcCrashReport.ConsoleOnly.Test.csproj @@ -0,0 +1,9 @@ + + + + Android.Device_Emulator.InProcCrashReport.ConsoleOnly.Test + Android.Device_Emulator.InProcCrashReport.ConsoleOnly.Test.dll + $(DefineConstants);INPROC_SCENARIO_CONSOLEONLY + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/ConsoleOnly/InProcCrashReport.ConsoleOnly.csproj b/src/tests/baseservices/exceptions/inproccrashreport/ConsoleOnly/InProcCrashReport.ConsoleOnly.csproj new file mode 100644 index 00000000000000..8caf88278063ee --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/ConsoleOnly/InProcCrashReport.ConsoleOnly.csproj @@ -0,0 +1,7 @@ + + + $(DefineConstants);INPROC_SCENARIO_CONSOLEONLY + + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/README.md b/src/tests/baseservices/exceptions/inproccrashreport/README.md index 684ad03f8af9ab..6de4c1e723ebfb 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/README.md +++ b/src/tests/baseservices/exceptions/inproccrashreport/README.md @@ -11,6 +11,7 @@ The same managed validation and native driver run as regular CoreCLR tests on de | `RichSigsegv` | One completed JSON file and a compact report: three thread records, interleaved managed/native frames, generic names, exact registers and frame metadata, exception details, and module associations. | | `Abort` | One completed JSON file and a compact report: two thread records, `SIGABRT`, a native-only crash stack, and no managed exception fields. | | `StackOverflow` | One completed JSON file and a compact report: a managed stack-overflow exception, 42 total frames represented by three trace entries, and a recursive entry repeated 40 times. Tests emission, not stack exhaustion or trace compression. | +| `ConsoleOnly` | The rich compact report, without even creating the lifecycle report directory despite being given a valid root. | Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. The additional threads are fixed callback records, not real concurrent threads. diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs index 54331a7a245d16..2d448d7c1a32b7 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -18,6 +18,8 @@ public static class Program private const int ScenarioId = 1; #elif INPROC_SCENARIO_STACKOVERFLOW private const int ScenarioId = 2; +#elif INPROC_SCENARIO_CONSOLEONLY + private const int ScenarioId = 3; #elif INPROC_SCENARIO_RICHSIGSEGV private const int ScenarioId = 0; #else @@ -125,10 +127,14 @@ private static void RunScenario(string outputDirectory) Check(result == 0, "native driver failed; see its diagnostics"); string reportDirectory = Path.Combine(outputDirectory, ".dotnet", "crash-reports"); +#if INPROC_SCENARIO_CONSOLEONLY + Check(!Directory.Exists(reportDirectory), "disabled lifecycle unexpectedly created a report directory"); +#else string[] reports = Directory.GetFiles(reportDirectory); Check(reports.Length == 1 && reports[0].EndsWith(".crashreport.json", StringComparison.Ordinal), $"expected one completed report and no temporary files, found: {string.Join(", ", reports)}"); ValidateJson(reports[0], ScenarioId); +#endif ValidateConsole(consolePath, ScenarioId); } diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp index 5e3a625cc94bfb..8c5c4258a8557b 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp @@ -37,6 +37,7 @@ namespace const int kScenarioRichSigsegv = 0; const int kScenarioAbort = 1; const int kScenarioStackOverflow = 2; + const int kScenarioConsoleOnly = 3; // Synthetic module handles, resolved by ModuleInfoCallback below. const void* const kManagedModule = reinterpret_cast(0x1000); @@ -359,6 +360,7 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( switch (scenario) { case kScenarioRichSigsegv: + case kScenarioConsoleOnly: signalNumber = SIGSEGV; settings.enumerateThreadsCallback = &EnumerateThreadsRichSigsegv; break; @@ -377,7 +379,7 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( InProcCrashReportInitialize(settings); - InitializeServices(reporterRootPath, /*enableLifecycle*/ true); + InitializeServices(reporterRootPath, scenario != kScenarioConsoleOnly); if (scenario == kScenarioStackOverflow) { From a581fba6bbedfb961704076168b97f2a68e68284 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Thu, 17 Sep 2026 13:51:18 -0400 Subject: [PATCH 5/8] Add cross-platform on-demand crash reporter tests Reuse the shared rich assertions for repeated caller-sink reports with changing signals. Cover null and nested requests, failed sinks and subsequent recovery, generation without services, and isolation from an enabled lifecycle sink. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- ...tor.InProcCrashReport.OnDemand.Test.csproj | 10 ++ .../InProcCrashReport.OnDemand.csproj | 7 + .../exceptions/inproccrashreport/README.md | 1 + .../inproccrashreport/Shared/Program.cs | 46 ++++++- .../Shared/inproccrashreport_test_driver.cpp | 130 ++++++++++++++++++ 5 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/OnDemand/Android.Device_Emulator.InProcCrashReport.OnDemand.Test.csproj create mode 100644 src/tests/baseservices/exceptions/inproccrashreport/OnDemand/InProcCrashReport.OnDemand.csproj diff --git a/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/OnDemand/Android.Device_Emulator.InProcCrashReport.OnDemand.Test.csproj b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/OnDemand/Android.Device_Emulator.InProcCrashReport.OnDemand.Test.csproj new file mode 100644 index 00000000000000..3fff22f924a0b4 --- /dev/null +++ b/src/tests/FunctionalTests/Android/Device_Emulator/InProcCrashReport/OnDemand/Android.Device_Emulator.InProcCrashReport.OnDemand.Test.csproj @@ -0,0 +1,10 @@ + + + + Android.Device_Emulator.InProcCrashReport.OnDemand.Test + Android.Device_Emulator.InProcCrashReport.OnDemand.Test.dll + $(DefineConstants);INPROC_SCENARIO_ONDEMAND + + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/OnDemand/InProcCrashReport.OnDemand.csproj b/src/tests/baseservices/exceptions/inproccrashreport/OnDemand/InProcCrashReport.OnDemand.csproj new file mode 100644 index 00000000000000..01668130adeb8f --- /dev/null +++ b/src/tests/baseservices/exceptions/inproccrashreport/OnDemand/InProcCrashReport.OnDemand.csproj @@ -0,0 +1,7 @@ + + + $(DefineConstants);INPROC_SCENARIO_ONDEMAND + + + + diff --git a/src/tests/baseservices/exceptions/inproccrashreport/README.md b/src/tests/baseservices/exceptions/inproccrashreport/README.md index 6de4c1e723ebfb..8f6d0d2f1cd09b 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/README.md +++ b/src/tests/baseservices/exceptions/inproccrashreport/README.md @@ -12,6 +12,7 @@ The same managed validation and native driver run as regular CoreCLR tests on de | `Abort` | One completed JSON file and a compact report: two thread records, `SIGABRT`, a native-only crash stack, and no managed exception fields. | | `StackOverflow` | One completed JSON file and a compact report: a managed stack-overflow exception, 42 total frames represented by three trace entries, and a recursive entry repeated 40 times. Tests emission, not stack exhaustion or trace compression. | | `ConsoleOnly` | The rich compact report, without even creating the lifecycle report directory despite being given a valid root. | +| `OnDemand` | Two JSON and two compact reports with changing signals, using the same rich assertions. Null and nested callbacks are rejected; failed sinks stop receiving writes and later requests succeed. The first report needs no services; later requests leave an enabled lifecycle directory empty. | Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. The additional threads are fixed callback records, not real concurrent threads. diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs index 2d448d7c1a32b7..6c0778013dc1e8 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -20,7 +20,7 @@ public static class Program private const int ScenarioId = 2; #elif INPROC_SCENARIO_CONSOLEONLY private const int ScenarioId = 3; -#elif INPROC_SCENARIO_RICHSIGSEGV +#elif INPROC_SCENARIO_RICHSIGSEGV || INPROC_SCENARIO_ONDEMAND private const int ScenarioId = 0; #else #error Define an INPROC_SCENARIO_* symbol for this test. @@ -39,6 +39,13 @@ public static class Program private static extern int InProcCrashReportTest_DriveScenario( int scenario, string reportRootPath, string consoleCapturePath); +#if INPROC_SCENARIO_ONDEMAND + [DllImport(NativeLib)] + private static extern int InProcCrashReportTest_DriveOnDemand( + string reportRootPath, string firstJsonPath, string secondJsonPath, + string firstLogPath, string secondLogPath); +#endif + #if INPROC_ANDROID public static int Main() #else @@ -46,7 +53,11 @@ public static int Main() public static int TestEntryPoint() #endif { +#if INPROC_SCENARIO_ONDEMAND + return RunTest(RunOnDemand); +#else return RunTest(RunScenario); +#endif } private static int RunTest(Action scenario) @@ -138,7 +149,32 @@ private static void RunScenario(string outputDirectory) ValidateConsole(consolePath, ScenarioId); } - private static void ValidateJson(string path, int scenario) +#if INPROC_SCENARIO_ONDEMAND + private static void RunOnDemand(string outputDirectory) + { + string firstJson = Path.Combine(outputDirectory, "first.json"); + string secondJson = Path.Combine(outputDirectory, "second.json"); + string firstLog = Path.Combine(outputDirectory, "first.log"); + string secondLog = Path.Combine(outputDirectory, "second.log"); + Stopwatch timer = Stopwatch.StartNew(); + int result = InProcCrashReportTest_DriveOnDemand(outputDirectory, firstJson, secondJson, firstLog, secondLog); + Console.WriteLine($"Native on-demand driver returned {result} in {timer.ElapsedMilliseconds} ms"); + Check(result == 0, "native on-demand driver failed; see its diagnostics"); + + // Both requests use the rich fixture; the changed signal detects stale output. + ValidateJson(firstJson, 0); + ValidateConsole(firstLog, 0); + ValidateJson(secondJson, 0, signal: "6"); + ValidateConsole(secondLog, 0, signal: "6 (SIGABRT)"); + + string reportDirectory = Path.Combine(outputDirectory, ".dotnet", "crash-reports"); + Check(Directory.Exists(reportDirectory), "native driver did not initialize lifecycle services"); + Check(!Directory.EnumerateFileSystemEntries(reportDirectory).Any(), + "on-demand requests unexpectedly changed the lifecycle report directory"); + } +#endif + + private static void ValidateJson(string path, int scenario, string? signal = null) { Console.WriteLine($"Validating JSON: {Path.GetFileName(path)}"); using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); @@ -148,7 +184,7 @@ private static void ValidateJson(string path, int scenario) CheckString(payload.GetProperty("configuration"), "architecture", GetArchitecture()); CheckString(payload, "pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); Check(!string.IsNullOrEmpty(payload.GetProperty("process_name").GetString()), "missing process name"); - CheckString(root.GetProperty("parameters"), "signal", scenario == 1 ? "6" : "11"); + CheckString(root.GetProperty("parameters"), "signal", signal ?? (scenario == 1 ? "6" : "11")); JsonElement threads = GetArray(payload, "threads", scenario switch { 1 => 2, 2 => 1, _ => 3 }); ulong crashTid = ReadHex(threads[0], "native_thread_id"); @@ -229,7 +265,7 @@ private static void ValidateStackOverflowJson(JsonElement crashed) } } - private static void ValidateConsole(string path, int scenario) + private static void ValidateConsole(string path, int scenario, string? signal = null) { Console.WriteLine($"Validating compact report: {Path.GetFileName(path)}"); string console = File.ReadAllText(path); @@ -238,7 +274,7 @@ private static void ValidateConsole(string path, int scenario) Check(lines.Count(line => line == ".NET Crash Report v1.0.0") == 1, "expected one protocol header"); Check(lines.Contains($"ABI: {GetArchitecture()}"), "incorrect ABI"); Check(lines.Count(line => line.StartsWith("signal ", StringComparison.Ordinal)) == 1, "expected one signal line"); - Check(lines.Contains($"signal {(scenario == 1 ? "6 (SIGABRT)" : "11 (SIGSEGV)")}"), "incorrect signal"); + Check(lines.Contains($"signal {signal ?? (scenario == 1 ? "6 (SIGABRT)" : "11 (SIGSEGV)")}"), "incorrect signal"); Check(!lines.Any(line => line == "(no managed frames)" || line.StartsWith("... +", StringComparison.Ordinal)), "compact report unexpectedly omitted frames"); diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp index 8c5c4258a8557b..5c8333d0ad31c8 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp @@ -333,6 +333,73 @@ namespace return restored; } #endif + + struct OnDemandOutputContext + { + FILE* file; + const char* path; + ucontext_t* signalContext; + bool attemptReentrantReport; + bool reentrantAttempted; + bool reentrantResult; + }; + + bool WriteOnDemandOutput(const char* buffer, size_t length, void* context) + { + OnDemandOutputContext* output = static_cast(context); + if (output->attemptReentrantReport && !output->reentrantAttempted) + { + output->reentrantAttempted = true; + output->reentrantResult = InProcCrashReportCreateReport( + InProcCrashReportOutputFormat::Json, + SIGSEGV, + output->signalContext, + &WriteOnDemandOutput, + output); + } + + return CheckIo(fwrite(buffer, 1, length, output->file) == length, "fwrite", output->path); + } + + bool RejectOutput(const char* /*buffer*/, size_t /*length*/, void* context) + { + (*static_cast(context))++; + return false; + } + + bool WriteOnDemandReport( + InProcCrashReportOutputFormat outputFormat, + int signal, + const char* outputPath, + ucontext_t* signalContext, + bool attemptReentrantReport = false) + { + printf("Generating on-demand format %u, signal %d: %s\n", static_cast(outputFormat), signal, outputPath); + fflush(stdout); + FILE* file = fopen(outputPath, "wb"); + if (!CheckIo(file != nullptr, "fopen", outputPath)) + { + return false; + } + + OnDemandOutputContext output = {}; + output.file = file; + output.path = outputPath; + output.signalContext = signalContext; + output.attemptReentrantReport = attemptReentrantReport; + + bool generated = InProcCrashReportCreateReport( + outputFormat, + signal, + signalContext, + &WriteOnDemandOutput, + &output); + + bool closed = CheckIo(fclose(file) == 0, "fclose", outputPath); + return Check(generated, "on-demand generation returned false") && closed && + Check(!attemptReentrantReport || (output.reentrantAttempted && !output.reentrantResult), + "nested request was not attempted or was incorrectly accepted"); + } } // One fatal-shaped scenario per process: the reporter retains its in-flight guard. @@ -420,3 +487,66 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( #endif return errnoPreserved && captured ? 0 : -1; } + +// First generate without services; subsequent requests must not use the enabled +// lifecycle file sink, even after a caller-sink failure. +extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveOnDemand( + const char* reportRootPath, + const char* firstJsonPath, + const char* secondJsonPath, + const char* firstLogPath, + const char* secondLogPath) +{ + if (!InitializePal()) + { + return -1; + } + + InProcCrashReporterSettings settings = {}; + settings.isManagedThreadCallback = &IsManagedThreadCallback; + settings.walkStackCallback = nullptr; + settings.enumerateThreadsCallback = &EnumerateThreadsRichSigsegv; + settings.moduleInfoCallback = &ModuleInfoCallback; + settings.frameLimitPerThread = 0; + InProcCrashReportInitialize(settings); + + SyntheticContext syntheticContext; + FillSyntheticContext(&syntheticContext); + ucontext_t* signalContext = &syntheticContext.context; + + if (!Check(!InProcCrashReportCreateReport( + InProcCrashReportOutputFormat::Json, + SIGSEGV, + signalContext, + nullptr, + nullptr), "null output callback was accepted")) + { + return -1; + } + + if (!WriteOnDemandReport( + InProcCrashReportOutputFormat::Json, + SIGSEGV, + firstJsonPath, + signalContext, + /*attemptReentrantReport*/ true)) + { + return -1; + } + + InitializeServices(reportRootPath, /*enableLifecycle*/ true); + const InProcCrashReportOutputFormat formats[] = { InProcCrashReportOutputFormat::Json, InProcCrashReportOutputFormat::Log }; + for (InProcCrashReportOutputFormat format : formats) + { + int calls = 0; + bool generated = InProcCrashReportCreateReport(format, SIGSEGV, signalContext, &RejectOutput, &calls); + if (!Check(!generated && calls == 1, "failing output callback was ignored or invoked again after failure")) + { + return -1; + } + } + + return WriteOnDemandReport(InProcCrashReportOutputFormat::Json, SIGABRT, secondJsonPath, signalContext) && + WriteOnDemandReport(InProcCrashReportOutputFormat::Log, SIGSEGV, firstLogPath, signalContext) && + WriteOnDemandReport(InProcCrashReportOutputFormat::Log, SIGABRT, secondLogPath, signalContext) ? 0 : -1; +} From 34cb6de5da8e25ac70b25570d06d4e283c4968f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Fri, 18 Sep 2026 16:14:51 -0400 Subject: [PATCH 6/8] Test concurrent on-demand crash report requests Hold JSON and compact-report owners in an output callback while independent threads request both formats. Require immediate rejection without callbacks, preserve owner output, and verify recovery after owner success or sink failure. Use bounded handshakes and the production reporter without product changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- .../exceptions/inproccrashreport/README.md | 6 +- .../inproccrashreport/Shared/Program.cs | 133 ++++++++++++++++++ .../Shared/inproccrashreport_test_driver.cpp | 49 ++++++- 3 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/tests/baseservices/exceptions/inproccrashreport/README.md b/src/tests/baseservices/exceptions/inproccrashreport/README.md index 8f6d0d2f1cd09b..dd3ec4dce22ceb 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/README.md +++ b/src/tests/baseservices/exceptions/inproccrashreport/README.md @@ -12,9 +12,11 @@ The same managed validation and native driver run as regular CoreCLR tests on de | `Abort` | One completed JSON file and a compact report: two thread records, `SIGABRT`, a native-only crash stack, and no managed exception fields. | | `StackOverflow` | One completed JSON file and a compact report: a managed stack-overflow exception, 42 total frames represented by three trace entries, and a recursive entry repeated 40 times. Tests emission, not stack exhaustion or trace compression. | | `ConsoleOnly` | The rich compact report, without even creating the lifecycle report directory despite being given a valid root. | -| `OnDemand` | Two JSON and two compact reports with changing signals, using the same rich assertions. Null and nested callbacks are rejected; failed sinks stop receiving writes and later requests succeed. The first report needs no services; later requests leave an enabled lifecycle directory empty. | +| `OnDemand` | Sequential and concurrent JSON/compact requests with changing signals, using the same rich assertions. Null, nested, and overlapping requests are rejected; failed sinks stop receiving writes and later requests succeed. The first report needs no services; later requests leave an enabled lifecycle directory empty. | -Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. The additional threads are fixed callback records, not real concurrent threads. +Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. Thread records in the reports are synthetic; the on-demand concurrency checks use real threads to issue competing requests. + +The concurrency checks hold an on-demand request inside its first output callback while JSON and compact requests run on two other threads. Both contenders must return false without invoking their output callbacks or writing bytes, before the owner is released. Four cases cover JSON/compact owners that either complete or fail their sink. Successful owner output and subsequent requests are validated to detect writer-state corruption or an unreleased guard. Coordination uses explicit handshakes and bounded waits, not timing sleeps. These checks exercise the reporter's admission guard, not PAL's separate fatal-signal gate. Each invocation owns a unique output directory. Successful runs delete it; failed runs print its location and retain the files, including incomplete reports. Bounded report contents and native I/O diagnostics also appear in the test log. diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs index 6c0778013dc1e8..b093c8a0c3b0a0 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +#if INPROC_SCENARIO_ONDEMAND +using System.Collections.Generic; +#endif using System.Diagnostics; using System.Globalization; using System.IO; @@ -11,6 +14,10 @@ using System.Linq; using System.Runtime.InteropServices; using System.Text.Json; +#if INPROC_SCENARIO_ONDEMAND +using System.Threading; +using System.Threading.Tasks; +#endif public static class Program { @@ -40,10 +47,26 @@ private static extern int InProcCrashReportTest_DriveScenario( int scenario, string reportRootPath, string consoleCapturePath); #if INPROC_SCENARIO_ONDEMAND + private static readonly TimeSpan s_concurrencyTimeout = TimeSpan.FromSeconds(60); + + // Matches InProcCrashReportOutputFormat in the native reporter. + private enum ReportFormat : uint + { + Json = 0, + Log = 1, + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int BeforeWriteCallback(); + [DllImport(NativeLib)] private static extern int InProcCrashReportTest_DriveOnDemand( string reportRootPath, string firstJsonPath, string secondJsonPath, string firstLogPath, string secondLogPath); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl)] + private static extern int InProcCrashReportTest_CreateOnDemandReport( + ReportFormat format, int signal, string outputPath, BeforeWriteCallback beforeWrite); #endif #if INPROC_ANDROID @@ -167,11 +190,121 @@ private static void RunOnDemand(string outputDirectory) ValidateJson(secondJson, 0, signal: "6"); ValidateConsole(secondLog, 0, signal: "6 (SIGABRT)"); + foreach (ReportFormat format in new[] { ReportFormat.Json, ReportFormat.Log }) + { + RunConcurrentOnDemand(outputDirectory, format, failOwner: false); + RunConcurrentOnDemand(outputDirectory, format, failOwner: true); + } + string reportDirectory = Path.Combine(outputDirectory, ".dotnet", "crash-reports"); Check(Directory.Exists(reportDirectory), "native driver did not initialize lifecycle services"); Check(!Directory.EnumerateFileSystemEntries(reportDirectory).Any(), "on-demand requests unexpectedly changed the lifecycle report directory"); } + + private static void RunConcurrentOnDemand(string outputDirectory, ReportFormat ownerFormat, bool failOwner) + { + const int OwnerSignal = 11; + const int OtherSignal = 6; + string caseName = $"concurrent-{ownerFormat}-{(failOwner ? "failure" : "success")}"; + string caseDirectory = Directory.CreateDirectory(Path.Combine(outputDirectory, caseName)).FullName; + string ownerPath = Path.Combine(caseDirectory, $"owner.{ownerFormat}"); + Console.WriteLine($"Starting {caseName}: hold one owner while JSON and log contenders request reports"); + + TaskCompletionSource ownerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseOwner = new(TaskCreationOptions.RunContinuationsAsynchronously); + int ownerWrites = 0; + int ownerTimedOut = 0; + Task owner = StartOnDemandRequest(ownerFormat, OwnerSignal, ownerPath, () => + { + if (Interlocked.Increment(ref ownerWrites) == 1) + { + // Keep the reporter's guard occupied until both contenders have returned. + ownerEntered.SetResult(); + if (!releaseOwner.Task.Wait(s_concurrencyTimeout)) + { + Interlocked.Exchange(ref ownerTimedOut, 1); + return 0; + } + } + + return failOwner ? 0 : 1; + }); + + ReportFormat[] formats = [ReportFormat.Json, ReportFormat.Log]; + int[] contenderWrites = new int[formats.Length]; + List> contenders = new(formats.Length); + try + { + Check(ownerEntered.Task.Wait(s_concurrencyTimeout), $"{caseName}: owner never entered its output callback"); + for (int i = 0; i < formats.Length; i++) + { + int index = i; + contenders.Add(StartOnDemandRequest(formats[index], OtherSignal, + Path.Combine(caseDirectory, $"contender.{formats[index]}"), () => + { + Interlocked.Increment(ref contenderWrites[index]); + return 0; + })); + } + + Check(Task.WhenAll(contenders).Wait(s_concurrencyTimeout), + $"{caseName}: contenders waited for the owner instead of rejecting overlap"); + for (int i = 0; i < contenders.Count; i++) + { + Check(contenders[i].Result == 0, $"{caseName}: {formats[i]} contender returned {contenders[i].Result}, expected rejection"); + Check(contenderWrites[i] == 0, $"{caseName}: rejected {formats[i]} contender invoked its output callback"); + Check(new FileInfo(Path.Combine(caseDirectory, $"contender.{formats[i]}")).Length == 0, + $"{caseName}: rejected {formats[i]} contender wrote output"); + } + + Check(!owner.IsCompleted, $"{caseName}: owner completed before being released"); + } + finally + { + releaseOwner.TrySetResult(); + Check(Task.WhenAll(contenders.Append(owner)).Wait(s_concurrencyTimeout), + $"{caseName}: reporting tasks did not finish after releasing the owner"); + } + + Check(ownerTimedOut == 0, $"{caseName}: owner timed out waiting for release"); + Check(owner.Result == (failOwner ? 0 : 1), $"{caseName}: owner returned {owner.Result}"); + if (failOwner) + { + Check(ownerWrites == 1, $"{caseName}: failed owner sink was invoked {ownerWrites} times"); + Check(new FileInfo(ownerPath).Length == 0, $"{caseName}: failed owner wrote output"); + } + else + { + ValidateOnDemandOutput(ownerPath, ownerFormat, OwnerSignal); + } + + foreach (ReportFormat format in formats) + { + string recoveryPath = Path.Combine(caseDirectory, $"recovery.{format}"); + int result = InProcCrashReportTest_CreateOnDemandReport(format, OtherSignal, recoveryPath, static () => 1); + Check(result == 1, $"{caseName}: subsequent {format} request returned {result}"); + ValidateOnDemandOutput(recoveryPath, format, OtherSignal); + } + + Console.WriteLine($"PASS: {caseName}; both contenders rejected without writes, owner and recovery verified"); + } + + private static Task StartOnDemandRequest(ReportFormat format, int signal, string path, BeforeWriteCallback beforeWrite) => + Task.Factory.StartNew(() => InProcCrashReportTest_CreateOnDemandReport(format, signal, path, beforeWrite), + CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + private static void ValidateOnDemandOutput(string path, ReportFormat format, int signal) + { + if (format == ReportFormat.Json) + { + ValidateJson(path, 0, signal.ToString(CultureInfo.InvariantCulture)); + } + else + { + ValidateConsole(path, 0, signal == 6 ? "6 (SIGABRT)" : "11 (SIGSEGV)"); + } + } #endif private static void ValidateJson(string path, int scenario, string? signal = null) diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp index 5c8333d0ad31c8..410acec1de0794 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp @@ -339,6 +339,7 @@ namespace FILE* file; const char* path; ucontext_t* signalContext; + int (*beforeWriteCallback)(); bool attemptReentrantReport; bool reentrantAttempted; bool reentrantResult; @@ -347,6 +348,11 @@ namespace bool WriteOnDemandOutput(const char* buffer, size_t length, void* context) { OnDemandOutputContext* output = static_cast(context); + if (output->beforeWriteCallback != nullptr && output->beforeWriteCallback() == 0) + { + return false; + } + if (output->attemptReentrantReport && !output->reentrantAttempted) { output->reentrantAttempted = true; @@ -367,25 +373,27 @@ namespace return false; } - bool WriteOnDemandReport( + int CreateOnDemandReport( InProcCrashReportOutputFormat outputFormat, int signal, const char* outputPath, ucontext_t* signalContext, - bool attemptReentrantReport = false) + bool attemptReentrantReport, + int (*beforeWriteCallback)()) { printf("Generating on-demand format %u, signal %d: %s\n", static_cast(outputFormat), signal, outputPath); fflush(stdout); FILE* file = fopen(outputPath, "wb"); if (!CheckIo(file != nullptr, "fopen", outputPath)) { - return false; + return -1; } OnDemandOutputContext output = {}; output.file = file; output.path = outputPath; output.signalContext = signalContext; + output.beforeWriteCallback = beforeWriteCallback; output.attemptReentrantReport = attemptReentrantReport; bool generated = InProcCrashReportCreateReport( @@ -396,9 +404,25 @@ namespace &output); bool closed = CheckIo(fclose(file) == 0, "fclose", outputPath); - return Check(generated, "on-demand generation returned false") && closed && - Check(!attemptReentrantReport || (output.reentrantAttempted && !output.reentrantResult), - "nested request was not attempted or was incorrectly accepted"); + if (!closed || + !Check(!attemptReentrantReport || (output.reentrantAttempted && !output.reentrantResult), + "nested request was not attempted or was incorrectly accepted")) + { + return -1; + } + + return generated ? 1 : 0; + } + + bool WriteOnDemandReport( + InProcCrashReportOutputFormat outputFormat, + int signal, + const char* outputPath, + ucontext_t* signalContext, + bool attemptReentrantReport = false) + { + return Check(CreateOnDemandReport(outputFormat, signal, outputPath, signalContext, attemptReentrantReport, nullptr) == 1, + "on-demand generation failed"); } } @@ -550,3 +574,16 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveOnDemand( WriteOnDemandReport(InProcCrashReportOutputFormat::Log, SIGSEGV, firstLogPath, signalContext) && WriteOnDemandReport(InProcCrashReportOutputFormat::Log, SIGABRT, secondLogPath, signalContext) ? 0 : -1; } + +// DriveOnDemand initializes the reporter before concurrent requests use this entry point. +extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_CreateOnDemandReport( + InProcCrashReportOutputFormat outputFormat, + int signal, + const char* outputPath, + int (*beforeWriteCallback)()) +{ + SyntheticContext syntheticContext; + FillSyntheticContext(&syntheticContext); + return CreateOnDemandReport(outputFormat, signal, outputPath, &syntheticContext.context, + /*attemptReentrantReport*/ false, beforeWriteCallback); +} From 081f03b756be8dc4342ac4f79390b9679d4f34b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Fri, 18 Sep 2026 16:35:28 -0400 Subject: [PATCH 7/8] Test mixed signal and on-demand crash report contention Exercise signal-dispatch rejection while successful and failing on-demand owners hold the shared reporter guard. Finish the existing isolated OnDemand process with a signal-shaped owner, validating output and on-demand rejection both during and after signal reporting. Reuse bounded coordination and platform capture without product hooks or real signals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- .../exceptions/inproccrashreport/README.md | 6 +- .../inproccrashreport/Shared/Program.cs | 158 +++++++++++++----- .../Shared/inproccrashreport_test_driver.cpp | 87 ++++++---- 3 files changed, 176 insertions(+), 75 deletions(-) diff --git a/src/tests/baseservices/exceptions/inproccrashreport/README.md b/src/tests/baseservices/exceptions/inproccrashreport/README.md index dd3ec4dce22ceb..76906151f23ed4 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/README.md +++ b/src/tests/baseservices/exceptions/inproccrashreport/README.md @@ -12,11 +12,13 @@ The same managed validation and native driver run as regular CoreCLR tests on de | `Abort` | One completed JSON file and a compact report: two thread records, `SIGABRT`, a native-only crash stack, and no managed exception fields. | | `StackOverflow` | One completed JSON file and a compact report: a managed stack-overflow exception, 42 total frames represented by three trace entries, and a recursive entry repeated 40 times. Tests emission, not stack exhaustion or trace compression. | | `ConsoleOnly` | The rich compact report, without even creating the lifecycle report directory despite being given a valid root. | -| `OnDemand` | Sequential and concurrent JSON/compact requests with changing signals, using the same rich assertions. Null, nested, and overlapping requests are rejected; failed sinks stop receiving writes and later requests succeed. The first report needs no services; later requests leave an enabled lifecycle directory empty. | +| `OnDemand` | Sequential and concurrent JSON/compact requests with changing signals, using the same rich assertions. Null, nested, and overlapping requests are rejected; failed sinks stop receiving writes and later requests succeed. Mixed signal-shaped/on-demand requests test both directions of contention. The first request needs no services; only the final signal-shaped owner creates lifecycle output. | Every test must exit normally with code 100; none intentionally crashes. Desktop projects use isolated generated test runners because the native reporter retains process-wide state. Thread records in the reports are synthetic; the on-demand concurrency checks use real threads to issue competing requests. -The concurrency checks hold an on-demand request inside its first output callback while JSON and compact requests run on two other threads. Both contenders must return false without invoking their output callbacks or writing bytes, before the owner is released. Four cases cover JSON/compact owners that either complete or fail their sink. Successful owner output and subsequent requests are validated to detect writer-state corruption or an unreleased guard. Coordination uses explicit handshakes and bounded waits, not timing sleeps. These checks exercise the reporter's admission guard, not PAL's separate fatal-signal gate. +The concurrency checks hold an on-demand request inside its first output callback while JSON and compact requests run on two other threads. Both contenders must return false without invoking their output callbacks or writing bytes, before the owner is released. A signal-dispatch request also runs while that owner is held and must return without enumerating threads, emitting compact output, or creating lifecycle files. Four cases cover JSON/compact owners that either complete or fail their sink. Successful owner output and subsequent requests are validated to detect writer-state corruption or an unreleased guard. + +A final case holds a signal-shaped owner inside the fixture's thread-enumeration callback while on-demand requests in both formats are rejected. After release, the signal owner must produce one completed JSON file and a valid compact report. On-demand requests must remain rejected afterwards because the signal path retains its guard. This case runs last in the existing isolated process; the reporter is not reset or replaced. Coordination uses explicit handshakes and bounded waits, not timing sleeps. All of these calls target the same private reporter instance and exercise its admission guard, not PAL's separate fatal-signal gate. Each invocation owns a unique output directory. Successful runs delete it; failed runs print its location and retain the files, including incomplete reports. Bounded report contents and native I/O diagnostics also appear in the test log. diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs index b093c8a0c3b0a0..40511bb2d01cb5 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -57,7 +57,7 @@ private enum ReportFormat : uint } [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - private delegate int BeforeWriteCallback(); + private delegate int ReportCallback(); [DllImport(NativeLib)] private static extern int InProcCrashReportTest_DriveOnDemand( @@ -66,7 +66,11 @@ private static extern int InProcCrashReportTest_DriveOnDemand( [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl)] private static extern int InProcCrashReportTest_CreateOnDemandReport( - ReportFormat format, int signal, string outputPath, BeforeWriteCallback beforeWrite); + ReportFormat format, int signal, string outputPath, ReportCallback beforeWrite); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl)] + private static extern int InProcCrashReportTest_CreateSignalReport( + string consolePath, ReportCallback beforeEnumerate); #endif #if INPROC_ANDROID @@ -200,6 +204,9 @@ private static void RunOnDemand(string outputDirectory) Check(Directory.Exists(reportDirectory), "native driver did not initialize lifecycle services"); Check(!Directory.EnumerateFileSystemEntries(reportDirectory).Any(), "on-demand requests unexpectedly changed the lifecycle report directory"); + + // The signal path retains the guard, so no later request can generate a report. + RunSignalOwnedContention(outputDirectory); } private static void RunConcurrentOnDemand(string outputDirectory, ReportFormat ownerFormat, bool failOwner) @@ -209,17 +216,85 @@ private static void RunConcurrentOnDemand(string outputDirectory, ReportFormat o string caseName = $"concurrent-{ownerFormat}-{(failOwner ? "failure" : "success")}"; string caseDirectory = Directory.CreateDirectory(Path.Combine(outputDirectory, caseName)).FullName; string ownerPath = Path.Combine(caseDirectory, $"owner.{ownerFormat}"); - Console.WriteLine($"Starting {caseName}: hold one owner while JSON and log contenders request reports"); + Console.WriteLine($"Starting {caseName}: hold an on-demand owner against on-demand and signal requests"); + + RunWithBlockedOwner(caseName, + callback => StartOnDemandRequest(ownerFormat, OwnerSignal, ownerPath, callback), + contenders => + { + CheckRejectedOnDemandRequests(caseDirectory, contenders); + + string signalPath = Path.Combine(caseDirectory, "signal.log"); + int signalEnumerations = 0; + Task signal = StartSignalRequest(signalPath, () => + { + Interlocked.Increment(ref signalEnumerations); + return 1; + }); + contenders.Add(signal); + Check(signal.Wait(s_concurrencyTimeout), $"{caseName}: signal request did not return while on-demand owner was held"); + Check(signal.Result == 1, $"{caseName}: signal capture failed"); + Check(signalEnumerations == 0, $"{caseName}: rejected signal request enumerated threads"); + Check(new FileInfo(signalPath).Length == 0, $"{caseName}: rejected signal request wrote compact output"); + Check(!Directory.EnumerateFileSystemEntries(Path.Combine(outputDirectory, ".dotnet", "crash-reports")).Any(), + $"{caseName}: rejected signal request created lifecycle output"); + }, failOwner); + + if (failOwner) + { + Check(new FileInfo(ownerPath).Length == 0, $"{caseName}: failed owner wrote output"); + } + else + { + ValidateOnDemandOutput(ownerPath, ownerFormat, OwnerSignal); + } + + foreach (ReportFormat format in new[] { ReportFormat.Json, ReportFormat.Log }) + { + string recoveryPath = Path.Combine(caseDirectory, $"recovery.{format}"); + int result = InProcCrashReportTest_CreateOnDemandReport(format, OtherSignal, recoveryPath, static () => 1); + Check(result == 1, $"{caseName}: subsequent {format} request returned {result}"); + ValidateOnDemandOutput(recoveryPath, format, OtherSignal); + } + Console.WriteLine($"PASS: {caseName}; on-demand and signal contenders rejected without output, owner and recovery verified"); + } + + private static void RunSignalOwnedContention(string outputDirectory) + { + const string CaseName = "concurrent-signal-owner"; + string caseDirectory = Directory.CreateDirectory(Path.Combine(outputDirectory, CaseName)).FullName; + string consolePath = Path.Combine(caseDirectory, "owner.log"); + Console.WriteLine($"Starting {CaseName}: hold signal report enumeration against JSON and log requests"); + RunWithBlockedOwner(CaseName, + callback => StartSignalRequest(consolePath, callback), + contenders => CheckRejectedOnDemandRequests(caseDirectory, contenders), + failOwner: false); + + string[] reports = Directory.GetFiles(Path.Combine(outputDirectory, ".dotnet", "crash-reports")); + Check(reports.Length == 1 && reports[0].EndsWith(".crashreport.json", StringComparison.Ordinal), + $"{CaseName}: expected one completed signal report and no temporary files, found: {string.Join(", ", reports)}"); + ValidateJson(reports[0], 0); + ValidateConsole(consolePath, 0); + + string afterDirectory = Directory.CreateDirectory(Path.Combine(caseDirectory, "after-completion")).FullName; + CheckRejectedOnDemandRequests(afterDirectory, new List>()); + Console.WriteLine($"PASS: {CaseName}; both formats rejected during and after signal reporting, signal output verified"); + } + + private static void RunWithBlockedOwner( + string caseName, Func> startOwner, + Action>> checkContenders, bool failOwner) + { TaskCompletionSource ownerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource releaseOwner = new(TaskCreationOptions.RunContinuationsAsynchronously); - int ownerWrites = 0; + int ownerCallbacks = 0; int ownerTimedOut = 0; - Task owner = StartOnDemandRequest(ownerFormat, OwnerSignal, ownerPath, () => + Task owner = startOwner(() => { - if (Interlocked.Increment(ref ownerWrites) == 1) + if (Interlocked.Increment(ref ownerCallbacks) == 1) { - // Keep the reporter's guard occupied until both contenders have returned. + // Keep the reporter's guard occupied until all contenders have returned. ownerEntered.SetResult(); if (!releaseOwner.Task.Wait(s_concurrencyTimeout)) { @@ -231,33 +306,11 @@ private static void RunConcurrentOnDemand(string outputDirectory, ReportFormat o return failOwner ? 0 : 1; }); - ReportFormat[] formats = [ReportFormat.Json, ReportFormat.Log]; - int[] contenderWrites = new int[formats.Length]; - List> contenders = new(formats.Length); + List> contenders = new(); try { - Check(ownerEntered.Task.Wait(s_concurrencyTimeout), $"{caseName}: owner never entered its output callback"); - for (int i = 0; i < formats.Length; i++) - { - int index = i; - contenders.Add(StartOnDemandRequest(formats[index], OtherSignal, - Path.Combine(caseDirectory, $"contender.{formats[index]}"), () => - { - Interlocked.Increment(ref contenderWrites[index]); - return 0; - })); - } - - Check(Task.WhenAll(contenders).Wait(s_concurrencyTimeout), - $"{caseName}: contenders waited for the owner instead of rejecting overlap"); - for (int i = 0; i < contenders.Count; i++) - { - Check(contenders[i].Result == 0, $"{caseName}: {formats[i]} contender returned {contenders[i].Result}, expected rejection"); - Check(contenderWrites[i] == 0, $"{caseName}: rejected {formats[i]} contender invoked its output callback"); - Check(new FileInfo(Path.Combine(caseDirectory, $"contender.{formats[i]}")).Length == 0, - $"{caseName}: rejected {formats[i]} contender wrote output"); - } - + Check(ownerEntered.Task.Wait(s_concurrencyTimeout), $"{caseName}: owner never entered its callback"); + checkContenders(contenders); Check(!owner.IsCompleted, $"{caseName}: owner completed before being released"); } finally @@ -271,29 +324,46 @@ private static void RunConcurrentOnDemand(string outputDirectory, ReportFormat o Check(owner.Result == (failOwner ? 0 : 1), $"{caseName}: owner returned {owner.Result}"); if (failOwner) { - Check(ownerWrites == 1, $"{caseName}: failed owner sink was invoked {ownerWrites} times"); - Check(new FileInfo(ownerPath).Length == 0, $"{caseName}: failed owner wrote output"); + Check(ownerCallbacks == 1, $"{caseName}: failed owner sink was invoked {ownerCallbacks} times"); } - else + } + + private static void CheckRejectedOnDemandRequests(string caseDirectory, List> contenders) + { + ReportFormat[] formats = [ReportFormat.Json, ReportFormat.Log]; + int[] writes = new int[formats.Length]; + Task[] requests = new Task[formats.Length]; + for (int i = 0; i < formats.Length; i++) { - ValidateOnDemandOutput(ownerPath, ownerFormat, OwnerSignal); + int index = i; + requests[index] = StartOnDemandRequest(formats[index], 6, + Path.Combine(caseDirectory, $"contender.{formats[index]}"), () => + { + Interlocked.Increment(ref writes[index]); + return 0; + }); + contenders.Add(requests[index]); } - foreach (ReportFormat format in formats) + Check(Task.WhenAll(requests).Wait(s_concurrencyTimeout), + $"{caseDirectory}: on-demand contenders did not reject the occupied guard"); + for (int i = 0; i < requests.Length; i++) { - string recoveryPath = Path.Combine(caseDirectory, $"recovery.{format}"); - int result = InProcCrashReportTest_CreateOnDemandReport(format, OtherSignal, recoveryPath, static () => 1); - Check(result == 1, $"{caseName}: subsequent {format} request returned {result}"); - ValidateOnDemandOutput(recoveryPath, format, OtherSignal); + Check(requests[i].Result == 0, $"{caseDirectory}: {formats[i]} contender returned {requests[i].Result}, expected rejection"); + Check(writes[i] == 0, $"{caseDirectory}: rejected {formats[i]} contender invoked its output callback"); + Check(new FileInfo(Path.Combine(caseDirectory, $"contender.{formats[i]}")).Length == 0, + $"{caseDirectory}: rejected {formats[i]} contender wrote output"); } - - Console.WriteLine($"PASS: {caseName}; both contenders rejected without writes, owner and recovery verified"); } - private static Task StartOnDemandRequest(ReportFormat format, int signal, string path, BeforeWriteCallback beforeWrite) => + private static Task StartOnDemandRequest(ReportFormat format, int signal, string path, ReportCallback beforeWrite) => Task.Factory.StartNew(() => InProcCrashReportTest_CreateOnDemandReport(format, signal, path, beforeWrite), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + private static Task StartSignalRequest(string consolePath, ReportCallback beforeEnumerate) => + Task.Factory.StartNew(() => InProcCrashReportTest_CreateSignalReport(consolePath, beforeEnumerate), + CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + private static void ValidateOnDemandOutput(string path, ReportFormat format, int signal) { if (format == ReportFormat.Json) diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp index 410acec1de0794..0a60a4b0f22dd7 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/inproccrashreport_test_driver.cpp @@ -101,6 +101,15 @@ namespace #endif }; + struct SignalReportContext + { + int (*beforeEnumerateCallback)(); + bool callbackSucceeded; + }; + + // Coordination belongs to this test call, not to other callers of the reporter. + thread_local SignalReportContext* t_signalReportContext = nullptr; + bool IsManagedThreadCallback() { return true; @@ -180,6 +189,11 @@ namespace InProcCrashReportFrameCallback frameCallback, void* ctx) { + if (t_signalReportContext != nullptr && t_signalReportContext->beforeEnumerateCallback != nullptr) + { + t_signalReportContext->callbackSucceeded = t_signalReportContext->beforeEnumerateCallback() != 0; + } + threadCallback(crashingTid, /*isCrashThread*/ true, "System.NullReferenceException", 0x80004003, ctx); EmitManagedFrame(frameCallback, 0x000000000040aaaa, "DoWork", "Synthetic.App.Worker`1[System.Int32]", 0x06000001, ctx); @@ -334,6 +348,40 @@ namespace } #endif + bool WriteSignalReport(int signalNumber, const char* consoleCapturePath, int (*beforeEnumerateCallback)()) + { + SyntheticContext syntheticContext; + FillSyntheticContext(&syntheticContext); + + siginfo_t si = {}; + si.si_signo = signalNumber; + +#if defined(TARGET_ANDROID) + InProcCrashReportTest_ResetConsoleCapture(); +#else + int savedStderr; + if (!BeginConsoleCapture(consoleCapturePath, &savedStderr)) + { + return false; + } +#endif + + SignalReportContext signalReportContext = { beforeEnumerateCallback, true }; + t_signalReportContext = &signalReportContext; + errno = EDOM; + InProcCrashReportSignalDispatcher(signalNumber, &si, &syntheticContext.context); + bool errnoPreserved = Check(errno == EDOM, "signal dispatcher changed errno"); + t_signalReportContext = nullptr; + +#if defined(TARGET_ANDROID) + bool captured = WriteConsoleCapture(consoleCapturePath); +#else + bool captured = EndConsoleCapture(savedStderr); +#endif + return errnoPreserved && captured && + Check(signalReportContext.callbackSucceeded, "signal enumeration callback failed"); + } + struct OnDemandOutputContext { FILE* file; @@ -437,10 +485,6 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( return -1; } -#if defined(TARGET_ANDROID) - InProcCrashReportTest_ResetConsoleCapture(); -#endif - InProcCrashReporterSettings settings = {}; settings.isManagedThreadCallback = &IsManagedThreadCallback; settings.walkStackCallback = nullptr; @@ -485,31 +529,7 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_DriveScenario( InProcCrashReportEndStackOverflowTrace(); } - SyntheticContext syntheticContext; - FillSyntheticContext(&syntheticContext); - - siginfo_t si; - memset(&si, 0, sizeof(si)); - si.si_signo = signalNumber; - -#if !defined(TARGET_ANDROID) - int savedStderr; - if (!BeginConsoleCapture(consoleCapturePath, &savedStderr)) - { - return -1; - } -#endif - - errno = EDOM; - InProcCrashReportSignalDispatcher(signalNumber, &si, &syntheticContext.context); - bool errnoPreserved = Check(errno == EDOM, "signal dispatcher changed errno"); - -#if defined(TARGET_ANDROID) - bool captured = WriteConsoleCapture(consoleCapturePath); -#else - bool captured = EndConsoleCapture(savedStderr); -#endif - return errnoPreserved && captured ? 0 : -1; + return WriteSignalReport(signalNumber, consoleCapturePath, nullptr) ? 0 : -1; } // First generate without services; subsequent requests must not use the enabled @@ -587,3 +607,12 @@ extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_CreateOnDemandReport( return CreateOnDemandReport(outputFormat, signal, outputPath, &syntheticContext.context, /*attemptReentrantReport*/ false, beforeWriteCallback); } + +// Uses the same initialized reporter as on-demand calls; bypasses PAL signal handling. +// A successful return confirms capture, not admission: the dispatcher has no return value. +extern "C" INPROC_TEST_EXPORT int InProcCrashReportTest_CreateSignalReport( + const char* consoleCapturePath, + int (*beforeEnumerateCallback)()) +{ + return WriteSignalReport(SIGSEGV, consoleCapturePath, beforeEnumerateCallback) ? 1 : -1; +} From f3b2d63d4e64fa43f8413776d1f62e1804906db9 Mon Sep 17 00:00:00 2001 From: Mitchell Hwang Date: Fri, 18 Sep 2026 14:30:10 -0400 Subject: [PATCH 8/8] TEMP: Probe RichSigsegv Helix failure-artifact uploads Intentionally fail only RichSigsegv after its report assertions pass, before successful-run cleanup. Include an incomplete-file marker to exercise recursive retention alongside the real compact and JSON reports. Keep the expected success exit code unchanged so CI reports a genuine failure. Revert this entire commit after confirming downloadable Linux and Android Helix artifacts, then require normal passing CI before merging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa976a9e-b875-4524-82e1-1485d03d14fa --- .../exceptions/inproccrashreport/Shared/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs index 40511bb2d01cb5..7896f0949721ee 100644 --- a/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs +++ b/src/tests/baseservices/exceptions/inproccrashreport/Shared/Program.cs @@ -114,6 +114,11 @@ private static int RunTest(Action scenario) WriteArtifactArchive(archivePath); #endif scenario(outputDirectory); +#if INPROC_SCENARIO_RICHSIGSEGV + // Temporary probe for Helix uploads of completed and incomplete reports. + File.WriteAllText(Path.Combine(outputDirectory, ".dotnet", "crash-reports", "incomplete.tmp"), "artifact probe\n"); + Check(false, "EXPECTED_ARTIFACT_RETENTION_PROBE: assertions passed; revert this probe after verifying Helix artifacts."); +#endif Directory.Delete(outputDirectory, recursive: true); Console.WriteLine($"PASS: crash report assertions completed in {timer.ElapsedMilliseconds} ms"); return 100;