From 14a5b6a06574a5ed303d2adec66de23c558d80a8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:25:57 +0100 Subject: [PATCH 01/59] test(runtime): check that a DLL's copy reports crashes while loaded The copy of the runtime in a DLL that links it installs its crash handler when the DLL loads. A child that removes the executable's handler, as a program without the runtime has none, then faults while the DLL is loaded gets the backtrace of the DLL's copy. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index d1193188..391d7fb1 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1228,4 +1228,35 @@ namespace EXPECT_EXIT(faultAfterManyUnloads(), endedByTheFault(), crashBacktrace(Backtrace::NamesTheExecutableOnly)); } + + // Loads the DLL that links the runtime, whose copy installs its handler over the executable's. + // Prints why and exits with 2 when it does not load. + HMODULE loadRuntimeLibrary() + { + HMODULE runtime = LoadLibraryA(CT_RUNTIME_LIBRARY); + if (!runtime) + { + std::fprintf(stderr, "the runtime library did not load\n"); + std::exit(2); + } + return runtime; + } + + // Removes the executable's handler, as for a program that does not link the runtime, loads the + // DLL that links it, then faults. + [[noreturn]] void faultWhileADllThatLinksTheRuntimeIsLoaded() + { + SetUnhandledExceptionFilter(nullptr); + loadRuntimeLibrary(); + faultOnAThread(&raiseUnhandledException); + } + + // While the DLL is loaded, its copy's handler reports the crashes of the process (#236). + TEST(BacktraceReplaceDeathTest, ADllThatLinksTheRuntimeReportsTheCrashesWhileItIsLoaded) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultWhileADllThatLinksTheRuntimeIsLoaded(), endedByTheFault(), + crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + } } // namespace From 7bfa995bcc13528eb1728b1455c8ddef0d730e9a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:25:57 +0100 Subject: [PATCH 02/59] test(runtime): check that an unloaded DLL leaves a later host filter A program that installs a filter of its own over the handler of a DLL's copy keeps it when FreeLibrary unloads the DLL: the child's filter, not the runtime's handler, ends the process, with an exit code of its own. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 391d7fb1..02835d3a 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1259,4 +1259,34 @@ namespace EXPECT_EXIT(faultWhileADllThatLinksTheRuntimeIsLoaded(), endedByTheFault(), crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); } + + // A filter that the program installs for the exceptions that nothing handles, as a crash + // reporter does: it says so, then ends the process with an exit code of its own. + constexpr UINT kHostFilterExit = 9; + + LONG WINAPI hostFilter(EXCEPTION_POINTERS*) + { + std::fputs("host filter\n", stderr); + std::fflush(stderr); + TerminateProcess(GetCurrentProcess(), kHostFilterExit); + return EXCEPTION_CONTINUE_SEARCH; + } + + [[noreturn]] void faultWithTheHostsFilterInstalledAfterADll() + { + HMODULE runtime = loadRuntimeLibrary(); + SetUnhandledExceptionFilter(&hostFilter); + FreeLibrary(runtime); + faultOnAThread(&raiseUnhandledException); + } + + // Unloading the DLL leaves the filter that the program installed over its copy's handler + // (#236). + TEST(BacktraceReplaceDeathTest, ADllUnloadedLeavesTheHostsFilterInstalledAfterIt) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultWithTheHostsFilterInstalledAfterADll(), + testing::ExitedWithCode(static_cast(kHostFilterExit)), "host filter"); + } } // namespace From ff1d3590a44b6c8a103714b741723f5315a5f302 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:34:02 +0100 Subject: [PATCH 03/59] fix(runtime): put back the filter a DLL's crash handler replaced With CT_BACKTRACE set, the copy of the runtime in a DLL installs its crash handler over the filter installed before it, the executable's handler or one of the program's, and nothing put that filter back once FreeLibrary had unloaded the DLL. The copy now keeps the filter it replaced, and puts it back when FreeLibrary unloads the DLL, after the destructors of the DLL's global objects, if its handler is still the process's: a filter installed over it since stays. Nothing changes when the process terminates. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 46 +++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 042441aa..778f236c 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -1,4 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 +// +// The crash handler of this copy of the runtime, which CT_BACKTRACE installs for the whole process +// when the module that holds the copy initializes: the executable, or a DLL that links the runtime, +// whose copy's handler goes over the filter installed before it. When FreeLibrary unloads the DLL, +// whose code the process must not call afterwards, its copy puts that filter back (#236). #include "ct_runtime_internal.h" #include "ct_runtime_helpers.h" @@ -14,9 +19,16 @@ #pragma comment(lib, "Dbghelp.lib") +// The objects of this file are constructed before the program's and destroyed after them: a DLL's +// copy puts the filter back after the destructors of the DLL's global objects, whose crashes its +// handler still reports. +#pragma init_seg(lib) + namespace { std::atomic ct_backtrace_installed{0}; + // The filter that this copy's handler went over when the copy installed it. + std::atomic ct_replaced_filter{nullptr}; // Runs inside the unhandled-exception filter, like the POSIX signal handler: only the // lock-free writers, and a symbol name only from a session, which the caller passes with the @@ -80,6 +92,37 @@ namespace return EXCEPTION_EXECUTE_HANDLER; } + + // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that + // holds the copy, if the handler is still the process's: a filter installed over it since + // stays. Windows has no call that reads the process's filter without setting one: a filter + // that another thread installs between the two calls below is replaced. Nothing changes when + // the process terminates: the DLL stays mapped until then. + struct CtCrashHandlerRelease + { + // User-provided, so that the object is initialized dynamically, in this file's + // init_seg: clang registers the destructor of a constant-initialized object from + // .CRT$XCU, with the program's own objects. + CT_NOINSTR CtCrashHandlerRelease() {} + + CT_NOINSTR ~CtCrashHandlerRelease() + { + if (ct_backtrace_installed.load(std::memory_order_acquire) == 0 || + !ct_dll_unloading_dynamically()) + { + return; + } + const LPTOP_LEVEL_EXCEPTION_FILTER replaced = + ct_replaced_filter.load(std::memory_order_acquire); + const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(replaced); + if (current != &ct_exception_filter) + { + SetUnhandledExceptionFilter(current); + } + } + }; + + CtCrashHandlerRelease ct_crash_handler_release; } // namespace CT_NOINSTR void ct_maybe_install_backtrace(void) @@ -107,7 +150,8 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) (void)ct_dbghelp_sync_modules_locked(process); } } - SetUnhandledExceptionFilter(ct_exception_filter); + ct_replaced_filter.store(SetUnhandledExceptionFilter(&ct_exception_filter), + std::memory_order_release); ct_write_prefix(CTLevel::Info); ct_write_cstr("ct: backtrace handler installed\n"); From 7d0cb320f4497edd44851ef9668097b6c0393e82 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:25:57 +0100 Subject: [PATCH 04/59] test(runtime): check the executable reports crashes after a DLL unloads The copy of the runtime in a DLL installs its crash handler over the executable's. Once FreeLibrary has unloaded the DLL, whose code is gone, a crash must reach the executable's handler again. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 02835d3a..5ea34f31 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -12,7 +12,10 @@ // one forever. The crash handler's backtraces, which name frames through the same session, name // none after a DLL unloaded since (#225). The copy in a DLL frees the names it decoded when the DLL // unloads: the DLL keeps no heap block at each load, as one without the runtime. The boxes of the -// DLL's destructors still name types then, as they do when the process exits (#227). +// DLL's destructors still name types then, as they do when the process exits (#227). The copy in a +// DLL reports the process's crashes while the DLL is loaded. Once it unloads, the handler installed +// before the copy's reports them again, the executable's or the program's own filter, and a filter +// installed over the copy's stays, whatever the order in which two such DLLs unload (#236). #include #include @@ -1260,6 +1263,22 @@ namespace crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); } + [[noreturn]] void faultAfterADllThatLinksTheRuntimeUnloaded() + { + FreeLibrary(loadRuntimeLibrary()); + faultOnAThread(&raiseUnhandledException); + } + + // Once FreeLibrary has unloaded the DLL, whose code is gone, the executable's handler reports + // the crash (#236). + TEST(BacktraceReplaceDeathTest, OnceADllThatLinksTheRuntimeUnloadsTheExecutableReportsCrashes) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultAfterADllThatLinksTheRuntimeUnloaded(), endedByTheFault(), + crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + } + // A filter that the program installs for the exceptions that nothing handles, as a crash // reporter does: it says so, then ends the process with an exit code of its own. constexpr UINT kHostFilterExit = 9; From dd5148e5e64b6976b069307c4ba810277b2c958d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:25:57 +0100 Subject: [PATCH 05/59] test(runtime): check a host filter installed before a DLL comes back A program's own filter, installed before a DLL that links the runtime loads, must handle the crashes again once FreeLibrary has unloaded the DLL, whose copy installed its handler over the filter. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 5ea34f31..6be5a696 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1291,6 +1291,23 @@ namespace return EXCEPTION_CONTINUE_SEARCH; } + [[noreturn]] void faultWithTheHostsFilterInstalledBeforeADll() + { + SetUnhandledExceptionFilter(&hostFilter); + FreeLibrary(loadRuntimeLibrary()); + faultOnAThread(&raiseUnhandledException); + } + + // The filter that the program installed before the DLL's copy installed its handler is back + // once FreeLibrary has unloaded the DLL (#236). + TEST(BacktraceReplaceDeathTest, TheHostsFilterInstalledBeforeADllIsBackOnceItUnloads) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultWithTheHostsFilterInstalledBeforeADll(), + testing::ExitedWithCode(static_cast(kHostFilterExit)), "host filter"); + } + [[noreturn]] void faultWithTheHostsFilterInstalledAfterADll() { HMODULE runtime = loadRuntimeLibrary(); From 8a8a49ca925debce6764fb1a6d8d8f5489f67b91 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:25:57 +0100 Subject: [PATCH 06/59] test(runtime): check crashes after two DLLs unload in reverse order Two copies of the DLL that links the runtime, each a module apart, each install their handler over the one before. Unloaded in reverse order, they must leave the executable's handler to report the crash. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 6be5a696..d787e76b 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1325,4 +1325,51 @@ namespace EXPECT_EXIT(faultWithTheHostsFilterInstalledAfterADll(), testing::ExitedWithCode(static_cast(kHostFilterExit)), "host filter"); } + + // A copy of the DLL that links the runtime, at a path of its own: the loader maps each copy as + // a module apart, with a copy of the runtime of its own. + fs::path runtimeLibraryCopyPath(const char* name) + { + return fs::path(testing::TempDir()) / (std::string("ct_runtime_library_") + name + ".dll"); + } + + // Loads two copies of the DLL that links the runtime, whose copies of the runtime each install + // their handler over the one before, unloads them in load order or in reverse, then faults. + // Prints why and exits with 2 when a copy does not load. + [[noreturn]] void faultAfterTwoDllsUnloaded(const fs::path& first, const fs::path& second, + bool in_load_order) + { + HMODULE libraries[2] = {}; + const fs::path* paths[2] = {&first, &second}; + for (int index = 0; index < 2; ++index) + { + std::error_code error; + fs::copy_file(CT_RUNTIME_LIBRARY, *paths[index], fs::copy_options::overwrite_existing, + error); + libraries[index] = error ? nullptr : LoadLibraryW(paths[index]->c_str()); + if (!libraries[index]) + { + std::fprintf(stderr, "copy %d of the runtime library did not load\n", index + 1); + std::exit(2); + } + } + FreeLibrary(libraries[in_load_order ? 0 : 1]); + FreeLibrary(libraries[in_load_order ? 1 : 0]); + faultOnAThread(&raiseUnhandledException); + } + + // The second copy's handler was installed over the first's: unloaded first, it leaves the + // first copy's handler, which leaves the executable's (#236). + TEST(BacktraceReplaceDeathTest, OnceTwoDllsUnloadInReverseOrderTheExecutableReportsCrashes) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + const fs::path first = runtimeLibraryCopyPath("reverse_1"); + const fs::path second = runtimeLibraryCopyPath("reverse_2"); + EXPECT_EXIT(faultAfterTwoDllsUnloaded(first, second, false), endedByTheFault(), + crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + std::error_code ignored; + fs::remove(first, ignored); + fs::remove(second, ignored); + } } // namespace From f959ea5164281db930b3a837777e787e4a5f4dad Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:34:02 +0100 Subject: [PATCH 07/59] refactor(runtime): publish the filter a crash handler replaced Each copy of the runtime keeps the filter that its crash handler replaced in a record, in a section .ctcrash of its module, with a magic number and the handler's address: another copy can find the record from the handler alone. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 778f236c..ab3bd354 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -8,6 +8,7 @@ #include "ct_runtime_helpers.h" #include +#include #include #include @@ -24,11 +25,12 @@ // handler still reports. #pragma init_seg(lib) +// The section of the record below, in the module that holds this copy of the runtime. +#pragma section(".ctcrash", read, write) + namespace { std::atomic ct_backtrace_installed{0}; - // The filter that this copy's handler went over when the copy installed it. - std::atomic ct_replaced_filter{nullptr}; // Runs inside the unhandled-exception filter, like the POSIX signal handler: only the // lock-free writers, and a symbol name only from a session, which the caller passes with the @@ -93,6 +95,22 @@ namespace return EXCEPTION_EXECUTE_HANDLER; } + // What a copy of the runtime tells the other copies in the process of its crash handler: the + // filter that the handler went over when the copy installed it. A copy finds another one's + // record from the address of its handler alone, in the section .ctcrash of the module that + // holds the handler. The magic number reads "ctcrash", then the version of the layout. + struct CtCrashHandlerRecord + { + std::uint64_t magic; + LPTOP_LEVEL_EXCEPTION_FILTER handler; + std::atomic replaced; + }; + + constexpr std::uint64_t kCrashHandlerMagic = 0x0168736172637463; + + __declspec(allocate(".ctcrash")) constinit CtCrashHandlerRecord ct_crash_handler_record{ + kCrashHandlerMagic, &ct_exception_filter, nullptr}; + // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that // holds the copy, if the handler is still the process's: a filter installed over it since // stays. Windows has no call that reads the process's filter without setting one: a filter @@ -112,10 +130,11 @@ namespace { return; } + CtCrashHandlerRecord& record = ct_crash_handler_record; const LPTOP_LEVEL_EXCEPTION_FILTER replaced = - ct_replaced_filter.load(std::memory_order_acquire); + record.replaced.load(std::memory_order_acquire); const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(replaced); - if (current != &ct_exception_filter) + if (current != record.handler) { SetUnhandledExceptionFilter(current); } @@ -150,8 +169,8 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) (void)ct_dbghelp_sync_modules_locked(process); } } - ct_replaced_filter.store(SetUnhandledExceptionFilter(&ct_exception_filter), - std::memory_order_release); + ct_crash_handler_record.replaced.store(SetUnhandledExceptionFilter(&ct_exception_filter), + std::memory_order_release); ct_write_prefix(CTLevel::Info); ct_write_cstr("ct: backtrace handler installed\n"); From ebebfaed6a7ac0c2e88e8cf491fda91be459308a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:34:02 +0100 Subject: [PATCH 08/59] fix(runtime): hand a DLL's replaced filter to the copy above it Two DLLs that link the runtime, unloaded in load order: the first copy's handler was no longer the process's when it unloaded, so it left the filters as they were, and the second copy, whose handler had replaced the first one's, put that handler back when it unloaded in turn, although its code was gone. A copy whose handler is no longer the process's now walks back from the process's filter through the handlers of the copies that keep a record, to the copy whose handler went over its own, and hands it the filter it replaced. The walk stops at the first filter that is not such a handler. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 67 ++++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index ab3bd354..024ac777 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #ifndef NOMINMAX @@ -107,15 +108,55 @@ namespace }; constexpr std::uint64_t kCrashHandlerMagic = 0x0168736172637463; + constexpr char kCrashHandlerSection[IMAGE_SIZEOF_SHORT_NAME] = {'.', 'c', 't', 'c', + 'r', 'a', 's', 'h'}; __declspec(allocate(".ctcrash")) constinit CtCrashHandlerRecord ct_crash_handler_record{ kCrashHandlerMagic, &ct_exception_filter, nullptr}; + // The record of the copy of the runtime whose crash handler `filter` is, or null for any other + // filter: one of the program's, or that of a copy that keeps no record. Reads the headers of + // the module that holds `filter`, which the loader maps with the image, and the section of the + // record, which a copy keeps readable. Called with the loader lock held, as FreeLibrary holds + // it while the DLL detaches, so that no module unloads meanwhile. + CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_of(LPTOP_LEVEL_EXCEPTION_FILTER filter) + { + HMODULE module = nullptr; + if (!filter || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(filter), &module)) + { + return nullptr; + } + auto* image = reinterpret_cast(module); + const auto* headers = reinterpret_cast( + image + reinterpret_cast(image)->e_lfanew); + const IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(headers); + for (WORD index = 0; index < headers->FileHeader.NumberOfSections; ++index, ++section) + { + if (std::memcmp(section->Name, kCrashHandlerSection, sizeof(section->Name)) != 0) + { + continue; + } + if ((section->Characteristics & IMAGE_SCN_MEM_READ) == 0 || + section->Misc.VirtualSize < sizeof(CtCrashHandlerRecord)) + { + return nullptr; + } + auto* record = reinterpret_cast(image + section->VirtualAddress); + return record->magic == kCrashHandlerMagic && record->handler == filter ? record + : nullptr; + } + return nullptr; + } + // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that - // holds the copy, if the handler is still the process's: a filter installed over it since - // stays. Windows has no call that reads the process's filter without setting one: a filter - // that another thread installs between the two calls below is replaced. Nothing changes when - // the process terminates: the DLL stays mapped until then. + // holds the copy, if the handler is still the process's. A filter installed over it since stays: + // the program's, or the handler of another DLL's copy, which gets the filter put back instead, + // so as not to put back a handler whose code is gone when it unloads in turn. Windows has no + // call that reads the process's filter without setting one: a filter that another thread + // installs between the two calls below is replaced. Nothing changes when the process + // terminates: the DLL stays mapped until then. struct CtCrashHandlerRelease { // User-provided, so that the object is initialized dynamically, in this file's @@ -134,9 +175,23 @@ namespace const LPTOP_LEVEL_EXCEPTION_FILTER replaced = record.replaced.load(std::memory_order_acquire); const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(replaced); - if (current != record.handler) + if (current == record.handler) + { + return; + } + SetUnhandledExceptionFilter(current); + // Each record holds a filter installed before the handler it describes: the walk goes + // back in time, from the process's filter, and ends at the first one that is not the + // handler of a copy that keeps a record. + for (CtCrashHandlerRecord* above = ct_crash_handler_record_of(current); above;) { - SetUnhandledExceptionFilter(current); + LPTOP_LEVEL_EXCEPTION_FILTER below = record.handler; + if (above->replaced.compare_exchange_strong(below, replaced, + std::memory_order_acq_rel)) + { + return; + } + above = ct_crash_handler_record_of(below); } } }; From eb4fa5ee5dc83286476679fd110a2d174a3abc55 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:25:57 +0100 Subject: [PATCH 09/59] test(runtime): check crashes after two DLLs unload in load order The first copy unloads while the second's handler, installed over its own, is still there. Once the second unloads too, the executable's handler must report the crash, not the first copy's, whose code is gone. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index d787e76b..47236082 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1372,4 +1372,20 @@ namespace fs::remove(first, ignored); fs::remove(second, ignored); } + + // The first copy unloads while the second's handler, installed over its own, is still there: + // once the second copy unloads too, the executable's handler reports the crash, not the first + // copy's, whose code is gone (#236). + TEST(BacktraceReplaceDeathTest, OnceTwoDllsUnloadInLoadOrderTheExecutableReportsCrashes) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + const fs::path first = runtimeLibraryCopyPath("in_order_1"); + const fs::path second = runtimeLibraryCopyPath("in_order_2"); + EXPECT_EXIT(faultAfterTwoDllsUnloaded(first, second, true), endedByTheFault(), + crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + std::error_code ignored; + fs::remove(first, ignored); + fs::remove(second, ignored); + } } // namespace From 169d3c68d654f49e63fc31b05b1fa0a1b98213a4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:38:51 +0100 Subject: [PATCH 10/59] fix(runtime): find the copy above among the loaded modules A copy of the runtime that unloads while another copy's handler is over its own looked for that copy from the process's filter back, and stopped at the first filter that was not a copy's handler. A filter of the program installed over the other copy's handler, as a scope that puts that handler back afterwards does, hid the copy: once it unloaded in turn, it put back a handler whose code was gone. The copy now looks for the record that holds its handler among the loaded modules, which no thread unloads while FreeLibrary holds the loader lock. The record no longer needs the handler's address. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 64 ++++++++++---------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 024ac777..33dc290b 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -7,6 +7,7 @@ #include "ct_runtime_internal.h" #include "ct_runtime_helpers.h" +#include #include #include #include @@ -18,6 +19,7 @@ #endif #include #include +#include #pragma comment(lib, "Dbghelp.lib") @@ -98,12 +100,11 @@ namespace // What a copy of the runtime tells the other copies in the process of its crash handler: the // filter that the handler went over when the copy installed it. A copy finds another one's - // record from the address of its handler alone, in the section .ctcrash of the module that - // holds the handler. The magic number reads "ctcrash", then the version of the layout. + // record in the section .ctcrash of the module that holds it. The magic number reads + // "ctcrash", then the version of the layout. struct CtCrashHandlerRecord { std::uint64_t magic; - LPTOP_LEVEL_EXCEPTION_FILTER handler; std::atomic replaced; }; @@ -112,22 +113,17 @@ namespace 'r', 'a', 's', 'h'}; __declspec(allocate(".ctcrash")) constinit CtCrashHandlerRecord ct_crash_handler_record{ - kCrashHandlerMagic, &ct_exception_filter, nullptr}; - - // The record of the copy of the runtime whose crash handler `filter` is, or null for any other - // filter: one of the program's, or that of a copy that keeps no record. Reads the headers of - // the module that holds `filter`, which the loader maps with the image, and the section of the - // record, which a copy keeps readable. Called with the loader lock held, as FreeLibrary holds - // it while the DLL detaches, so that no module unloads meanwhile. - CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_of(LPTOP_LEVEL_EXCEPTION_FILTER filter) + kCrashHandlerMagic, nullptr}; + + // The modules that a copy looks through for the record of the copy whose handler went over its + // own: the first ones that the loader lists. + constexpr DWORD kModulesLookedThrough = 1024; + + // The record of the copy of the runtime in `module`, or null when the module holds none: it + // links no copy, or one that keeps no record. Reads the module's headers, which the loader maps + // with the image, and the section of the record, which a copy keeps readable. + CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_in(HMODULE module) { - HMODULE module = nullptr; - if (!filter || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | - GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, - reinterpret_cast(filter), &module)) - { - return nullptr; - } auto* image = reinterpret_cast(module); const auto* headers = reinterpret_cast( image + reinterpret_cast(image)->e_lfanew); @@ -144,8 +140,7 @@ namespace return nullptr; } auto* record = reinterpret_cast(image + section->VirtualAddress); - return record->magic == kCrashHandlerMagic && record->handler == filter ? record - : nullptr; + return record->magic == kCrashHandlerMagic ? record : nullptr; } return nullptr; } @@ -171,27 +166,34 @@ namespace { return; } - CtCrashHandlerRecord& record = ct_crash_handler_record; const LPTOP_LEVEL_EXCEPTION_FILTER replaced = - record.replaced.load(std::memory_order_acquire); + ct_crash_handler_record.replaced.load(std::memory_order_acquire); const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(replaced); - if (current == record.handler) + if (current == &ct_exception_filter) { return; } SetUnhandledExceptionFilter(current); - // Each record holds a filter installed before the handler it describes: the walk goes - // back in time, from the process's filter, and ends at the first one that is not the - // handler of a copy that keeps a record. - for (CtCrashHandlerRecord* above = ct_crash_handler_record_of(current); above;) + // The copy whose handler went over this one's may be under a filter of the program, + // which keeps the handler it replaced to itself: the copy is looked for among the + // modules, which no thread unloads meanwhile, FreeLibrary holding the loader lock. + HMODULE modules[kModulesLookedThrough]; + DWORD size = 0; + if (!EnumProcessModules(GetCurrentProcess(), modules, sizeof(modules), &size)) + { + return; + } + const DWORD count = + std::min(static_cast(size / sizeof(HMODULE)), kModulesLookedThrough); + for (DWORD index = 0; index < count; ++index) { - LPTOP_LEVEL_EXCEPTION_FILTER below = record.handler; - if (above->replaced.compare_exchange_strong(below, replaced, - std::memory_order_acq_rel)) + CtCrashHandlerRecord* above = ct_crash_handler_record_in(modules[index]); + LPTOP_LEVEL_EXCEPTION_FILTER handler = &ct_exception_filter; + if (above && above->replaced.compare_exchange_strong(handler, replaced, + std::memory_order_acq_rel)) { return; } - above = ct_crash_handler_record_of(below); } } }; From 1ecf68cb35cb087d0b7144b24495d6a6239ebc7a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:36:50 +0100 Subject: [PATCH 11/59] test(runtime): load the copies of the runtime library in a helper The tests that unload two copies of the DLL that links the runtime load them through one function, for a test to come that unloads them in another way. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 47236082..7768e4d5 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1334,12 +1334,10 @@ namespace } // Loads two copies of the DLL that links the runtime, whose copies of the runtime each install - // their handler over the one before, unloads them in load order or in reverse, then faults. - // Prints why and exits with 2 when a copy does not load. - [[noreturn]] void faultAfterTwoDllsUnloaded(const fs::path& first, const fs::path& second, - bool in_load_order) + // their handler over the one before. Prints why and exits with 2 when a copy does not load. + void loadRuntimeLibraryCopies(const fs::path& first, const fs::path& second, + HMODULE (&libraries)[2]) { - HMODULE libraries[2] = {}; const fs::path* paths[2] = {&first, &second}; for (int index = 0; index < 2; ++index) { @@ -1353,6 +1351,15 @@ namespace std::exit(2); } } + } + + // Loads two copies of the DLL that links the runtime, unloads them in load order or in reverse, + // then faults. + [[noreturn]] void faultAfterTwoDllsUnloaded(const fs::path& first, const fs::path& second, + bool in_load_order) + { + HMODULE libraries[2] = {}; + loadRuntimeLibraryCopies(first, second, libraries); FreeLibrary(libraries[in_load_order ? 0 : 1]); FreeLibrary(libraries[in_load_order ? 1 : 0]); faultOnAThread(&raiseUnhandledException); From a6deed2b59006b2f05359aa561083f562aa8c752 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:37:24 +0100 Subject: [PATCH 12/59] test(runtime): check two DLLs unloaded around a host's filter The first copy unloads while a filter of the program is over the second copy's handler, which had replaced the first one's. Once the program has put the second copy's handler back and that copy has unloaded too, the executable's handler must report the crash, not the first copy's, whose code is gone. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 37 ++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 7768e4d5..7b025c00 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -15,7 +15,8 @@ // DLL's destructors still name types then, as they do when the process exits (#227). The copy in a // DLL reports the process's crashes while the DLL is loaded. Once it unloads, the handler installed // before the copy's reports them again, the executable's or the program's own filter, and a filter -// installed over the copy's stays, whatever the order in which two such DLLs unload (#236). +// installed over the copy's stays, whatever the order in which two such DLLs unload, around a +// filter of the program or not (#236). #include #include @@ -1395,4 +1396,38 @@ namespace fs::remove(first, ignored); fs::remove(second, ignored); } + + // Loads two copies of the DLL that links the runtime. Then, as a scope of the program does, + // installs the host's filter over the second copy's handler, and puts that handler back + // afterwards: the first copy unloads meanwhile. Then the second copy unloads, and the child + // faults. + [[noreturn]] void faultAfterTwoDllsUnloadedAroundTheHostsFilter(const fs::path& first, + const fs::path& second) + { + HMODULE libraries[2] = {}; + loadRuntimeLibraryCopies(first, second, libraries); + const LPTOP_LEVEL_EXCEPTION_FILTER second_handler = + SetUnhandledExceptionFilter(&hostFilter); + FreeLibrary(libraries[0]); + SetUnhandledExceptionFilter(second_handler); + FreeLibrary(libraries[1]); + faultOnAThread(&raiseUnhandledException); + } + + // The second copy's handler went over the first's, and the host's filter was over it when the + // first copy unloaded. Once the host has put the second copy's handler back and that copy has + // unloaded too, the executable's handler reports the crash, not the first copy's, whose code + // is gone (#236). + TEST(BacktraceReplaceDeathTest, TwoDllsUnloadedAroundTheHostsFilterLeaveTheExecutablesHandler) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + const fs::path first = runtimeLibraryCopyPath("around_1"); + const fs::path second = runtimeLibraryCopyPath("around_2"); + EXPECT_EXIT(faultAfterTwoDllsUnloadedAroundTheHostsFilter(first, second), endedByTheFault(), + crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + std::error_code ignored; + fs::remove(first, ignored); + fs::remove(second, ignored); + } } // namespace From 8f51424aac94eee5089d4b9eae568a9313eab909 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:07:22 +0100 Subject: [PATCH 13/59] test(runtime): print the exception that the host's filter sees The host's filter of the crash tests prints the code of the exception it gets, and the tests expect the fault's: an exception raised while the DLL unloads, such as an access violation, no longer passes for it. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 7b025c00..1e3f750a 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1281,12 +1281,14 @@ namespace } // A filter that the program installs for the exceptions that nothing handles, as a crash - // reporter does: it says so, then ends the process with an exit code of its own. + // reporter does: it prints the code of the exception, then ends the process with an exit code + // of its own. constexpr UINT kHostFilterExit = 9; - LONG WINAPI hostFilter(EXCEPTION_POINTERS*) + LONG WINAPI hostFilter(EXCEPTION_POINTERS* exception_info) { - std::fputs("host filter\n", stderr); + std::fprintf(stderr, "host filter %lx\n", + static_cast(exception_info->ExceptionRecord->ExceptionCode)); std::fflush(stderr); TerminateProcess(GetCurrentProcess(), kHostFilterExit); return EXCEPTION_CONTINUE_SEARCH; @@ -1306,7 +1308,8 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultWithTheHostsFilterInstalledBeforeADll(), - testing::ExitedWithCode(static_cast(kHostFilterExit)), "host filter"); + testing::ExitedWithCode(static_cast(kHostFilterExit)), + "host filter e0000225"); } [[noreturn]] void faultWithTheHostsFilterInstalledAfterADll() @@ -1324,7 +1327,8 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultWithTheHostsFilterInstalledAfterADll(), - testing::ExitedWithCode(static_cast(kHostFilterExit)), "host filter"); + testing::ExitedWithCode(static_cast(kHostFilterExit)), + "host filter e0000225"); } // A copy of the DLL that links the runtime, at a path of its own: the loader maps each copy as From 2ed205a5096ddb4f542b77cd8a394fc7bec0983e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:08:00 +0100 Subject: [PATCH 14/59] test(runtime): check that the crash tests' DLLs unload A test whose DLL stayed mapped after FreeLibrary would find its copy's handler still there, and pass whatever the runtime does: the children of the crash tests now exit with 2 when a DLL that links the runtime is still mapped once freed. The copies of the DLL that two tests load are named after the child's process, which deletes them once unloaded: two runs that share a temporary directory no longer copy over a DLL that the other has loaded. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 120 ++++++++++++---------- 1 file changed, 66 insertions(+), 54 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 1e3f750a..df431154 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1246,6 +1246,26 @@ namespace return runtime; } + // Whether a module that FreeLibrary freed is still mapped: the loader unmaps it once nothing + // holds it. + bool stillMapped(HMODULE module) + { + MEMORY_BASIC_INFORMATION region = {}; + return VirtualQuery(module, ®ion, sizeof(region)) != 0 && region.State != MEM_FREE; + } + + // Frees a DLL that links the runtime. Prints why and exits with 2 when the DLL stays mapped: + // its copy's handler would still have its code. + void unloadRuntimeLibrary(HMODULE module) + { + FreeLibrary(module); + if (stillMapped(module)) + { + std::fprintf(stderr, "a runtime library stayed mapped\n"); + std::exit(2); + } + } + // Removes the executable's handler, as for a program that does not link the runtime, loads the // DLL that links it, then faults. [[noreturn]] void faultWhileADllThatLinksTheRuntimeIsLoaded() @@ -1266,7 +1286,7 @@ namespace [[noreturn]] void faultAfterADllThatLinksTheRuntimeUnloaded() { - FreeLibrary(loadRuntimeLibrary()); + unloadRuntimeLibrary(loadRuntimeLibrary()); faultOnAThread(&raiseUnhandledException); } @@ -1297,7 +1317,7 @@ namespace [[noreturn]] void faultWithTheHostsFilterInstalledBeforeADll() { SetUnhandledExceptionFilter(&hostFilter); - FreeLibrary(loadRuntimeLibrary()); + unloadRuntimeLibrary(loadRuntimeLibrary()); faultOnAThread(&raiseUnhandledException); } @@ -1316,7 +1336,7 @@ namespace { HMODULE runtime = loadRuntimeLibrary(); SetUnhandledExceptionFilter(&hostFilter); - FreeLibrary(runtime); + unloadRuntimeLibrary(runtime); faultOnAThread(&raiseUnhandledException); } @@ -1331,42 +1351,50 @@ namespace "host filter e0000225"); } - // A copy of the DLL that links the runtime, at a path of its own: the loader maps each copy as - // a module apart, with a copy of the runtime of its own. - fs::path runtimeLibraryCopyPath(const char* name) - { - return fs::path(testing::TempDir()) / (std::string("ct_runtime_library_") + name + ".dll"); - } - - // Loads two copies of the DLL that links the runtime, whose copies of the runtime each install - // their handler over the one before. Prints why and exits with 2 when a copy does not load. - void loadRuntimeLibraryCopies(const fs::path& first, const fs::path& second, - HMODULE (&libraries)[2]) + // A copy of the DLL that links the runtime, at a path of the child's own: the loader maps each + // copy as a module apart, with a copy of the runtime of its own. Prints why and exits with 2 + // when it does not load. + class RuntimeLibraryCopy { - const fs::path* paths[2] = {&first, &second}; - for (int index = 0; index < 2; ++index) + public: + explicit RuntimeLibraryCopy(const char* name) + : path_(fs::path(testing::TempDir()) / + (std::string("ct_runtime_library_") + name + "_" + + std::to_string(GetCurrentProcessId()) + ".dll")) { std::error_code error; - fs::copy_file(CT_RUNTIME_LIBRARY, *paths[index], fs::copy_options::overwrite_existing, - error); - libraries[index] = error ? nullptr : LoadLibraryW(paths[index]->c_str()); - if (!libraries[index]) + fs::copy_file(CT_RUNTIME_LIBRARY, path_, fs::copy_options::overwrite_existing, error); + module_ = error ? nullptr : LoadLibraryW(path_.c_str()); + if (!module_) { - std::fprintf(stderr, "copy %d of the runtime library did not load\n", index + 1); + std::fprintf(stderr, "%s did not load\n", path_.string().c_str()); std::exit(2); } } - } + RuntimeLibraryCopy(const RuntimeLibraryCopy&) = delete; + RuntimeLibraryCopy& operator=(const RuntimeLibraryCopy&) = delete; + + // Unloads the copy, then deletes its file. + void unload() + { + unloadRuntimeLibrary(module_); + std::error_code ignored; + fs::remove(path_, ignored); + } + + private: + fs::path path_; + HMODULE module_ = nullptr; + }; - // Loads two copies of the DLL that links the runtime, unloads them in load order or in reverse, - // then faults. - [[noreturn]] void faultAfterTwoDllsUnloaded(const fs::path& first, const fs::path& second, - bool in_load_order) + // Loads two copies of the DLL that links the runtime, whose copies of the runtime each install + // their handler over the one before, unloads them in load order or in reverse, then faults. + [[noreturn]] void faultAfterTwoDllsUnloaded(bool in_load_order) { - HMODULE libraries[2] = {}; - loadRuntimeLibraryCopies(first, second, libraries); - FreeLibrary(libraries[in_load_order ? 0 : 1]); - FreeLibrary(libraries[in_load_order ? 1 : 0]); + RuntimeLibraryCopy first("first"); + RuntimeLibraryCopy second("second"); + (in_load_order ? first : second).unload(); + (in_load_order ? second : first).unload(); faultOnAThread(&raiseUnhandledException); } @@ -1376,13 +1404,8 @@ namespace { GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; - const fs::path first = runtimeLibraryCopyPath("reverse_1"); - const fs::path second = runtimeLibraryCopyPath("reverse_2"); - EXPECT_EXIT(faultAfterTwoDllsUnloaded(first, second, false), endedByTheFault(), + EXPECT_EXIT(faultAfterTwoDllsUnloaded(false), endedByTheFault(), crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); - std::error_code ignored; - fs::remove(first, ignored); - fs::remove(second, ignored); } // The first copy unloads while the second's handler, installed over its own, is still there: @@ -1392,29 +1415,23 @@ namespace { GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; - const fs::path first = runtimeLibraryCopyPath("in_order_1"); - const fs::path second = runtimeLibraryCopyPath("in_order_2"); - EXPECT_EXIT(faultAfterTwoDllsUnloaded(first, second, true), endedByTheFault(), + EXPECT_EXIT(faultAfterTwoDllsUnloaded(true), endedByTheFault(), crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); - std::error_code ignored; - fs::remove(first, ignored); - fs::remove(second, ignored); } // Loads two copies of the DLL that links the runtime. Then, as a scope of the program does, // installs the host's filter over the second copy's handler, and puts that handler back // afterwards: the first copy unloads meanwhile. Then the second copy unloads, and the child // faults. - [[noreturn]] void faultAfterTwoDllsUnloadedAroundTheHostsFilter(const fs::path& first, - const fs::path& second) + [[noreturn]] void faultAfterTwoDllsUnloadedAroundTheHostsFilter() { - HMODULE libraries[2] = {}; - loadRuntimeLibraryCopies(first, second, libraries); + RuntimeLibraryCopy first("first"); + RuntimeLibraryCopy second("second"); const LPTOP_LEVEL_EXCEPTION_FILTER second_handler = SetUnhandledExceptionFilter(&hostFilter); - FreeLibrary(libraries[0]); + first.unload(); SetUnhandledExceptionFilter(second_handler); - FreeLibrary(libraries[1]); + second.unload(); faultOnAThread(&raiseUnhandledException); } @@ -1426,12 +1443,7 @@ namespace { GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; - const fs::path first = runtimeLibraryCopyPath("around_1"); - const fs::path second = runtimeLibraryCopyPath("around_2"); - EXPECT_EXIT(faultAfterTwoDllsUnloadedAroundTheHostsFilter(first, second), endedByTheFault(), + EXPECT_EXIT(faultAfterTwoDllsUnloadedAroundTheHostsFilter(), endedByTheFault(), crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); - std::error_code ignored; - fs::remove(first, ignored); - fs::remove(second, ignored); } } // namespace From d0f4c5fce4c684923c3cfd50356d3bb8c4062352 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:08:26 +0100 Subject: [PATCH 15/59] test(runtime): check which copy's handler prints the backtrace The crash tests accepted a backtrace from any copy of the runtime. The child now prints the image of the module whose handler must report the crash, the executable's or the DLL's, and the first frame of the backtrace, which the handler takes in itself, must lie in it. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 55 ++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index df431154..707aa621 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1266,12 +1266,52 @@ namespace } } + // A crash backtrace that names RaiseException, printed by the handler of the module whose image + // the child printed after `mark`: the first frame, which the handler takes in itself, lies in + // that image. + class CrashBacktraceFrom : public testing::MatcherInterface + { + public: + explicit CrashBacktraceFrom(const char* mark) : mark_(mark) {} + + bool MatchAndExplain(const std::string& output, + testing::MatchResultListener* listener) const override + { + if (!CrashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong) + .MatchAndExplain(output, listener)) + { + return false; + } + if (!imageAfter(output, mark_).holds(crashFramesOf(output).front().address)) + { + *listener << "the first frame is not in the image after " << mark_; + return false; + } + return true; + } + + void DescribeTo(std::ostream* os) const override + { + *os << "is a crash backtrace that names RaiseException, printed by the handler in the " + "image after " + << mark_; + } + + private: + const char* mark_; + }; + + testing::Matcher crashBacktraceFrom(const char* mark) + { + return testing::MakeMatcher(new CrashBacktraceFrom(mark)); + } + // Removes the executable's handler, as for a program that does not link the runtime, loads the // DLL that links it, then faults. [[noreturn]] void faultWhileADllThatLinksTheRuntimeIsLoaded() { SetUnhandledExceptionFilter(nullptr); - loadRuntimeLibrary(); + printImage("handler:2", loadRuntimeLibrary()); faultOnAThread(&raiseUnhandledException); } @@ -1281,12 +1321,13 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultWhileADllThatLinksTheRuntimeIsLoaded(), endedByTheFault(), - crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + crashBacktraceFrom("handler:2 ")); } [[noreturn]] void faultAfterADllThatLinksTheRuntimeUnloaded() { unloadRuntimeLibrary(loadRuntimeLibrary()); + printImage("handler:2", GetModuleHandleW(nullptr)); faultOnAThread(&raiseUnhandledException); } @@ -1297,7 +1338,7 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultAfterADllThatLinksTheRuntimeUnloaded(), endedByTheFault(), - crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + crashBacktraceFrom("handler:2 ")); } // A filter that the program installs for the exceptions that nothing handles, as a crash @@ -1395,6 +1436,7 @@ namespace RuntimeLibraryCopy second("second"); (in_load_order ? first : second).unload(); (in_load_order ? second : first).unload(); + printImage("handler:2", GetModuleHandleW(nullptr)); faultOnAThread(&raiseUnhandledException); } @@ -1405,7 +1447,7 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultAfterTwoDllsUnloaded(false), endedByTheFault(), - crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + crashBacktraceFrom("handler:2 ")); } // The first copy unloads while the second's handler, installed over its own, is still there: @@ -1416,7 +1458,7 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultAfterTwoDllsUnloaded(true), endedByTheFault(), - crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + crashBacktraceFrom("handler:2 ")); } // Loads two copies of the DLL that links the runtime. Then, as a scope of the program does, @@ -1432,6 +1474,7 @@ namespace first.unload(); SetUnhandledExceptionFilter(second_handler); second.unload(); + printImage("handler:2", GetModuleHandleW(nullptr)); faultOnAThread(&raiseUnhandledException); } @@ -1444,6 +1487,6 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultAfterTwoDllsUnloadedAroundTheHostsFilter(), endedByTheFault(), - crashBacktrace(Backtrace::NamesTheModulesLoadedAllAlong)); + crashBacktraceFrom("handler:2 ")); } } // namespace From 4be3a0d8d9159d35327ef671d20c18be7f5745f7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:09:00 +0100 Subject: [PATCH 16/59] refactor(runtime): share the readability check of the Windows runtime ct_is_readable, which the vtable boxes use before they read the RTTI of an object, moves out of the anonymous namespace of windows/ct_runtime_vtable.cpp, declared with the other helpers of the Windows runtime, for the crash handler to use as well. Refs #236 --- src/runtime/ct_runtime_helpers.h | 4 ++ src/runtime/windows/ct_runtime_vtable.cpp | 56 +++++++++++------------ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/runtime/ct_runtime_helpers.h b/src/runtime/ct_runtime_helpers.h index bc1ec428..93d7ba3f 100644 --- a/src/runtime/ct_runtime_helpers.h +++ b/src/runtime/ct_runtime_helpers.h @@ -78,6 +78,10 @@ CT_NODISCARD bool ct_dbghelp_sync_modules_locked(HANDLE process); // held. CT_NODISCARD bool ct_dbghelp_module_current_locked(HANDLE process, DWORD64 address); +// Whether the `size` bytes at `addr` are committed memory of a single region, neither inaccessible +// nor a guard page (windows/ct_runtime_vtable.cpp). +CT_NODISCARD bool ct_is_readable(const void* addr, size_t size); + // UnDecorateSymbolName, with the DbgHelp lock: Unavailable when the lock was not free in time. inline CtDemangleOutcome ct_demangle_outcome(const char* name, std::string& out) { diff --git a/src/runtime/windows/ct_runtime_vtable.cpp b/src/runtime/windows/ct_runtime_vtable.cpp index 83174058..c27dc895 100644 --- a/src/runtime/windows/ct_runtime_vtable.cpp +++ b/src/runtime/windows/ct_runtime_vtable.cpp @@ -28,6 +28,34 @@ // virtual calls. #pragma init_seg(lib) +CT_NODISCARD CT_NOINSTR bool ct_is_readable(const void* addr, size_t size) +{ + if (!addr || size == 0) + { + return false; + } + + MEMORY_BASIC_INFORMATION mbi = {}; + if (VirtualQuery(addr, &mbi, sizeof(mbi)) == 0) + { + return false; + } + + if (mbi.State != MEM_COMMIT) + { + return false; + } + if ((mbi.Protect & PAGE_NOACCESS) != 0 || (mbi.Protect & PAGE_GUARD) != 0) + { + return false; + } + + const uintptr_t start = reinterpret_cast(addr); + const uintptr_t end = start + size; + const uintptr_t region_end = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; + return end >= start && end <= region_end; +} + namespace { constexpr size_t kBoxMaxValueWidth = 60; @@ -107,34 +135,6 @@ namespace normalized == PAGE_EXECUTE_READWRITE || normalized == PAGE_EXECUTE_WRITECOPY; } - CT_NODISCARD CT_NOINSTR bool ct_is_readable(const void* addr, size_t size) - { - if (!addr || size == 0) - { - return false; - } - - MEMORY_BASIC_INFORMATION mbi = {}; - if (VirtualQuery(addr, &mbi, sizeof(mbi)) == 0) - { - return false; - } - - if (mbi.State != MEM_COMMIT) - { - return false; - } - if ((mbi.Protect & PAGE_NOACCESS) != 0 || (mbi.Protect & PAGE_GUARD) != 0) - { - return false; - } - - const uintptr_t start = reinterpret_cast(addr); - const uintptr_t end = start + size; - const uintptr_t region_end = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; - return end >= start && end <= region_end; - } - CT_NODISCARD CT_NOINSTR bool ct_lookup_symbol_name(const void* addr, std::string& out) { if (!addr) From 258bce425b2b9f271f82397149b496e4d0c0ccd9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:09:32 +0100 Subject: [PATCH 17/59] fix(runtime): read a module's headers only once found readable A copy of the runtime whose handler is no longer the process's reads the headers of the loaded modules to find the copy whose handler went over its own. A packer or an anti-tamper check may erase a module's headers, or make their page unreadable: the read then faulted inside DllMain, while FreeLibrary held the loader lock. The copy now checks each part with ct_is_readable, and the DOS and PE signatures, before it reads further, and skips the module otherwise. A record must also lie in a writable section, since the copy updates it. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 32 +++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 33dc290b..b71eec15 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -120,26 +120,42 @@ namespace constexpr DWORD kModulesLookedThrough = 1024; // The record of the copy of the runtime in `module`, or null when the module holds none: it - // links no copy, or one that keeps no record. Reads the module's headers, which the loader maps - // with the image, and the section of the record, which a copy keeps readable. + // links no copy, or one that keeps no record. The loader maps a module's headers with its image, + // but a packer or an anti-tamper check may have erased them, or made them unreadable: each part + // is read once found readable, and must look like what it is. CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_in(HMODULE module) { auto* image = reinterpret_cast(module); - const auto* headers = reinterpret_cast( - image + reinterpret_cast(image)->e_lfanew); + const auto* dos = reinterpret_cast(image); + if (!ct_is_readable(dos, sizeof(*dos)) || dos->e_magic != IMAGE_DOS_SIGNATURE || + dos->e_lfanew <= 0) + { + return nullptr; + } + const auto* headers = reinterpret_cast(image + dos->e_lfanew); + if (!ct_is_readable(headers, sizeof(*headers)) || headers->Signature != IMAGE_NT_SIGNATURE) + { + return nullptr; + } const IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(headers); - for (WORD index = 0; index < headers->FileHeader.NumberOfSections; ++index, ++section) + const WORD sections = headers->FileHeader.NumberOfSections; + if (!ct_is_readable(section, sections * sizeof(IMAGE_SECTION_HEADER))) + { + return nullptr; + } + for (WORD index = 0; index < sections; ++index, ++section) { if (std::memcmp(section->Name, kCrashHandlerSection, sizeof(section->Name)) != 0) { continue; } - if ((section->Characteristics & IMAGE_SCN_MEM_READ) == 0 || - section->Misc.VirtualSize < sizeof(CtCrashHandlerRecord)) + auto* record = reinterpret_cast(image + section->VirtualAddress); + if ((section->Characteristics & IMAGE_SCN_MEM_WRITE) == 0 || + section->Misc.VirtualSize < sizeof(CtCrashHandlerRecord) || + !ct_is_readable(record, sizeof(*record))) { return nullptr; } - auto* record = reinterpret_cast(image + section->VirtualAddress); return record->magic == kCrashHandlerMagic ? record : nullptr; } return nullptr; From 0a0da663193a0bb8d7d88a576c48fb39f3987daa Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:10:00 +0100 Subject: [PATCH 18/59] test(runtime): check a DLL unloading past unreadable module headers A DLL without the runtime has the page of its headers made unreadable, as a packer or an anti-tamper check can. The DLL that links the runtime, under the program's filter, then unloads on a thread of its own: its copy, looking for the copy above, must skip that module rather than fault while FreeLibrary holds the loader lock. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 707aa621..0a956cea 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1489,4 +1489,36 @@ namespace EXPECT_EXIT(faultAfterTwoDllsUnloadedAroundTheHostsFilter(), endedByTheFault(), crashBacktraceFrom("handler:2 ")); } + + // Loads a DLL without the runtime and the DLL that links it, installs the host's filter over the + // copy's handler, then makes the page of the other DLL's headers unreadable, as a packer or an + // anti-tamper check can. Unloads the DLL that links the runtime on a thread of its own, where + // nothing but the host's filter would catch a fault, then faults. Prints why and exits with 2 + // when the page stays readable. + [[noreturn]] void faultAfterUnloadingPastUnreadableHeaders() + { + HMODULE other = LoadLibraryA(CT_RELOAD_LIBRARY_A); + HMODULE runtime = loadRuntimeLibrary(); + SetUnhandledExceptionFilter(&hostFilter); + DWORD protection = 0; + if (!other || !VirtualProtect(other, 1, PAGE_NOACCESS, &protection)) + { + std::fprintf(stderr, "the headers of the other library stayed readable\n"); + std::exit(2); + } + std::thread(unloadRuntimeLibrary, runtime).join(); + faultOnAThread(&raiseUnhandledException); + } + + // The copy, whose handler is no longer the process's, looks through the modules for the copy + // whose handler went over its own: it skips one whose headers it cannot read, rather than fault + // while FreeLibrary unloads its DLL, and the host's filter gets the fault's exception (#236). + TEST(BacktraceReplaceDeathTest, ADllUnloadsPastAModuleWhoseHeadersAreUnreadable) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultAfterUnloadingPastUnreadableHeaders(), + testing::ExitedWithCode(static_cast(kHostFilterExit)), + "host filter e0000225"); + } } // namespace From da5949e5b5745a7079ae8d68e45c47545f174780 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:10:37 +0100 Subject: [PATCH 19/59] fix(runtime): look through every module the loader lists The copy of the runtime that unloads looked for the copy above among the first 1024 modules only. That copy loaded after this one, so it comes late in the list, where a process with more modules cut it off, and it then put back a handler whose code was gone. The copy now asks how many modules the loader lists, and reads them all into a block of the process's heap, which DllMain may allocate. The comment says that a DLL calling exit() runs this without the loader lock. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 37 ++++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index b71eec15..9bfb3de9 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -115,10 +115,6 @@ namespace __declspec(allocate(".ctcrash")) constinit CtCrashHandlerRecord ct_crash_handler_record{ kCrashHandlerMagic, nullptr}; - // The modules that a copy looks through for the record of the copy whose handler went over its - // own: the first ones that the loader lists. - constexpr DWORD kModulesLookedThrough = 1024; - // The record of the copy of the runtime in `module`, or null when the module holds none: it // links no copy, or one that keeps no record. The loader maps a module's headers with its image, // but a packer or an anti-tamper check may have erased them, or made them unreadable: each part @@ -192,25 +188,36 @@ namespace SetUnhandledExceptionFilter(current); // The copy whose handler went over this one's may be under a filter of the program, // which keeps the handler it replaced to itself: the copy is looked for among the - // modules, which no thread unloads meanwhile, FreeLibrary holding the loader lock. - HMODULE modules[kModulesLookedThrough]; + // modules that the loader lists, in a block of the process's heap, which DllMain may + // allocate. FreeLibrary holds the loader lock meanwhile, so that no thread unloads one; + // a DLL that calls exit() runs this without it, and reads only what it finds readable. + const HANDLE process = GetCurrentProcess(); + HMODULE first = nullptr; DWORD size = 0; - if (!EnumProcessModules(GetCurrentProcess(), modules, sizeof(modules), &size)) + if (!EnumProcessModules(process, &first, sizeof(first), &size) || size == 0) { return; } - const DWORD count = - std::min(static_cast(size / sizeof(HMODULE)), kModulesLookedThrough); - for (DWORD index = 0; index < count; ++index) + auto* modules = static_cast(HeapAlloc(GetProcessHeap(), 0, size)); + DWORD listed = 0; + if (modules && EnumProcessModules(process, modules, size, &listed)) { - CtCrashHandlerRecord* above = ct_crash_handler_record_in(modules[index]); - LPTOP_LEVEL_EXCEPTION_FILTER handler = &ct_exception_filter; - if (above && above->replaced.compare_exchange_strong(handler, replaced, - std::memory_order_acq_rel)) + const DWORD count = static_cast(std::min(size, listed) / sizeof(HMODULE)); + for (DWORD index = 0; index < count; ++index) { - return; + CtCrashHandlerRecord* above = ct_crash_handler_record_in(modules[index]); + LPTOP_LEVEL_EXCEPTION_FILTER handler = &ct_exception_filter; + if (above && above->replaced.compare_exchange_strong(handler, replaced, + std::memory_order_acq_rel)) + { + break; + } } } + if (modules) + { + HeapFree(GetProcessHeap(), 0, modules); + } } }; From 37c5c38ff242dc57bb6458be9cda9810a80e43b5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:11:14 +0100 Subject: [PATCH 20/59] fix(runtime): hand the filter over to every copy that holds the handler The copy of the runtime that unloads gave the filter it replaced to the first copy found whose record held its handler, and looked for one only when its handler was no longer the process's. A program that puts back a filter it read earlier, over the handlers of copies loaded since, can leave two records holding the handler, or one whose copy's handler is not the process's while the unloading one's is. The copy now hands the filter over to every record that holds its handler, whether its handler was still the process's or not. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 82 +++++++++++--------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 9bfb3de9..17ee3644 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -157,10 +157,48 @@ namespace return nullptr; } + // Gives `replaced`, the filter that this copy's handler went over, to every copy of the runtime + // whose handler went over this one's, so that none puts back a handler whose code is gone when + // its own DLL unloads. That is one copy at most, unless the program put back, over the handlers + // of copies loaded since, a filter it had read earlier. A filter of the program installed over + // such a copy's handler hides it from the process's filter: the copies are looked for among + // the modules that the loader lists, in a block of the process's heap, which DllMain may + // allocate. FreeLibrary holds the loader lock meanwhile, so that no thread unloads one; a DLL + // that calls exit() runs this without it, and reads only what it finds readable. + CT_NOINSTR void ct_crash_handler_hand_over(LPTOP_LEVEL_EXCEPTION_FILTER replaced) + { + const HANDLE process = GetCurrentProcess(); + HMODULE first = nullptr; + DWORD size = 0; + if (!EnumProcessModules(process, &first, sizeof(first), &size) || size == 0) + { + return; + } + auto* modules = static_cast(HeapAlloc(GetProcessHeap(), 0, size)); + DWORD listed = 0; + if (modules && EnumProcessModules(process, modules, size, &listed)) + { + const DWORD count = static_cast(std::min(size, listed) / sizeof(HMODULE)); + for (DWORD index = 0; index < count; ++index) + { + CtCrashHandlerRecord* above = ct_crash_handler_record_in(modules[index]); + LPTOP_LEVEL_EXCEPTION_FILTER handler = &ct_exception_filter; + if (above) + { + (void)above->replaced.compare_exchange_strong(handler, replaced, + std::memory_order_acq_rel); + } + } + } + if (modules) + { + HeapFree(GetProcessHeap(), 0, modules); + } + } + // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that - // holds the copy, if the handler is still the process's. A filter installed over it since stays: - // the program's, or the handler of another DLL's copy, which gets the filter put back instead, - // so as not to put back a handler whose code is gone when it unloads in turn. Windows has no + // holds the copy, if the handler is still the process's: a filter installed over it since + // stays. The copies whose handlers went over this one's get that filter instead. Windows has no // call that reads the process's filter without setting one: a filter that another thread // installs between the two calls below is replaced. Nothing changes when the process // terminates: the DLL stays mapped until then. @@ -181,43 +219,11 @@ namespace const LPTOP_LEVEL_EXCEPTION_FILTER replaced = ct_crash_handler_record.replaced.load(std::memory_order_acquire); const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(replaced); - if (current == &ct_exception_filter) - { - return; - } - SetUnhandledExceptionFilter(current); - // The copy whose handler went over this one's may be under a filter of the program, - // which keeps the handler it replaced to itself: the copy is looked for among the - // modules that the loader lists, in a block of the process's heap, which DllMain may - // allocate. FreeLibrary holds the loader lock meanwhile, so that no thread unloads one; - // a DLL that calls exit() runs this without it, and reads only what it finds readable. - const HANDLE process = GetCurrentProcess(); - HMODULE first = nullptr; - DWORD size = 0; - if (!EnumProcessModules(process, &first, sizeof(first), &size) || size == 0) - { - return; - } - auto* modules = static_cast(HeapAlloc(GetProcessHeap(), 0, size)); - DWORD listed = 0; - if (modules && EnumProcessModules(process, modules, size, &listed)) - { - const DWORD count = static_cast(std::min(size, listed) / sizeof(HMODULE)); - for (DWORD index = 0; index < count; ++index) - { - CtCrashHandlerRecord* above = ct_crash_handler_record_in(modules[index]); - LPTOP_LEVEL_EXCEPTION_FILTER handler = &ct_exception_filter; - if (above && above->replaced.compare_exchange_strong(handler, replaced, - std::memory_order_acq_rel)) - { - break; - } - } - } - if (modules) + if (current != &ct_exception_filter) { - HeapFree(GetProcessHeap(), 0, modules); + SetUnhandledExceptionFilter(current); } + ct_crash_handler_hand_over(replaced); } }; From b0b9a7f55901c7a57343bc9923fc4cc13dd49771 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:11:33 +0100 Subject: [PATCH 21/59] test(runtime): check two copies whose handlers replaced one handler A program that puts back a filter it read earlier, the first copy's handler, over the second copy's, then loads a third copy, leaves the second and the third copies' records both holding the first one's handler. Once the first copy unloads, neither the third nor the second copy may put that handler back when they unload in turn, whatever the order in which the loader lists their modules. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 0a956cea..d103dbc1 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1521,4 +1521,50 @@ namespace testing::ExitedWithCode(static_cast(kHostFilterExit)), "host filter e0000225"); } + + // The process's filter, read the only way Windows allows: by setting another one, then putting + // it back. + LPTOP_LEVEL_EXCEPTION_FILTER currentFilter() + { + const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(&hostFilter); + SetUnhandledExceptionFilter(current); + return current; + } + + // Loads a copy of the DLL that links the runtime, then a second one. The program puts the first + // copy's handler back over the second's, as one that restores a filter it read earlier does, + // and a third copy loads: the handlers of the second and the third copies both went over the + // first one's. The first copy unloads, then the third, then the second, once the program has + // put its handler back too. Exits with 3 when the process's filter is not then the one that + // the first copy's handler went over, with 0 otherwise. + [[noreturn]] void unloadTwoCopiesThatReplacedOneHandler() + { + const LPTOP_LEVEL_EXCEPTION_FILTER executable = currentFilter(); + RuntimeLibraryCopy first("first"); + const LPTOP_LEVEL_EXCEPTION_FILTER first_handler = currentFilter(); + RuntimeLibraryCopy second("second"); + const LPTOP_LEVEL_EXCEPTION_FILTER second_handler = currentFilter(); + SetUnhandledExceptionFilter(first_handler); + RuntimeLibraryCopy third("third"); + first.unload(); + third.unload(); + const bool after_the_third = currentFilter() == executable; + SetUnhandledExceptionFilter(second_handler); + second.unload(); + if (!after_the_third || currentFilter() != executable) + { + std::fprintf(stderr, "a copy put back the handler of the first, unloaded\n"); + std::exit(3); + } + std::exit(0); + } + + // Every copy whose handler went over the handler of a copy that unloads gets the filter that + // the latter went over, whatever the order in which the loader lists their modules (#236). + TEST(BacktraceReplaceDeathTest, TwoCopiesThatReplacedOneHandlerGetWhatItReplaced) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(unloadTwoCopiesThatReplacedOneHandler(), testing::ExitedWithCode(0), ""); + } } // namespace From b592e53fb1c024a3da69a408526e673929de8740 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:41:24 +0100 Subject: [PATCH 22/59] docs(readme): describe the crash handler of a DLL's copy With CT_BACKTRACE, each copy of the runtime installs its crash handler over the filter installed before it. The README says when a DLL's copy puts that filter back, and what becomes of a filter installed over the handler since: the program's own, or another copy's handler. Refs #236 --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index cda69d47..f4bae35a 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,22 @@ runtime as follows: have unloaded. Once that copy has stopped following the loader, when the module that holds it detaches, the handler prints every frame bare. +## Crash handler on Windows + +With `CT_BACKTRACE` set, each copy of the runtime, the executable's and that of each DLL that links +the runtime, installs its crash handler for the whole process with `SetUnhandledExceptionFilter` +when its module initializes, over the filter installed before it: the handler of another copy, or a +filter of the program. When `FreeLibrary` unloads a DLL, its copy puts that filter back if its +handler is still the process's. A filter installed over the handler since stays: + +- a filter of the program stays the process's. Once the DLL is unloaded, it must not call the + handler it replaced, whose code is gone; +- the handler of another DLL's copy gets the filter that the unloaded copy had replaced, and puts it + back when its own DLL unloads. + +Each copy keeps the filter it replaced in a section `.ctcrash` of its module, where the other copies +find it. Nothing changes when the process terminates, nor in the executable's copy (#236). + ## LLVM versions CoreTrace Compiler builds and instruments with LLVM 16 to 23. Most behaviour is the same on From 6064c2da1033d7f62f8bff3ff1a005236179be86 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 01:41:24 +0100 Subject: [PATCH 23/59] docs(changelog): note the crash handler left by an unloaded DLL Refs #236 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17433252..7aa83661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,14 @@ pull request. allocated, and each load of the DLL built another one: 488 bytes per load and unload in the measurement of #227, whose DLL decoded two names. The copy now frees the cache when `FreeLibrary` unloads the DLL, after the DLL's destructors, whose boxes still name types (#227). +- **The crash handler of an unloaded DLL, on Windows.** With `CT_BACKTRACE`, the copy of the + runtime in a DLL that links it installs its crash handler over the filter installed before it, + and nothing put that filter back. Once `FreeLibrary` had unloaded the DLL, an exception that + nothing handled ended the process without a backtrace: neither the executable's handler nor a + filter that the program had installed before the DLL ran. The copy now puts the filter back when + `FreeLibrary` unloads the DLL, if its handler is still the process's. A filter installed over it + since stays; when that is the handler of another DLL's copy, that copy gets the filter, and puts + it back when its own DLL unloads (README, "Crash handler on Windows") (#236). ### Tests and CI From 779dcd8c4435c2887ea52b95573798f756e5f609 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 02:38:14 +0100 Subject: [PATCH 24/59] test(runtime): watch for a read of the unreadable headers The test of a DLL unloading past unreadable module headers passed without the readability checks too: the access violation that the read raised during FreeLibrary did not reach the program's filter. A vectored handler, which sees every exception before any other handler, now records an access violation in the unreadable page while the DLL unloads, and the child exits with 4 when there was one. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index d103dbc1..21b64fdc 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1490,23 +1490,53 @@ namespace crashBacktraceFrom("handler:2 ")); } + // The page that a test made unreadable, and whether an access violation in it has been raised + // since, handled or not: a vectored handler sees every exception before any other handler. + std::atomic unreadable_page{0}; + std::atomic unreadable_page_size{0}; + std::atomic unreadable_page_read{false}; + + LONG CALLBACK watchTheUnreadablePage(EXCEPTION_POINTERS* exception_info) + { + const EXCEPTION_RECORD* exception = exception_info->ExceptionRecord; + if (exception->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && + exception->NumberParameters >= 2 && + exception->ExceptionInformation[1] - unreadable_page.load() < + unreadable_page_size.load()) + { + unreadable_page_read.store(true); + } + return EXCEPTION_CONTINUE_SEARCH; + } + // Loads a DLL without the runtime and the DLL that links it, installs the host's filter over the // copy's handler, then makes the page of the other DLL's headers unreadable, as a packer or an // anti-tamper check can. Unloads the DLL that links the runtime on a thread of its own, where // nothing but the host's filter would catch a fault, then faults. Prints why and exits with 2 - // when the page stays readable. + // when the page stays readable, with 4 when the unload read it, whoever handled the fault. [[noreturn]] void faultAfterUnloadingPastUnreadableHeaders() { HMODULE other = LoadLibraryA(CT_RELOAD_LIBRARY_A); HMODULE runtime = loadRuntimeLibrary(); SetUnhandledExceptionFilter(&hostFilter); + SYSTEM_INFO system = {}; + GetSystemInfo(&system); + unreadable_page_size.store(system.dwPageSize); + unreadable_page.store(reinterpret_cast(other)); DWORD protection = 0; if (!other || !VirtualProtect(other, 1, PAGE_NOACCESS, &protection)) { std::fprintf(stderr, "the headers of the other library stayed readable\n"); std::exit(2); } + PVOID watch = AddVectoredExceptionHandler(1, &watchTheUnreadablePage); std::thread(unloadRuntimeLibrary, runtime).join(); + RemoveVectoredExceptionHandler(watch); + if (unreadable_page_read.load()) + { + std::fprintf(stderr, "the unload read the unreadable headers\n"); + std::exit(4); + } faultOnAThread(&raiseUnhandledException); } From a765c3d5dbf0cb0eacd13aea3f51c1cbd0f9be5a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:38:19 +0100 Subject: [PATCH 25/59] fix(runtime): leave the crash filter alone while a DLL's exit() runs A DLL that calls its own C runtime's exit() runs its destructors before the loader detaches it, without the loader lock: the release of its copy's crash handler then walked the modules while other threads could still unload them, although the DLL stays mapped until the process ends. The TLS callback of windows/ct_runtime_shutdown.cpp now records the loader's DLL_PROCESS_DETACH, which comes before the module's entry point and the destructors that the C runtime runs from it, and the release runs only once the loader detaches the DLL. Refs #236 --- src/runtime/ct_runtime_internal.h | 6 ++++++ src/runtime/windows/ct_runtime_backtrace.cpp | 8 ++++---- src/runtime/windows/ct_runtime_shutdown.cpp | 16 +++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/runtime/ct_runtime_internal.h b/src/runtime/ct_runtime_internal.h index d92558ef..d776d6cf 100644 --- a/src/runtime/ct_runtime_internal.h +++ b/src/runtime/ct_runtime_internal.h @@ -461,6 +461,12 @@ CT_NODISCARD CT_NOINSTR CtProcessShutdown ct_process_shutdown(void); // its C runtime runs that code before the loader reports the termination, so its copy frees what // it holds then too. CT_NODISCARD CT_NOINSTR bool ct_dll_unloading_dynamically(void); + +// Whether the loader has started to detach the module that holds this copy of the runtime, as when +// FreeLibrary unloads a DLL, with the loader lock held. False while a DLL's own exit() runs the +// destructors that the C runtime otherwise runs from the loader's detach, before the loader +// detaches the DLL and without its lock (#236). +CT_NODISCARD CT_NOINSTR bool ct_loader_detaching_module(void); #endif CT_NOINSTR void ct_log_alloc_details(const char* label, const char* status, size_t req_size, diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 17ee3644..b8cba6ff 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -163,8 +163,7 @@ namespace // of copies loaded since, a filter it had read earlier. A filter of the program installed over // such a copy's handler hides it from the process's filter: the copies are looked for among // the modules that the loader lists, in a block of the process's heap, which DllMain may - // allocate. FreeLibrary holds the loader lock meanwhile, so that no thread unloads one; a DLL - // that calls exit() runs this without it, and reads only what it finds readable. + // allocate. FreeLibrary holds the loader lock meanwhile, so that no thread unloads one. CT_NOINSTR void ct_crash_handler_hand_over(LPTOP_LEVEL_EXCEPTION_FILTER replaced) { const HANDLE process = GetCurrentProcess(); @@ -201,7 +200,8 @@ namespace // stays. The copies whose handlers went over this one's get that filter instead. Windows has no // call that reads the process's filter without setting one: a filter that another thread // installs between the two calls below is replaced. Nothing changes when the process - // terminates: the DLL stays mapped until then. + // terminates, nor while a DLL's own exit() runs this destructor, before the loader detaches the + // DLL and without the loader lock: the DLL stays mapped until the process ends. struct CtCrashHandlerRelease { // User-provided, so that the object is initialized dynamically, in this file's @@ -212,7 +212,7 @@ namespace CT_NOINSTR ~CtCrashHandlerRelease() { if (ct_backtrace_installed.load(std::memory_order_acquire) == 0 || - !ct_dll_unloading_dynamically()) + !ct_dll_unloading_dynamically() || !ct_loader_detaching_module()) { return; } diff --git a/src/runtime/windows/ct_runtime_shutdown.cpp b/src/runtime/windows/ct_runtime_shutdown.cpp index c09cb786..d57e4292 100644 --- a/src/runtime/windows/ct_runtime_shutdown.cpp +++ b/src/runtime/windows/ct_runtime_shutdown.cpp @@ -4,7 +4,8 @@ // while the module detaches: the leak report of a DLL's copy, and the release of its DbgHelp // session and of its names. That code cannot tell from lpvReserved: only DllMain receives it // non-null at process termination, the TLS callbacks receive it null then too (#226, #232). -// ct_dll_unloading_dynamically tells whether that code frees what the copy holds. +// ct_dll_unloading_dynamically tells whether that code frees what the copy holds, and +// ct_loader_detaching_module whether the loader runs it, rather than a DLL's own exit() (#236). #include "ct_runtime_internal.h" #include @@ -21,6 +22,10 @@ namespace using CtShutdownInProgress = BOOLEAN(NTAPI*)(); std::atomic ct_shutdown_in_progress{nullptr}; + // Set when the loader detaches the module: its TLS callbacks receive DLL_PROCESS_DETACH before + // its entry point, from which the C runtime runs the module's destructors (#232). + std::atomic ct_loader_detaching{false}; + CT_NOINSTR void NTAPI ct_shutdown_tls(PVOID, DWORD reason, PVOID) { if (reason == DLL_PROCESS_ATTACH) @@ -30,6 +35,10 @@ namespace GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlDllShutdownInProgress")), std::memory_order_release); } + else if (reason == DLL_PROCESS_DETACH) + { + ct_loader_detaching.store(true, std::memory_order_release); + } } } // namespace @@ -51,6 +60,11 @@ CT_NODISCARD CT_NOINSTR bool ct_dll_unloading_dynamically(void) ct_process_shutdown() == CtProcessShutdown::NotInProgress; } +CT_NODISCARD CT_NOINSTR bool ct_loader_detaching_module(void) +{ + return ct_loader_detaching.load(std::memory_order_acquire); +} + // The TLS callback that looks RtlDllShutdownInProgress up, in the file of ct_process_shutdown, so // that a module calling one links the other. #pragma comment(linker, "/INCLUDE:_tls_used") From 7be3ce32106730d1110f4628b70a047b98a990dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:39:12 +0100 Subject: [PATCH 26/59] fix(runtime): link the crash handler records when a DLL's copy installs The release of a DLL's copy found the copies whose handlers went over its own by listing the modules into a block of the process's heap: when the allocation or the listing failed, nothing was handed over, and a copy loaded later put back the unloaded copy's handler. Each DLL's copy now links its record, when it installs its handler, to the record of the copy whose handler it went over, found from that handler's address. Both happen with the loader lock held, at the DLL's attach and at FreeLibrary's detach: the release follows the links, takes the copy out of the list below it, and hands the filter it replaced to the copies above, without allocating or listing the modules. The executable's copy links to none. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 150 ++++++++++++------- 1 file changed, 94 insertions(+), 56 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index b8cba6ff..224c88b3 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -7,7 +7,6 @@ #include "ct_runtime_internal.h" #include "ct_runtime_helpers.h" -#include #include #include #include @@ -19,10 +18,12 @@ #endif #include #include -#include #pragma comment(lib, "Dbghelp.lib") +// The module that holds this copy of the runtime. +extern "C" IMAGE_DOS_HEADER __ImageBase; + // The objects of this file are constructed before the program's and destroyed after them: a DLL's // copy puts the filter back after the destructors of the DLL's global objects, whose crashes its // handler still reports. @@ -98,29 +99,54 @@ namespace return EXCEPTION_EXECUTE_HANDLER; } - // What a copy of the runtime tells the other copies in the process of its crash handler: the - // filter that the handler went over when the copy installed it. A copy finds another one's - // record in the section .ctcrash of the module that holds it. The magic number reads - // "ctcrash", then the version of the layout. + // What a copy of the runtime tells the other copies in the process of its crash handler, in the + // section .ctcrash of its module, where a copy finds another one's record from the address of + // its handler. `replaced` is the filter that the handler went over when the copy installed it, + // and `below` the record of the copy whose handler that filter is, if any. `above` is the first + // of the copies whose handlers went over this one's, and `next` the following one over the same + // copy. A DLL's copy makes its links when it installs its handler and undoes them when + // FreeLibrary unloads the DLL, both with the loader lock held: a record is linked only while its + // module is loaded, and the release follows the links without allocating or listing the + // modules. The executable's copy, which installs its handler without the loader lock and keeps + // it as long as the process, links to none. The magic number reads "ctcrash", then the version + // of the layout. struct CtCrashHandlerRecord { std::uint64_t magic; - std::atomic replaced; + LPTOP_LEVEL_EXCEPTION_FILTER handler; + LPTOP_LEVEL_EXCEPTION_FILTER replaced; + CtCrashHandlerRecord* below; + CtCrashHandlerRecord* above; + CtCrashHandlerRecord* next; }; - constexpr std::uint64_t kCrashHandlerMagic = 0x0168736172637463; + constexpr std::uint64_t kCrashHandlerMagic = 0x0268736172637463; constexpr char kCrashHandlerSection[IMAGE_SIZEOF_SHORT_NAME] = {'.', 'c', 't', 'c', 'r', 'a', 's', 'h'}; __declspec(allocate(".ctcrash")) constinit CtCrashHandlerRecord ct_crash_handler_record{ - kCrashHandlerMagic, nullptr}; + kCrashHandlerMagic, &ct_exception_filter, nullptr, nullptr, nullptr, nullptr}; + + // Whether this copy's handler is installed, with its record: set once, when the module + // initializes, and read when it detaches. + bool ct_crash_handler_in_place = false; - // The record of the copy of the runtime in `module`, or null when the module holds none: it - // links no copy, or one that keeps no record. The loader maps a module's headers with its image, - // but a packer or an anti-tamper check may have erased them, or made them unreadable: each part - // is read once found readable, and must look like what it is. - CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_in(HMODULE module) + // The record of the copy of the runtime whose handler `filter` is, if any. `readable` is false + // when the headers of the module that holds `filter` cannot be read, so that whether it is a + // copy's handler cannot be told: a packer or an anti-tamper check may have erased them, or made + // them unreadable. Each part is read once found readable, and must look like what it is. + CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_of(LPTOP_LEVEL_EXCEPTION_FILTER filter, + bool& readable) { + readable = true; + HMODULE module = nullptr; + if (!filter || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(filter), &module)) + { + return nullptr; + } + readable = false; auto* image = reinterpret_cast(module); const auto* dos = reinterpret_cast(image); if (!ct_is_readable(dos, sizeof(*dos)) || dos->e_magic != IMAGE_DOS_SIGNATURE || @@ -152,56 +178,56 @@ namespace { return nullptr; } - return record->magic == kCrashHandlerMagic ? record : nullptr; + readable = true; + return record->magic == kCrashHandlerMagic && record->handler == filter ? record + : nullptr; } + readable = true; return nullptr; } - // Gives `replaced`, the filter that this copy's handler went over, to every copy of the runtime - // whose handler went over this one's, so that none puts back a handler whose code is gone when - // its own DLL unloads. That is one copy at most, unless the program put back, over the handlers - // of copies loaded since, a filter it had read earlier. A filter of the program installed over - // such a copy's handler hides it from the process's filter: the copies are looked for among - // the modules that the loader lists, in a block of the process's heap, which DllMain may - // allocate. FreeLibrary holds the loader lock meanwhile, so that no thread unloads one. - CT_NOINSTR void ct_crash_handler_hand_over(LPTOP_LEVEL_EXCEPTION_FILTER replaced) + // Takes `record` out of the links when FreeLibrary unloads the DLL that holds its copy: out of + // the list of the copy below, and the copies above get the filter that its handler replaced, + // and that copy below as theirs, so that none puts back the unloading copy's handler, whose + // code is gone, when its own DLL unloads. FreeLibrary holds the loader lock meanwhile: every + // record linked is in a module still loaded. + CT_NOINSTR void ct_crash_handler_unlink(CtCrashHandlerRecord& record) { - const HANDLE process = GetCurrentProcess(); - HMODULE first = nullptr; - DWORD size = 0; - if (!EnumProcessModules(process, &first, sizeof(first), &size) || size == 0) - { - return; - } - auto* modules = static_cast(HeapAlloc(GetProcessHeap(), 0, size)); - DWORD listed = 0; - if (modules && EnumProcessModules(process, modules, size, &listed)) + if (record.below) { - const DWORD count = static_cast(std::min(size, listed) / sizeof(HMODULE)); - for (DWORD index = 0; index < count; ++index) + for (CtCrashHandlerRecord** link = &record.below->above; *link; link = &(*link)->next) { - CtCrashHandlerRecord* above = ct_crash_handler_record_in(modules[index]); - LPTOP_LEVEL_EXCEPTION_FILTER handler = &ct_exception_filter; - if (above) + if (*link == &record) { - (void)above->replaced.compare_exchange_strong(handler, replaced, - std::memory_order_acq_rel); + *link = record.next; + break; } } } - if (modules) + for (CtCrashHandlerRecord* above = record.above; above;) { - HeapFree(GetProcessHeap(), 0, modules); + CtCrashHandlerRecord* const next = above->next; + above->replaced = record.replaced; + above->below = record.below; + above->next = record.below ? record.below->above : nullptr; + if (record.below) + { + record.below->above = above; + } + above = next; } + record.below = nullptr; + record.above = nullptr; + record.next = nullptr; } // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that // holds the copy, if the handler is still the process's: a filter installed over it since - // stays. The copies whose handlers went over this one's get that filter instead. Windows has no - // call that reads the process's filter without setting one: a filter that another thread - // installs between the two calls below is replaced. Nothing changes when the process - // terminates, nor while a DLL's own exit() runs this destructor, before the loader detaches the - // DLL and without the loader lock: the DLL stays mapped until the process ends. + // stays. Then takes the copy's record out of the links. Windows has no call that reads the + // process's filter without setting one: a filter that another thread installs between the two + // calls below is replaced. Nothing changes when the process terminates, nor while a DLL's own + // exit() runs this destructor, before the loader detaches the DLL and without the loader lock: + // the DLL stays mapped until the process ends. struct CtCrashHandlerRelease { // User-provided, so that the object is initialized dynamically, in this file's @@ -211,19 +237,19 @@ namespace CT_NOINSTR ~CtCrashHandlerRelease() { - if (ct_backtrace_installed.load(std::memory_order_acquire) == 0 || - !ct_dll_unloading_dynamically() || !ct_loader_detaching_module()) + if (!ct_crash_handler_in_place || !ct_dll_unloading_dynamically() || + !ct_loader_detaching_module()) { return; } - const LPTOP_LEVEL_EXCEPTION_FILTER replaced = - ct_crash_handler_record.replaced.load(std::memory_order_acquire); - const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(replaced); - if (current != &ct_exception_filter) + CtCrashHandlerRecord& record = ct_crash_handler_record; + const LPTOP_LEVEL_EXCEPTION_FILTER current = + SetUnhandledExceptionFilter(record.replaced); + if (current != record.handler) { SetUnhandledExceptionFilter(current); } - ct_crash_handler_hand_over(replaced); + ct_crash_handler_unlink(record); } }; @@ -255,8 +281,20 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) (void)ct_dbghelp_sync_modules_locked(process); } } - ct_crash_handler_record.replaced.store(SetUnhandledExceptionFilter(&ct_exception_filter), - std::memory_order_release); + CtCrashHandlerRecord& record = ct_crash_handler_record; + record.replaced = SetUnhandledExceptionFilter(record.handler); + if (reinterpret_cast(&__ImageBase) != GetModuleHandleW(nullptr)) + { + // A DLL initializes with the loader lock held, which keeps the links of every copy. + bool readable = true; + record.below = ct_crash_handler_record_of(record.replaced, readable); + if (record.below) + { + record.next = record.below->above; + record.below->above = &record; + } + } + ct_crash_handler_in_place = true; ct_write_prefix(CTLevel::Info); ct_write_cstr("ct: backtrace handler installed\n"); From 73a41814c28758a4a6b84992d78ddaa33fc34ce1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:43:26 +0100 Subject: [PATCH 27/59] fix(runtime): put back a filter installed during a handler's release The release of a DLL's copy sets the filter its handler replaced, then puts back the filter it found there when that is not its handler. A filter that another thread installed between those two calls was lost: the second call replaced it. The second call returns that filter instead of the one the first call set: the release now puts it back in turn, and so on, so that no filter installed meanwhile is lost. The thread that installed it still got, as the filter it replaced, the one set by the release for that moment: Windows has no call that reads the process's filter, or sets it only if it is the one expected. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 40 ++++++++++++++------ 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 224c88b3..e732a8a6 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -221,13 +221,36 @@ namespace record.next = nullptr; } + // Makes `replaced` the process's filter if `handler` still is, and leaves the filter otherwise. + // Windows has no call that reads the process's filter, or sets it only if it is the one + // expected: this sets `replaced`, then puts the filter it replaced back at once when that is not + // `handler`. A filter that another thread installs between two of these calls is what the + // second returns instead of the filter set by the first: it is put back in turn, and so on. + // That thread got from SetUnhandledExceptionFilter, as the filter it replaced, the one set here + // for that moment. + CT_NOINSTR void ct_crash_handler_put_back(LPTOP_LEVEL_EXCEPTION_FILTER handler, + LPTOP_LEVEL_EXCEPTION_FILTER replaced) + { + LPTOP_LEVEL_EXCEPTION_FILTER set = replaced; + LPTOP_LEVEL_EXCEPTION_FILTER found = SetUnhandledExceptionFilter(replaced); + while (found != handler) + { + const LPTOP_LEVEL_EXCEPTION_FILTER installed_meanwhile = + SetUnhandledExceptionFilter(found); + if (installed_meanwhile == set) + { + return; + } + set = found; + found = installed_meanwhile; + } + } + // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that // holds the copy, if the handler is still the process's: a filter installed over it since - // stays. Then takes the copy's record out of the links. Windows has no call that reads the - // process's filter without setting one: a filter that another thread installs between the two - // calls below is replaced. Nothing changes when the process terminates, nor while a DLL's own - // exit() runs this destructor, before the loader detaches the DLL and without the loader lock: - // the DLL stays mapped until the process ends. + // stays. Then takes the copy's record out of the links. Nothing changes when the process + // terminates, nor while a DLL's own exit() runs this destructor, before the loader detaches the + // DLL and without the loader lock: the DLL stays mapped until the process ends. struct CtCrashHandlerRelease { // User-provided, so that the object is initialized dynamically, in this file's @@ -243,12 +266,7 @@ namespace return; } CtCrashHandlerRecord& record = ct_crash_handler_record; - const LPTOP_LEVEL_EXCEPTION_FILTER current = - SetUnhandledExceptionFilter(record.replaced); - if (current != record.handler) - { - SetUnhandledExceptionFilter(current); - } + ct_crash_handler_put_back(record.handler, record.replaced); ct_crash_handler_unlink(record); } }; From 3de9d42c8e1d73f73c230a9f7cad0d75e5ef96b1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:40:11 +0100 Subject: [PATCH 28/59] fix(runtime): keep a DLL's handler off a filter it cannot inspect A DLL's copy whose handler goes over a filter in a module whose headers cannot be read, as a packer or an anti-tamper check can leave them, cannot tell whether that filter is another copy's handler, nor link its record to it. Installed anyway, it would put that handler back once its DLL had unloaded. The copy now puts the filter back in place and leaves its own handler out, saying so. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index e732a8a6..220b7b19 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -306,6 +306,16 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) // A DLL initializes with the loader lock held, which keeps the links of every copy. bool readable = true; record.below = ct_crash_handler_record_of(record.replaced, readable); + if (!readable) + { + // The filter may be the handler of a copy that this one cannot link to, and would put + // back once its code is gone: it goes back in place, without this copy's handler. + ct_crash_handler_put_back(record.handler, record.replaced); + ct_write_prefix(CTLevel::Info); + ct_write_cstr("ct: backtrace handler not installed: the headers of the module of the " + "filter in place cannot be read\n"); + return; + } if (record.below) { record.next = record.below->above; From a9d9aa0c5d95874361dd18d4b7b97121cd382721 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:42:05 +0100 Subject: [PATCH 29/59] test(runtime): check that a DLL calling exit() leaves its handler alone The DLL of reload/ct_leak_dll.cpp, which links the runtime, ends the process with its own C runtime's exit(). Its copy's import of SetUnhandledExceptionFilter is patched to end the process with 5: the release must not run while exit() runs the DLL's destructors, before the loader detaches the DLL and without its lock. runtime_unit_tests now builds with the path of that DLL. Refs #236 --- test/unit/CMakeLists.txt | 4 ++ test/unit/runtime_vtable_replace_test.cpp | 86 +++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index f65a072c..f878dfd9 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -181,6 +181,10 @@ if(TARGET ct_instrument_runtime) foreach(target ct_leak_dll dll_leak_report_main) coretrace_force_msvc_runtime(${target}) endforeach() + # The crash handler tests load that DLL too: it runs no code of its own when FreeLibrary unloads + # it, and it can end the process with its own C runtime's exit() (#236). + target_compile_definitions(runtime_unit_tests PRIVATE CT_LEAK_DLL="$") + add_dependencies(runtime_unit_tests ct_leak_dll) endif() if(APPLE) # Objective-C object tracking: pulls ct_runtime_objc.cpp, which calls into libobjc. diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 21b64fdc..b961db2e 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1597,4 +1597,90 @@ namespace BacktraceInChildren backtrace; EXPECT_EXIT(unloadTwoCopiesThatReplacedOneHandler(), testing::ExitedWithCode(0), ""); } + + // Points the import of `function` from kernel32.dll by the loaded `module` at `replacement`, and + // returns what it pointed at, as the loader resolved it: null when `module` does not import it. + void* patchImport(HMODULE module, const char* function, void* replacement) + { + auto* image = reinterpret_cast(module); + const auto* headers = reinterpret_cast( + image + reinterpret_cast(image)->e_lfanew); + const IMAGE_DATA_DIRECTORY& imports = + headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if (imports.VirtualAddress == 0) + { + return nullptr; + } + for (auto* library = + reinterpret_cast(image + imports.VirtualAddress); + library->Name != 0; ++library) + { + if (_stricmp(reinterpret_cast(image + library->Name), "kernel32.dll") != 0) + { + continue; + } + const auto* name = + reinterpret_cast(image + library->OriginalFirstThunk); + auto* slot = reinterpret_cast(image + library->FirstThunk); + for (; name->u1.AddressOfData != 0; ++name, ++slot) + { + if (IMAGE_SNAP_BY_ORDINAL(name->u1.Ordinal) || + std::strcmp(reinterpret_cast( + image + name->u1.AddressOfData) + ->Name, + function) != 0) + { + continue; + } + DWORD protection = 0; + VirtualProtect(&slot->u1.Function, sizeof(slot->u1.Function), PAGE_READWRITE, + &protection); + void* resolved = reinterpret_cast(slot->u1.Function); + slot->u1.Function = reinterpret_cast(replacement); + VirtualProtect(&slot->u1.Function, sizeof(slot->u1.Function), protection, + &protection); + return resolved; + } + } + return nullptr; + } + + // A stand-in for SetUnhandledExceptionFilter in a copy of the runtime that must not call it: + // it ends the process with 5. + LPTOP_LEVEL_EXCEPTION_FILTER WINAPI setFilterEndsTheProcess(LPTOP_LEVEL_EXCEPTION_FILTER) + { + std::fputs("a copy set the process's filter\n", stderr); + std::fflush(stderr); + TerminateProcess(GetCurrentProcess(), 5); + return nullptr; + } + + // Loads the DLL of reload/ct_leak_dll.cpp, which links the runtime, then has it end the process + // with its own C runtime's exit(), which runs the DLL's destructors before the loader detaches + // it, without the loader lock. Its copy's SetUnhandledExceptionFilter ends the process with 5 + // if the release calls it then. Prints why and exits with 2 when that cannot be set up. + [[noreturn]] void endTheProcessFromADll() + { + HMODULE dll = LoadLibraryA(CT_LEAK_DLL); + auto* dll_exit = + dll ? reinterpret_cast(GetProcAddress(dll, "ct_leak_dll_exit")) : nullptr; + if (!dll_exit || !patchImport(dll, "SetUnhandledExceptionFilter", + reinterpret_cast(&setFilterEndsTheProcess))) + { + std::fprintf(stderr, "the leak DLL could not be set up\n"); + std::exit(2); + } + dll_exit(); + std::exit(8); + } + + // A DLL that ends the process with exit() stays mapped until then: its copy leaves its handler + // in place, rather than release it without the loader lock, while other threads may still + // unload modules (#236). + TEST(BacktraceReplaceDeathTest, ADllThatCallsExitLeavesItsHandlerInPlace) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(endTheProcessFromADll(), testing::ExitedWithCode(0), ""); + } } // namespace From 9b4a28a3e737f098528c90790918948d80ec3ff9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:42:28 +0100 Subject: [PATCH 30/59] test(runtime): check a hand-over for which nothing can be allocated A copy of the DLL of reload/ct_leak_dll.cpp, whose own code does nothing when it unloads, has its imports of HeapAlloc and K32EnumProcessModules patched to fail, as when the process heap is exhausted. Unloaded while the handler of a second copy is over its own, it must still hand the filter it replaced over to that copy: once the second copy unloads too, the executable's handler must report the crash. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 59 ++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index b961db2e..09265511 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1392,19 +1392,19 @@ namespace "host filter e0000225"); } - // A copy of the DLL that links the runtime, at a path of the child's own: the loader maps each - // copy as a module apart, with a copy of the runtime of its own. Prints why and exits with 2 - // when it does not load. + // A copy of a DLL that links the runtime, by default the one of reload/ct_runtime_library.cpp, + // at a path of the child's own: the loader maps each copy as a module apart, with a copy of the + // runtime of its own. Prints why and exits with 2 when it does not load. class RuntimeLibraryCopy { public: - explicit RuntimeLibraryCopy(const char* name) + explicit RuntimeLibraryCopy(const char* name, const char* source = CT_RUNTIME_LIBRARY) : path_(fs::path(testing::TempDir()) / - (std::string("ct_runtime_library_") + name + "_" + + (fs::path(source).stem().string() + "_" + name + "_" + std::to_string(GetCurrentProcessId()) + ".dll")) { std::error_code error; - fs::copy_file(CT_RUNTIME_LIBRARY, path_, fs::copy_options::overwrite_existing, error); + fs::copy_file(source, path_, fs::copy_options::overwrite_existing, error); module_ = error ? nullptr : LoadLibraryW(path_.c_str()); if (!module_) { @@ -1415,6 +1415,11 @@ namespace RuntimeLibraryCopy(const RuntimeLibraryCopy&) = delete; RuntimeLibraryCopy& operator=(const RuntimeLibraryCopy&) = delete; + HMODULE module() const + { + return module_; + } + // Unloads the copy, then deletes its file. void unload() { @@ -1683,4 +1688,46 @@ namespace BacktraceInChildren backtrace; EXPECT_EXIT(endTheProcessFromADll(), testing::ExitedWithCode(0), ""); } + + // Stand-ins for what kernel32.dll does in a copy of the runtime that unloads: every allocation + // from a heap and every listing of the modules fails. + LPVOID WINAPI failingHeapAlloc(HANDLE, DWORD, SIZE_T) + { + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return nullptr; + } + + BOOL WINAPI failingEnumProcessModules(HANDLE, HMODULE*, DWORD, LPDWORD) + { + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return FALSE; + } + + // Loads a copy of the DLL of reload/ct_leak_dll.cpp, which links the runtime and runs no code of + // its own when it unloads, then a copy of the DLL that links the runtime, whose handler goes + // over the first copy's. Has every allocation from a heap and every listing of the modules fail + // in the first copy, unloads it, then the second copy, and faults. + [[noreturn]] void faultAfterAHandOverThatNothingCouldBeAllocatedFor() + { + RuntimeLibraryCopy first("first", CT_LEAK_DLL); + RuntimeLibraryCopy second("second"); + patchImport(first.module(), "HeapAlloc", reinterpret_cast(&failingHeapAlloc)); + patchImport(first.module(), "K32EnumProcessModules", + reinterpret_cast(&failingEnumProcessModules)); + first.unload(); + second.unload(); + printImage("handler:2", GetModuleHandleW(nullptr)); + faultOnAThread(&raiseUnhandledException); + } + + // The first copy hands the filter it replaced over to the second without allocating anything + // or listing the modules: once the second unloads too, the executable's handler reports the + // crash, not the first copy's, whose code is gone (#236). + TEST(BacktraceReplaceDeathTest, ACopyHandsOverEvenWhenItCanNeitherAllocateNorListModules) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultAfterAHandOverThatNothingCouldBeAllocatedFor(), endedByTheFault(), + crashBacktraceFrom("handler:2 ")); + } } // namespace From f2bdb9a2738d63fc392889f3f0bb0474ddc83801 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:42:44 +0100 Subject: [PATCH 31/59] test(runtime): check a copy over a filter whose module it cannot read The page of the headers of a first copy of the DLL that links the runtime is made unreadable while a second copy loads: that copy cannot tell whether the filter in place is a copy's handler, nor link its record to it. It must keep its handler off that filter, so that once both copies have unloaded, the first one first, the executable's handler reports the crash. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 09265511..907cd5eb 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1730,4 +1730,36 @@ namespace EXPECT_EXIT(faultAfterAHandOverThatNothingCouldBeAllocatedFor(), endedByTheFault(), crashBacktraceFrom("handler:2 ")); } + + // Loads a copy of the DLL that links the runtime and makes the page of its headers unreadable, + // then loads a second copy, which cannot tell whether the filter in place, the first copy's + // handler, is a copy's. Makes the page readable again, unloads the first copy, then the second, + // and faults. Prints why and exits with 2 when the page stays readable. + [[noreturn]] void faultAfterACopyThatCouldNotReadTheFilterBelow() + { + RuntimeLibraryCopy first("first"); + DWORD protection = 0; + if (!VirtualProtect(first.module(), 1, PAGE_NOACCESS, &protection)) + { + std::fprintf(stderr, "the headers of the first copy stayed readable\n"); + std::exit(2); + } + RuntimeLibraryCopy second("second"); + VirtualProtect(first.module(), 1, protection, &protection); + first.unload(); + second.unload(); + printImage("handler:2", GetModuleHandleW(nullptr)); + faultOnAThread(&raiseUnhandledException); + } + + // The second copy keeps its handler off a filter that it cannot link to, and would put back + // once its code is gone: the first copy's handler stays the process's, and the executable's is + // back once the first copy unloads (#236). + TEST(BacktraceReplaceDeathTest, ACopyStaysOffAFilterWhoseModuleItCannotRead) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultAfterACopyThatCouldNotReadTheFilterBelow(), endedByTheFault(), + crashBacktraceFrom("handler:2 ")); + } } // namespace From e0c265ce3d1ab225d03c1b64eb2fc17340b0b3f5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:43:04 +0100 Subject: [PATCH 32/59] test(runtime): check a filter installed while a copy releases its own The import of SetUnhandledExceptionFilter by the copy in the DLL that links the runtime is patched to install a filter of its own right after the first call, as another thread would between the copy's first two calls. The copy, under the program's filter, must put that filter back when the second call returns it: it must be the process's once the DLL has unloaded. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 907cd5eb..185b8aff 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1762,4 +1762,67 @@ namespace EXPECT_EXIT(faultAfterACopyThatCouldNotReadTheFilterBelow(), endedByTheFault(), crashBacktraceFrom("handler:2 ")); } + + // SetUnhandledExceptionFilter as the loader resolved it for a copy of the runtime, before the + // copy's import is patched, and how many calls the patched import has had. + using SetFilterFunction = LPTOP_LEVEL_EXCEPTION_FILTER(WINAPI*)(LPTOP_LEVEL_EXCEPTION_FILTER); + SetFilterFunction resolvedSetFilter = nullptr; + std::atomic patchedSetFilterCalls{0}; + + // A filter that another thread installs: it prints the code of the exception, then ends the + // process with an exit code of its own. + constexpr UINT kConcurrentFilterExit = 10; + + LONG WINAPI concurrentFilter(EXCEPTION_POINTERS* exception_info) + { + std::fprintf(stderr, "concurrent filter %lx\n", + static_cast(exception_info->ExceptionRecord->ExceptionCode)); + std::fflush(stderr); + TerminateProcess(GetCurrentProcess(), kConcurrentFilterExit); + return EXCEPTION_CONTINUE_SEARCH; + } + + // A stand-in for SetUnhandledExceptionFilter that installs concurrentFilter right after its + // first call, as another thread would between the first two. + LPTOP_LEVEL_EXCEPTION_FILTER WINAPI + setFilterWithAnotherThread(LPTOP_LEVEL_EXCEPTION_FILTER filter) + { + const LPTOP_LEVEL_EXCEPTION_FILTER replaced = resolvedSetFilter(filter); + if (patchedSetFilterCalls.fetch_add(1) == 0) + { + resolvedSetFilter(&concurrentFilter); + } + return replaced; + } + + // Loads the DLL that links the runtime and installs the host's filter over its copy's handler. + // While FreeLibrary unloads the DLL, another thread installs a filter of its own between the + // copy's first two calls to SetUnhandledExceptionFilter, as the stand-in does. Then faults. + // Prints why and exits with 2 when the DLL does not import SetUnhandledExceptionFilter. + [[noreturn]] void faultAfterAFilterInstalledDuringTheRelease() + { + HMODULE runtime = loadRuntimeLibrary(); + SetUnhandledExceptionFilter(&hostFilter); + resolvedSetFilter = reinterpret_cast( + patchImport(runtime, "SetUnhandledExceptionFilter", + reinterpret_cast(&setFilterWithAnotherThread))); + if (!resolvedSetFilter) + { + std::fprintf(stderr, "the runtime library imports no SetUnhandledExceptionFilter\n"); + std::exit(2); + } + unloadRuntimeLibrary(runtime); + faultOnAThread(&raiseUnhandledException); + } + + // The copy, whose handler is no longer the process's, puts the host's filter back, then the + // filter that another thread installed meanwhile, which stays the process's (#236). + TEST(BacktraceReplaceDeathTest, AFilterAnotherThreadInstallsDuringTheReleaseStays) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultAfterAFilterInstalledDuringTheRelease(), + testing::ExitedWithCode(static_cast(kConcurrentFilterExit)), + "concurrent filter e0000225"); + } } // namespace From 353f5ff1aa5ab810d192b74c7d32c3af9bee70c4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:44:49 +0100 Subject: [PATCH 33/59] test(runtime): describe the latest crash handler tests in the header Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 185b8aff..e84685be 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -16,7 +16,10 @@ // DLL reports the process's crashes while the DLL is loaded. Once it unloads, the handler installed // before the copy's reports them again, the executable's or the program's own filter, and a filter // installed over the copy's stays, whatever the order in which two such DLLs unload, around a -// filter of the program or not (#236). +// filter of the program or not. A DLL that ends the process with exit() leaves its handler in +// place. A copy hands its filter over even when nothing can be allocated, keeps its handler off a +// filter whose module it cannot read, and puts back a filter that another thread installs while it +// puts its own back (#236). #include #include From fb7c43dceebfd2b06669675a36d900e504681542 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:45:45 +0100 Subject: [PATCH 34/59] docs(readme): describe how a DLL's copy hands its filter over The README says that a DLL's copy links its record to the copy below when it installs its handler, that it leaves its handler out over a filter whose module it cannot read, what becomes of a filter that another thread installs while it puts its own back, and that a DLL ending the process with exit() changes nothing. Refs #236 --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f4bae35a..99716a4a 100644 --- a/README.md +++ b/README.md @@ -297,8 +297,22 @@ handler is still the process's. A filter installed over the handler since stays: - the handler of another DLL's copy gets the filter that the unloaded copy had replaced, and puts it back when its own DLL unloads. -Each copy keeps the filter it replaced in a section `.ctcrash` of its module, where the other copies -find it. Nothing changes when the process terminates, nor in the executable's copy (#236). +Each copy keeps the filter it replaced in a section `.ctcrash` of its module. A DLL's copy links +that record to the one of the copy whose handler it replaced when it installs its own, so that +`FreeLibrary` hands the filter over without allocating anything or listing the modules. When the +copy cannot read the headers of the module that holds the filter in place, as a packer or an +anti-tamper check can leave them, it cannot tell whether that filter is another copy's handler: it +leaves its own handler out then, and says so. + +Windows has no call that reads the process's filter without setting one. The copy that unloads +finds out by setting the filter it replaced, and puts back at once the filter that this call +returns when that is not its handler. A filter that another thread installs between two of those +calls is put back in turn. That thread got from `SetUnhandledExceptionFilter`, as the filter it +replaced, the one that the copy had set for that moment. + +Nothing changes when the process terminates, nor when a DLL ends the process with its own C +runtime's `exit()`, which runs the DLL's exit-time code before the loader detaches it, nor in the +executable's copy (#236). ## LLVM versions From 4acdc2e23de54f4b1b9be9351dec7e5902e5ad9d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:44:49 +0100 Subject: [PATCH 35/59] docs(changelog): note a DLL's handler left out over an unreadable module Refs #236 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa83661..ea7ccf23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,7 +85,8 @@ pull request. filter that the program had installed before the DLL ran. The copy now puts the filter back when `FreeLibrary` unloads the DLL, if its handler is still the process's. A filter installed over it since stays; when that is the handler of another DLL's copy, that copy gets the filter, and puts - it back when its own DLL unloads (README, "Crash handler on Windows") (#236). + it back when its own DLL unloads. A DLL's copy that cannot read the headers of the module of the + filter in place leaves its own handler out (README, "Crash handler on Windows") (#236). ### Tests and CI From a017476ba19611ac5ad958e68fbf8506910f1a05 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:47:52 +0100 Subject: [PATCH 36/59] fix(runtime): link a DLL copy's record only where its release runs A DLL's copy releases its handler only where RtlDllShutdownInProgress tells FreeLibrary apart from the process's termination, from Windows 10 on. Before that, its record would stay linked in the records of other copies once FreeLibrary had unmapped its module, and their release would follow a link into unmapped memory. The copy now links its record only where ct_process_shutdown() can tell. Elsewhere it installs its handler as before this change, without a link. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 220b7b19..02e529b1 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -301,9 +301,14 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) } CtCrashHandlerRecord& record = ct_crash_handler_record; record.replaced = SetUnhandledExceptionFilter(record.handler); - if (reinterpret_cast(&__ImageBase) != GetModuleHandleW(nullptr)) + // A DLL's copy links its record to the copy below while the DLL initializes, with the loader + // lock held, which keeps the links of every copy. It does only where its release runs when + // FreeLibrary unloads the DLL, which needs ct_process_shutdown() to tell that apart from the + // process's termination, from Windows 10 on: otherwise its record would stay linked once its + // module is unmapped. + if (reinterpret_cast(&__ImageBase) != GetModuleHandleW(nullptr) && + ct_process_shutdown() != CtProcessShutdown::Unknown) { - // A DLL initializes with the loader lock held, which keeps the links of every copy. bool readable = true; record.below = ct_crash_handler_record_of(record.replaced, readable); if (!readable) From 88b3db9f111a504a395960e95f01d5aae73e49a6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 03:48:12 +0100 Subject: [PATCH 37/59] docs(readme): say what a DLL's copy does before Windows 10 Refs #236 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99716a4a..3617ba8e 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,9 @@ replaced, the one that the copy had set for that moment. Nothing changes when the process terminates, nor when a DLL ends the process with its own C runtime's `exit()`, which runs the DLL's exit-time code before the loader detaches it, nor in the -executable's copy (#236). +executable's copy. Before Windows 10, a DLL's copy cannot tell `FreeLibrary` apart from the +process's termination: it puts nothing back, and links its record to none, so that its handler +stays the process's filter once `FreeLibrary` has unloaded the DLL (#236). ## LLVM versions From 0111c53ffabefa1fc064d91d2f7c4f19948e5586 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 04:47:14 +0100 Subject: [PATCH 38/59] test(runtime): drop the test that hangs the Windows loader ACopyStaysOffAFilterWhoseModuleItCannotRead made the header page of a loaded DLL PAGE_NOACCESS, then loaded a second DLL: the loader reads that header under its lock while it loads the second, and hung the job for its 45-minute limit on every Windows build. The production path it covered stays, with a comment on why it has no unit test. The copy of the runtime still leaves its handler out when it cannot read the headers of the module of the filter in place. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 5 ++- test/unit/runtime_vtable_replace_test.cpp | 37 ++------------------ 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 02e529b1..e565a74e 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -314,7 +314,10 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) if (!readable) { // The filter may be the handler of a copy that this one cannot link to, and would put - // back once its code is gone: it goes back in place, without this copy's handler. + // back once its code is gone: it goes back in place, without this copy's handler. No + // unit test exercises this: a copy's module whose headers cannot be read is loaded by + // making its header page PAGE_NOACCESS, and loading the next module then hangs the + // loader, which reads that header under its lock. ct_crash_handler_put_back(record.handler, record.replaced); ct_write_prefix(CTLevel::Info); ct_write_cstr("ct: backtrace handler not installed: the headers of the module of the " diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index e84685be..aa368e55 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -17,9 +17,8 @@ // before the copy's reports them again, the executable's or the program's own filter, and a filter // installed over the copy's stays, whatever the order in which two such DLLs unload, around a // filter of the program or not. A DLL that ends the process with exit() leaves its handler in -// place. A copy hands its filter over even when nothing can be allocated, keeps its handler off a -// filter whose module it cannot read, and puts back a filter that another thread installs while it -// puts its own back (#236). +// place. A copy hands its filter over even when nothing can be allocated, and puts back a filter +// that another thread installs while it puts its own back (#236). #include #include @@ -1734,38 +1733,6 @@ namespace crashBacktraceFrom("handler:2 ")); } - // Loads a copy of the DLL that links the runtime and makes the page of its headers unreadable, - // then loads a second copy, which cannot tell whether the filter in place, the first copy's - // handler, is a copy's. Makes the page readable again, unloads the first copy, then the second, - // and faults. Prints why and exits with 2 when the page stays readable. - [[noreturn]] void faultAfterACopyThatCouldNotReadTheFilterBelow() - { - RuntimeLibraryCopy first("first"); - DWORD protection = 0; - if (!VirtualProtect(first.module(), 1, PAGE_NOACCESS, &protection)) - { - std::fprintf(stderr, "the headers of the first copy stayed readable\n"); - std::exit(2); - } - RuntimeLibraryCopy second("second"); - VirtualProtect(first.module(), 1, protection, &protection); - first.unload(); - second.unload(); - printImage("handler:2", GetModuleHandleW(nullptr)); - faultOnAThread(&raiseUnhandledException); - } - - // The second copy keeps its handler off a filter that it cannot link to, and would put back - // once its code is gone: the first copy's handler stays the process's, and the executable's is - // back once the first copy unloads (#236). - TEST(BacktraceReplaceDeathTest, ACopyStaysOffAFilterWhoseModuleItCannotRead) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(faultAfterACopyThatCouldNotReadTheFilterBelow(), endedByTheFault(), - crashBacktraceFrom("handler:2 ")); - } - // SetUnhandledExceptionFilter as the loader resolved it for a copy of the runtime, before the // copy's import is patched, and how many calls the patched import has had. using SetFilterFunction = LPTOP_LEVEL_EXCEPTION_FILTER(WINAPI*)(LPTOP_LEVEL_EXCEPTION_FILTER); From 5597a99262748f33faf82c03a6907b1433c28ae7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:37:51 +0100 Subject: [PATCH 39/59] test(runtime): drop the tests of a DLL copy's crash handler hand-over The crash handler of a DLL's copy will no longer be installed, nor put back when FreeLibrary unloads the DLL: the tests of that hand-over go. They checked a copy that cannot read the headers of the module of the filter in place, two copies whose handlers replaced one handler, a DLL that calls exit(), a hand-over for which nothing can be allocated, a filter installed by another thread during the release, and two DLLs unloaded around a filter of the program. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 277 +--------------------- 1 file changed, 1 insertion(+), 276 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index aa368e55..9807c6a7 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -15,10 +15,7 @@ // DLL's destructors still name types then, as they do when the process exits (#227). The copy in a // DLL reports the process's crashes while the DLL is loaded. Once it unloads, the handler installed // before the copy's reports them again, the executable's or the program's own filter, and a filter -// installed over the copy's stays, whatever the order in which two such DLLs unload, around a -// filter of the program or not. A DLL that ends the process with exit() leaves its handler in -// place. A copy hands its filter over even when nothing can be allocated, and puts back a filter -// that another thread installs while it puts its own back (#236). +// installed over the copy's stays, whatever the order in which two such DLLs unload (#236). #include #include @@ -1468,97 +1465,6 @@ namespace crashBacktraceFrom("handler:2 ")); } - // Loads two copies of the DLL that links the runtime. Then, as a scope of the program does, - // installs the host's filter over the second copy's handler, and puts that handler back - // afterwards: the first copy unloads meanwhile. Then the second copy unloads, and the child - // faults. - [[noreturn]] void faultAfterTwoDllsUnloadedAroundTheHostsFilter() - { - RuntimeLibraryCopy first("first"); - RuntimeLibraryCopy second("second"); - const LPTOP_LEVEL_EXCEPTION_FILTER second_handler = - SetUnhandledExceptionFilter(&hostFilter); - first.unload(); - SetUnhandledExceptionFilter(second_handler); - second.unload(); - printImage("handler:2", GetModuleHandleW(nullptr)); - faultOnAThread(&raiseUnhandledException); - } - - // The second copy's handler went over the first's, and the host's filter was over it when the - // first copy unloaded. Once the host has put the second copy's handler back and that copy has - // unloaded too, the executable's handler reports the crash, not the first copy's, whose code - // is gone (#236). - TEST(BacktraceReplaceDeathTest, TwoDllsUnloadedAroundTheHostsFilterLeaveTheExecutablesHandler) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(faultAfterTwoDllsUnloadedAroundTheHostsFilter(), endedByTheFault(), - crashBacktraceFrom("handler:2 ")); - } - - // The page that a test made unreadable, and whether an access violation in it has been raised - // since, handled or not: a vectored handler sees every exception before any other handler. - std::atomic unreadable_page{0}; - std::atomic unreadable_page_size{0}; - std::atomic unreadable_page_read{false}; - - LONG CALLBACK watchTheUnreadablePage(EXCEPTION_POINTERS* exception_info) - { - const EXCEPTION_RECORD* exception = exception_info->ExceptionRecord; - if (exception->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && - exception->NumberParameters >= 2 && - exception->ExceptionInformation[1] - unreadable_page.load() < - unreadable_page_size.load()) - { - unreadable_page_read.store(true); - } - return EXCEPTION_CONTINUE_SEARCH; - } - - // Loads a DLL without the runtime and the DLL that links it, installs the host's filter over the - // copy's handler, then makes the page of the other DLL's headers unreadable, as a packer or an - // anti-tamper check can. Unloads the DLL that links the runtime on a thread of its own, where - // nothing but the host's filter would catch a fault, then faults. Prints why and exits with 2 - // when the page stays readable, with 4 when the unload read it, whoever handled the fault. - [[noreturn]] void faultAfterUnloadingPastUnreadableHeaders() - { - HMODULE other = LoadLibraryA(CT_RELOAD_LIBRARY_A); - HMODULE runtime = loadRuntimeLibrary(); - SetUnhandledExceptionFilter(&hostFilter); - SYSTEM_INFO system = {}; - GetSystemInfo(&system); - unreadable_page_size.store(system.dwPageSize); - unreadable_page.store(reinterpret_cast(other)); - DWORD protection = 0; - if (!other || !VirtualProtect(other, 1, PAGE_NOACCESS, &protection)) - { - std::fprintf(stderr, "the headers of the other library stayed readable\n"); - std::exit(2); - } - PVOID watch = AddVectoredExceptionHandler(1, &watchTheUnreadablePage); - std::thread(unloadRuntimeLibrary, runtime).join(); - RemoveVectoredExceptionHandler(watch); - if (unreadable_page_read.load()) - { - std::fprintf(stderr, "the unload read the unreadable headers\n"); - std::exit(4); - } - faultOnAThread(&raiseUnhandledException); - } - - // The copy, whose handler is no longer the process's, looks through the modules for the copy - // whose handler went over its own: it skips one whose headers it cannot read, rather than fault - // while FreeLibrary unloads its DLL, and the host's filter gets the fault's exception (#236). - TEST(BacktraceReplaceDeathTest, ADllUnloadsPastAModuleWhoseHeadersAreUnreadable) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(faultAfterUnloadingPastUnreadableHeaders(), - testing::ExitedWithCode(static_cast(kHostFilterExit)), - "host filter e0000225"); - } - // The process's filter, read the only way Windows allows: by setting another one, then putting // it back. LPTOP_LEVEL_EXCEPTION_FILTER currentFilter() @@ -1568,43 +1474,6 @@ namespace return current; } - // Loads a copy of the DLL that links the runtime, then a second one. The program puts the first - // copy's handler back over the second's, as one that restores a filter it read earlier does, - // and a third copy loads: the handlers of the second and the third copies both went over the - // first one's. The first copy unloads, then the third, then the second, once the program has - // put its handler back too. Exits with 3 when the process's filter is not then the one that - // the first copy's handler went over, with 0 otherwise. - [[noreturn]] void unloadTwoCopiesThatReplacedOneHandler() - { - const LPTOP_LEVEL_EXCEPTION_FILTER executable = currentFilter(); - RuntimeLibraryCopy first("first"); - const LPTOP_LEVEL_EXCEPTION_FILTER first_handler = currentFilter(); - RuntimeLibraryCopy second("second"); - const LPTOP_LEVEL_EXCEPTION_FILTER second_handler = currentFilter(); - SetUnhandledExceptionFilter(first_handler); - RuntimeLibraryCopy third("third"); - first.unload(); - third.unload(); - const bool after_the_third = currentFilter() == executable; - SetUnhandledExceptionFilter(second_handler); - second.unload(); - if (!after_the_third || currentFilter() != executable) - { - std::fprintf(stderr, "a copy put back the handler of the first, unloaded\n"); - std::exit(3); - } - std::exit(0); - } - - // Every copy whose handler went over the handler of a copy that unloads gets the filter that - // the latter went over, whatever the order in which the loader lists their modules (#236). - TEST(BacktraceReplaceDeathTest, TwoCopiesThatReplacedOneHandlerGetWhatItReplaced) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(unloadTwoCopiesThatReplacedOneHandler(), testing::ExitedWithCode(0), ""); - } - // Points the import of `function` from kernel32.dll by the loaded `module` at `replacement`, and // returns what it pointed at, as the loader resolved it: null when `module` does not import it. void* patchImport(HMODULE module, const char* function, void* replacement) @@ -1651,148 +1520,4 @@ namespace } return nullptr; } - - // A stand-in for SetUnhandledExceptionFilter in a copy of the runtime that must not call it: - // it ends the process with 5. - LPTOP_LEVEL_EXCEPTION_FILTER WINAPI setFilterEndsTheProcess(LPTOP_LEVEL_EXCEPTION_FILTER) - { - std::fputs("a copy set the process's filter\n", stderr); - std::fflush(stderr); - TerminateProcess(GetCurrentProcess(), 5); - return nullptr; - } - - // Loads the DLL of reload/ct_leak_dll.cpp, which links the runtime, then has it end the process - // with its own C runtime's exit(), which runs the DLL's destructors before the loader detaches - // it, without the loader lock. Its copy's SetUnhandledExceptionFilter ends the process with 5 - // if the release calls it then. Prints why and exits with 2 when that cannot be set up. - [[noreturn]] void endTheProcessFromADll() - { - HMODULE dll = LoadLibraryA(CT_LEAK_DLL); - auto* dll_exit = - dll ? reinterpret_cast(GetProcAddress(dll, "ct_leak_dll_exit")) : nullptr; - if (!dll_exit || !patchImport(dll, "SetUnhandledExceptionFilter", - reinterpret_cast(&setFilterEndsTheProcess))) - { - std::fprintf(stderr, "the leak DLL could not be set up\n"); - std::exit(2); - } - dll_exit(); - std::exit(8); - } - - // A DLL that ends the process with exit() stays mapped until then: its copy leaves its handler - // in place, rather than release it without the loader lock, while other threads may still - // unload modules (#236). - TEST(BacktraceReplaceDeathTest, ADllThatCallsExitLeavesItsHandlerInPlace) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(endTheProcessFromADll(), testing::ExitedWithCode(0), ""); - } - - // Stand-ins for what kernel32.dll does in a copy of the runtime that unloads: every allocation - // from a heap and every listing of the modules fails. - LPVOID WINAPI failingHeapAlloc(HANDLE, DWORD, SIZE_T) - { - SetLastError(ERROR_NOT_ENOUGH_MEMORY); - return nullptr; - } - - BOOL WINAPI failingEnumProcessModules(HANDLE, HMODULE*, DWORD, LPDWORD) - { - SetLastError(ERROR_NOT_ENOUGH_MEMORY); - return FALSE; - } - - // Loads a copy of the DLL of reload/ct_leak_dll.cpp, which links the runtime and runs no code of - // its own when it unloads, then a copy of the DLL that links the runtime, whose handler goes - // over the first copy's. Has every allocation from a heap and every listing of the modules fail - // in the first copy, unloads it, then the second copy, and faults. - [[noreturn]] void faultAfterAHandOverThatNothingCouldBeAllocatedFor() - { - RuntimeLibraryCopy first("first", CT_LEAK_DLL); - RuntimeLibraryCopy second("second"); - patchImport(first.module(), "HeapAlloc", reinterpret_cast(&failingHeapAlloc)); - patchImport(first.module(), "K32EnumProcessModules", - reinterpret_cast(&failingEnumProcessModules)); - first.unload(); - second.unload(); - printImage("handler:2", GetModuleHandleW(nullptr)); - faultOnAThread(&raiseUnhandledException); - } - - // The first copy hands the filter it replaced over to the second without allocating anything - // or listing the modules: once the second unloads too, the executable's handler reports the - // crash, not the first copy's, whose code is gone (#236). - TEST(BacktraceReplaceDeathTest, ACopyHandsOverEvenWhenItCanNeitherAllocateNorListModules) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(faultAfterAHandOverThatNothingCouldBeAllocatedFor(), endedByTheFault(), - crashBacktraceFrom("handler:2 ")); - } - - // SetUnhandledExceptionFilter as the loader resolved it for a copy of the runtime, before the - // copy's import is patched, and how many calls the patched import has had. - using SetFilterFunction = LPTOP_LEVEL_EXCEPTION_FILTER(WINAPI*)(LPTOP_LEVEL_EXCEPTION_FILTER); - SetFilterFunction resolvedSetFilter = nullptr; - std::atomic patchedSetFilterCalls{0}; - - // A filter that another thread installs: it prints the code of the exception, then ends the - // process with an exit code of its own. - constexpr UINT kConcurrentFilterExit = 10; - - LONG WINAPI concurrentFilter(EXCEPTION_POINTERS* exception_info) - { - std::fprintf(stderr, "concurrent filter %lx\n", - static_cast(exception_info->ExceptionRecord->ExceptionCode)); - std::fflush(stderr); - TerminateProcess(GetCurrentProcess(), kConcurrentFilterExit); - return EXCEPTION_CONTINUE_SEARCH; - } - - // A stand-in for SetUnhandledExceptionFilter that installs concurrentFilter right after its - // first call, as another thread would between the first two. - LPTOP_LEVEL_EXCEPTION_FILTER WINAPI - setFilterWithAnotherThread(LPTOP_LEVEL_EXCEPTION_FILTER filter) - { - const LPTOP_LEVEL_EXCEPTION_FILTER replaced = resolvedSetFilter(filter); - if (patchedSetFilterCalls.fetch_add(1) == 0) - { - resolvedSetFilter(&concurrentFilter); - } - return replaced; - } - - // Loads the DLL that links the runtime and installs the host's filter over its copy's handler. - // While FreeLibrary unloads the DLL, another thread installs a filter of its own between the - // copy's first two calls to SetUnhandledExceptionFilter, as the stand-in does. Then faults. - // Prints why and exits with 2 when the DLL does not import SetUnhandledExceptionFilter. - [[noreturn]] void faultAfterAFilterInstalledDuringTheRelease() - { - HMODULE runtime = loadRuntimeLibrary(); - SetUnhandledExceptionFilter(&hostFilter); - resolvedSetFilter = reinterpret_cast( - patchImport(runtime, "SetUnhandledExceptionFilter", - reinterpret_cast(&setFilterWithAnotherThread))); - if (!resolvedSetFilter) - { - std::fprintf(stderr, "the runtime library imports no SetUnhandledExceptionFilter\n"); - std::exit(2); - } - unloadRuntimeLibrary(runtime); - faultOnAThread(&raiseUnhandledException); - } - - // The copy, whose handler is no longer the process's, puts the host's filter back, then the - // filter that another thread installed meanwhile, which stays the process's (#236). - TEST(BacktraceReplaceDeathTest, AFilterAnotherThreadInstallsDuringTheReleaseStays) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(faultAfterAFilterInstalledDuringTheRelease(), - testing::ExitedWithCode(static_cast(kConcurrentFilterExit)), - "concurrent filter e0000225"); - } } // namespace From 43b5cbb5c8a2bdbeb3c8e8f19e73a80d656e3c3c Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:37:59 +0100 Subject: [PATCH 40/59] test(runtime): stop giving the leak DLL to the runtime unit tests Only the dropped tests of a DLL that calls exit() and of a hand-over for which nothing can be allocated loaded it. Refs #236 --- test/unit/CMakeLists.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index f878dfd9..f65a072c 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -181,10 +181,6 @@ if(TARGET ct_instrument_runtime) foreach(target ct_leak_dll dll_leak_report_main) coretrace_force_msvc_runtime(${target}) endforeach() - # The crash handler tests load that DLL too: it runs no code of its own when FreeLibrary unloads - # it, and it can end the process with its own C runtime's exit() (#236). - target_compile_definitions(runtime_unit_tests PRIVATE CT_LEAK_DLL="$") - add_dependencies(runtime_unit_tests ct_leak_dll) endif() if(APPLE) # Objective-C object tracking: pulls ct_runtime_objc.cpp, which calls into libobjc. From 3406bec452e8426cdc2ab5b74671b6e465bf250c Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:38:09 +0100 Subject: [PATCH 41/59] test(runtime): drop the test of a DLL copy's own crash handler The test checked that the copy of the runtime in a DLL reports the process's crashes while the DLL is loaded, in a program without a handler of its own. That copy will no longer install its handler: only the executable's copy will, a contract under which such a program gets no crash backtrace from the DLL. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 26 ++++------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 9807c6a7..f988affb 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -12,10 +12,10 @@ // one forever. The crash handler's backtraces, which name frames through the same session, name // none after a DLL unloaded since (#225). The copy in a DLL frees the names it decoded when the DLL // unloads: the DLL keeps no heap block at each load, as one without the runtime. The boxes of the -// DLL's destructors still name types then, as they do when the process exits (#227). The copy in a -// DLL reports the process's crashes while the DLL is loaded. Once it unloads, the handler installed -// before the copy's reports them again, the executable's or the program's own filter, and a filter -// installed over the copy's stays, whatever the order in which two such DLLs unload (#236). +// DLL's destructors still name types then, as they do when the process exits (#227). Once a DLL +// that links the runtime unloads, the handler installed before its copy's reports the process's +// crashes, the executable's or the program's own filter, and a filter installed over the copy's +// stays, whatever the order in which two such DLLs unload (#236). #include #include @@ -1305,24 +1305,6 @@ namespace return testing::MakeMatcher(new CrashBacktraceFrom(mark)); } - // Removes the executable's handler, as for a program that does not link the runtime, loads the - // DLL that links it, then faults. - [[noreturn]] void faultWhileADllThatLinksTheRuntimeIsLoaded() - { - SetUnhandledExceptionFilter(nullptr); - printImage("handler:2", loadRuntimeLibrary()); - faultOnAThread(&raiseUnhandledException); - } - - // While the DLL is loaded, its copy's handler reports the crashes of the process (#236). - TEST(BacktraceReplaceDeathTest, ADllThatLinksTheRuntimeReportsTheCrashesWhileItIsLoaded) - { - GTEST_FLAG_SET(death_test_style, "threadsafe"); - BacktraceInChildren backtrace; - EXPECT_EXIT(faultWhileADllThatLinksTheRuntimeIsLoaded(), endedByTheFault(), - crashBacktraceFrom("handler:2 ")); - } - [[noreturn]] void faultAfterADllThatLinksTheRuntimeUnloaded() { unloadRuntimeLibrary(loadRuntimeLibrary()); From 01bfd369d62e2f8cb9427cb29f70050a904dcdd1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:38:24 +0100 Subject: [PATCH 42/59] fix(runtime): install the crash handler from the executable's copy only With CT_BACKTRACE, the copy of the runtime in a DLL that links it installed its crash handler for the whole process, and had to take it out when FreeLibrary unloaded the DLL. Windows has no call that sets the process's filter only if it still is the one expected: the release put the filter back by exchanges, in a loop that another thread changing its filters could keep going with the loader lock held, and that put back a filter over one that the program had set again to the same address. A filter that the program installed over the handler would call it once its code was gone, too. The copy in a DLL now installs no handler, and leaves the process's filter alone: the executable's copy reports the crashes of the whole process, those of the DLL's code included, and FreeLibrary unloads the DLL as usual. An instrumented DLL that a program without the runtime loads reports no crash. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index e565a74e..0801a34b 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -1,9 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // -// The crash handler of this copy of the runtime, which CT_BACKTRACE installs for the whole process -// when the module that holds the copy initializes: the executable, or a DLL that links the runtime, -// whose copy's handler goes over the filter installed before it. When FreeLibrary unloads the DLL, -// whose code the process must not call afterwards, its copy puts that filter back (#236). +// The crash handler of the runtime, which CT_BACKTRACE installs for the whole process from the +// executable's copy of the runtime only. A DLL that links the runtime has a copy of its own, which +// installs none: Windows has no call that puts the process's filter back only if it still is the +// one expected, so a DLL's copy could not take its handler out when FreeLibrary unloads the DLL +// without overwriting a filter that the program installs meanwhile, and a filter installed over the +// handler would call it once its code is gone. A crash in the DLL is reported by the executable's +// handler; an instrumented DLL that a program without the runtime loads reports none (#236). #include "ct_runtime_internal.h" #include "ct_runtime_helpers.h" @@ -276,7 +279,9 @@ namespace CT_NOINSTR void ct_maybe_install_backtrace(void) { - if (std::getenv("CT_BACKTRACE") == nullptr) + // A DLL's copy leaves the process's filter alone: see the top of this file. + if (std::getenv("CT_BACKTRACE") == nullptr || + reinterpret_cast(&__ImageBase) != GetModuleHandleW(nullptr)) { return; } From 7419a3130dc09d7fd586c14fea576f168bd591dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:38:34 +0100 Subject: [PATCH 43/59] refactor(runtime): drop the crash handler records and their release No copy of the runtime in a DLL installs the crash handler any more: nothing remains to put back when FreeLibrary unloads the DLL. The records in the section .ctcrash, their links between copies, the release that put the filter back by exchanges, and the inspection of the headers of the module of the filter in place go. Refs #236 --- src/runtime/windows/ct_runtime_backtrace.cpp | 217 +------------------ 1 file changed, 1 insertion(+), 216 deletions(-) diff --git a/src/runtime/windows/ct_runtime_backtrace.cpp b/src/runtime/windows/ct_runtime_backtrace.cpp index 0801a34b..0b511f70 100644 --- a/src/runtime/windows/ct_runtime_backtrace.cpp +++ b/src/runtime/windows/ct_runtime_backtrace.cpp @@ -11,9 +11,7 @@ #include "ct_runtime_helpers.h" #include -#include #include -#include #include #ifndef NOMINMAX @@ -27,14 +25,6 @@ // The module that holds this copy of the runtime. extern "C" IMAGE_DOS_HEADER __ImageBase; -// The objects of this file are constructed before the program's and destroyed after them: a DLL's -// copy puts the filter back after the destructors of the DLL's global objects, whose crashes its -// handler still reports. -#pragma init_seg(lib) - -// The section of the record below, in the module that holds this copy of the runtime. -#pragma section(".ctcrash", read, write) - namespace { std::atomic ct_backtrace_installed{0}; @@ -101,180 +91,6 @@ namespace return EXCEPTION_EXECUTE_HANDLER; } - - // What a copy of the runtime tells the other copies in the process of its crash handler, in the - // section .ctcrash of its module, where a copy finds another one's record from the address of - // its handler. `replaced` is the filter that the handler went over when the copy installed it, - // and `below` the record of the copy whose handler that filter is, if any. `above` is the first - // of the copies whose handlers went over this one's, and `next` the following one over the same - // copy. A DLL's copy makes its links when it installs its handler and undoes them when - // FreeLibrary unloads the DLL, both with the loader lock held: a record is linked only while its - // module is loaded, and the release follows the links without allocating or listing the - // modules. The executable's copy, which installs its handler without the loader lock and keeps - // it as long as the process, links to none. The magic number reads "ctcrash", then the version - // of the layout. - struct CtCrashHandlerRecord - { - std::uint64_t magic; - LPTOP_LEVEL_EXCEPTION_FILTER handler; - LPTOP_LEVEL_EXCEPTION_FILTER replaced; - CtCrashHandlerRecord* below; - CtCrashHandlerRecord* above; - CtCrashHandlerRecord* next; - }; - - constexpr std::uint64_t kCrashHandlerMagic = 0x0268736172637463; - constexpr char kCrashHandlerSection[IMAGE_SIZEOF_SHORT_NAME] = {'.', 'c', 't', 'c', - 'r', 'a', 's', 'h'}; - - __declspec(allocate(".ctcrash")) constinit CtCrashHandlerRecord ct_crash_handler_record{ - kCrashHandlerMagic, &ct_exception_filter, nullptr, nullptr, nullptr, nullptr}; - - // Whether this copy's handler is installed, with its record: set once, when the module - // initializes, and read when it detaches. - bool ct_crash_handler_in_place = false; - - // The record of the copy of the runtime whose handler `filter` is, if any. `readable` is false - // when the headers of the module that holds `filter` cannot be read, so that whether it is a - // copy's handler cannot be told: a packer or an anti-tamper check may have erased them, or made - // them unreadable. Each part is read once found readable, and must look like what it is. - CT_NOINSTR CtCrashHandlerRecord* ct_crash_handler_record_of(LPTOP_LEVEL_EXCEPTION_FILTER filter, - bool& readable) - { - readable = true; - HMODULE module = nullptr; - if (!filter || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | - GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, - reinterpret_cast(filter), &module)) - { - return nullptr; - } - readable = false; - auto* image = reinterpret_cast(module); - const auto* dos = reinterpret_cast(image); - if (!ct_is_readable(dos, sizeof(*dos)) || dos->e_magic != IMAGE_DOS_SIGNATURE || - dos->e_lfanew <= 0) - { - return nullptr; - } - const auto* headers = reinterpret_cast(image + dos->e_lfanew); - if (!ct_is_readable(headers, sizeof(*headers)) || headers->Signature != IMAGE_NT_SIGNATURE) - { - return nullptr; - } - const IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(headers); - const WORD sections = headers->FileHeader.NumberOfSections; - if (!ct_is_readable(section, sections * sizeof(IMAGE_SECTION_HEADER))) - { - return nullptr; - } - for (WORD index = 0; index < sections; ++index, ++section) - { - if (std::memcmp(section->Name, kCrashHandlerSection, sizeof(section->Name)) != 0) - { - continue; - } - auto* record = reinterpret_cast(image + section->VirtualAddress); - if ((section->Characteristics & IMAGE_SCN_MEM_WRITE) == 0 || - section->Misc.VirtualSize < sizeof(CtCrashHandlerRecord) || - !ct_is_readable(record, sizeof(*record))) - { - return nullptr; - } - readable = true; - return record->magic == kCrashHandlerMagic && record->handler == filter ? record - : nullptr; - } - readable = true; - return nullptr; - } - - // Takes `record` out of the links when FreeLibrary unloads the DLL that holds its copy: out of - // the list of the copy below, and the copies above get the filter that its handler replaced, - // and that copy below as theirs, so that none puts back the unloading copy's handler, whose - // code is gone, when its own DLL unloads. FreeLibrary holds the loader lock meanwhile: every - // record linked is in a module still loaded. - CT_NOINSTR void ct_crash_handler_unlink(CtCrashHandlerRecord& record) - { - if (record.below) - { - for (CtCrashHandlerRecord** link = &record.below->above; *link; link = &(*link)->next) - { - if (*link == &record) - { - *link = record.next; - break; - } - } - } - for (CtCrashHandlerRecord* above = record.above; above;) - { - CtCrashHandlerRecord* const next = above->next; - above->replaced = record.replaced; - above->below = record.below; - above->next = record.below ? record.below->above : nullptr; - if (record.below) - { - record.below->above = above; - } - above = next; - } - record.below = nullptr; - record.above = nullptr; - record.next = nullptr; - } - - // Makes `replaced` the process's filter if `handler` still is, and leaves the filter otherwise. - // Windows has no call that reads the process's filter, or sets it only if it is the one - // expected: this sets `replaced`, then puts the filter it replaced back at once when that is not - // `handler`. A filter that another thread installs between two of these calls is what the - // second returns instead of the filter set by the first: it is put back in turn, and so on. - // That thread got from SetUnhandledExceptionFilter, as the filter it replaced, the one set here - // for that moment. - CT_NOINSTR void ct_crash_handler_put_back(LPTOP_LEVEL_EXCEPTION_FILTER handler, - LPTOP_LEVEL_EXCEPTION_FILTER replaced) - { - LPTOP_LEVEL_EXCEPTION_FILTER set = replaced; - LPTOP_LEVEL_EXCEPTION_FILTER found = SetUnhandledExceptionFilter(replaced); - while (found != handler) - { - const LPTOP_LEVEL_EXCEPTION_FILTER installed_meanwhile = - SetUnhandledExceptionFilter(found); - if (installed_meanwhile == set) - { - return; - } - set = found; - found = installed_meanwhile; - } - } - - // Puts back the filter that this copy's handler went over when FreeLibrary unloads the DLL that - // holds the copy, if the handler is still the process's: a filter installed over it since - // stays. Then takes the copy's record out of the links. Nothing changes when the process - // terminates, nor while a DLL's own exit() runs this destructor, before the loader detaches the - // DLL and without the loader lock: the DLL stays mapped until the process ends. - struct CtCrashHandlerRelease - { - // User-provided, so that the object is initialized dynamically, in this file's - // init_seg: clang registers the destructor of a constant-initialized object from - // .CRT$XCU, with the program's own objects. - CT_NOINSTR CtCrashHandlerRelease() {} - - CT_NOINSTR ~CtCrashHandlerRelease() - { - if (!ct_crash_handler_in_place || !ct_dll_unloading_dynamically() || - !ct_loader_detaching_module()) - { - return; - } - CtCrashHandlerRecord& record = ct_crash_handler_record; - ct_crash_handler_put_back(record.handler, record.replaced); - ct_crash_handler_unlink(record); - } - }; - - CtCrashHandlerRelease ct_crash_handler_release; } // namespace CT_NOINSTR void ct_maybe_install_backtrace(void) @@ -304,38 +120,7 @@ CT_NOINSTR void ct_maybe_install_backtrace(void) (void)ct_dbghelp_sync_modules_locked(process); } } - CtCrashHandlerRecord& record = ct_crash_handler_record; - record.replaced = SetUnhandledExceptionFilter(record.handler); - // A DLL's copy links its record to the copy below while the DLL initializes, with the loader - // lock held, which keeps the links of every copy. It does only where its release runs when - // FreeLibrary unloads the DLL, which needs ct_process_shutdown() to tell that apart from the - // process's termination, from Windows 10 on: otherwise its record would stay linked once its - // module is unmapped. - if (reinterpret_cast(&__ImageBase) != GetModuleHandleW(nullptr) && - ct_process_shutdown() != CtProcessShutdown::Unknown) - { - bool readable = true; - record.below = ct_crash_handler_record_of(record.replaced, readable); - if (!readable) - { - // The filter may be the handler of a copy that this one cannot link to, and would put - // back once its code is gone: it goes back in place, without this copy's handler. No - // unit test exercises this: a copy's module whose headers cannot be read is loaded by - // making its header page PAGE_NOACCESS, and loading the next module then hangs the - // loader, which reads that header under its lock. - ct_crash_handler_put_back(record.handler, record.replaced); - ct_write_prefix(CTLevel::Info); - ct_write_cstr("ct: backtrace handler not installed: the headers of the module of the " - "filter in place cannot be read\n"); - return; - } - if (record.below) - { - record.next = record.below->above; - record.below->above = &record; - } - } - ct_crash_handler_in_place = true; + SetUnhandledExceptionFilter(ct_exception_filter); ct_write_prefix(CTLevel::Info); ct_write_cstr("ct: backtrace handler installed\n"); From 214aaca1b5bb7adb1299f0560d7d1ba9d2f1922d Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:38:44 +0100 Subject: [PATCH 44/59] refactor(runtime): keep the readability check in the Windows vtable file The crash handler records shared it with the vtable diagnostics, its only user once the records are gone: it is local to that file again, as on main. Refs #236 --- src/runtime/ct_runtime_helpers.h | 4 -- src/runtime/windows/ct_runtime_vtable.cpp | 56 +++++++++++------------ 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/runtime/ct_runtime_helpers.h b/src/runtime/ct_runtime_helpers.h index 93d7ba3f..bc1ec428 100644 --- a/src/runtime/ct_runtime_helpers.h +++ b/src/runtime/ct_runtime_helpers.h @@ -78,10 +78,6 @@ CT_NODISCARD bool ct_dbghelp_sync_modules_locked(HANDLE process); // held. CT_NODISCARD bool ct_dbghelp_module_current_locked(HANDLE process, DWORD64 address); -// Whether the `size` bytes at `addr` are committed memory of a single region, neither inaccessible -// nor a guard page (windows/ct_runtime_vtable.cpp). -CT_NODISCARD bool ct_is_readable(const void* addr, size_t size); - // UnDecorateSymbolName, with the DbgHelp lock: Unavailable when the lock was not free in time. inline CtDemangleOutcome ct_demangle_outcome(const char* name, std::string& out) { diff --git a/src/runtime/windows/ct_runtime_vtable.cpp b/src/runtime/windows/ct_runtime_vtable.cpp index c27dc895..83174058 100644 --- a/src/runtime/windows/ct_runtime_vtable.cpp +++ b/src/runtime/windows/ct_runtime_vtable.cpp @@ -28,34 +28,6 @@ // virtual calls. #pragma init_seg(lib) -CT_NODISCARD CT_NOINSTR bool ct_is_readable(const void* addr, size_t size) -{ - if (!addr || size == 0) - { - return false; - } - - MEMORY_BASIC_INFORMATION mbi = {}; - if (VirtualQuery(addr, &mbi, sizeof(mbi)) == 0) - { - return false; - } - - if (mbi.State != MEM_COMMIT) - { - return false; - } - if ((mbi.Protect & PAGE_NOACCESS) != 0 || (mbi.Protect & PAGE_GUARD) != 0) - { - return false; - } - - const uintptr_t start = reinterpret_cast(addr); - const uintptr_t end = start + size; - const uintptr_t region_end = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; - return end >= start && end <= region_end; -} - namespace { constexpr size_t kBoxMaxValueWidth = 60; @@ -135,6 +107,34 @@ namespace normalized == PAGE_EXECUTE_READWRITE || normalized == PAGE_EXECUTE_WRITECOPY; } + CT_NODISCARD CT_NOINSTR bool ct_is_readable(const void* addr, size_t size) + { + if (!addr || size == 0) + { + return false; + } + + MEMORY_BASIC_INFORMATION mbi = {}; + if (VirtualQuery(addr, &mbi, sizeof(mbi)) == 0) + { + return false; + } + + if (mbi.State != MEM_COMMIT) + { + return false; + } + if ((mbi.Protect & PAGE_NOACCESS) != 0 || (mbi.Protect & PAGE_GUARD) != 0) + { + return false; + } + + const uintptr_t start = reinterpret_cast(addr); + const uintptr_t end = start + size; + const uintptr_t region_end = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; + return end >= start && end <= region_end; + } + CT_NODISCARD CT_NOINSTR bool ct_lookup_symbol_name(const void* addr, std::string& out) { if (!addr) From 02c7192beefdb74f1aebeba1df38de1eb1f92f6a Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:38:44 +0100 Subject: [PATCH 45/59] refactor(runtime): drop the check that the loader detaches the module Only the release of a DLL copy's crash handler asked whether the loader, rather than the DLL's own exit(), runs the module's destructors: that release is gone. Refs #236 --- src/runtime/ct_runtime_internal.h | 6 ------ src/runtime/windows/ct_runtime_shutdown.cpp | 16 +--------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/runtime/ct_runtime_internal.h b/src/runtime/ct_runtime_internal.h index d776d6cf..d92558ef 100644 --- a/src/runtime/ct_runtime_internal.h +++ b/src/runtime/ct_runtime_internal.h @@ -461,12 +461,6 @@ CT_NODISCARD CT_NOINSTR CtProcessShutdown ct_process_shutdown(void); // its C runtime runs that code before the loader reports the termination, so its copy frees what // it holds then too. CT_NODISCARD CT_NOINSTR bool ct_dll_unloading_dynamically(void); - -// Whether the loader has started to detach the module that holds this copy of the runtime, as when -// FreeLibrary unloads a DLL, with the loader lock held. False while a DLL's own exit() runs the -// destructors that the C runtime otherwise runs from the loader's detach, before the loader -// detaches the DLL and without its lock (#236). -CT_NODISCARD CT_NOINSTR bool ct_loader_detaching_module(void); #endif CT_NOINSTR void ct_log_alloc_details(const char* label, const char* status, size_t req_size, diff --git a/src/runtime/windows/ct_runtime_shutdown.cpp b/src/runtime/windows/ct_runtime_shutdown.cpp index d57e4292..c09cb786 100644 --- a/src/runtime/windows/ct_runtime_shutdown.cpp +++ b/src/runtime/windows/ct_runtime_shutdown.cpp @@ -4,8 +4,7 @@ // while the module detaches: the leak report of a DLL's copy, and the release of its DbgHelp // session and of its names. That code cannot tell from lpvReserved: only DllMain receives it // non-null at process termination, the TLS callbacks receive it null then too (#226, #232). -// ct_dll_unloading_dynamically tells whether that code frees what the copy holds, and -// ct_loader_detaching_module whether the loader runs it, rather than a DLL's own exit() (#236). +// ct_dll_unloading_dynamically tells whether that code frees what the copy holds. #include "ct_runtime_internal.h" #include @@ -22,10 +21,6 @@ namespace using CtShutdownInProgress = BOOLEAN(NTAPI*)(); std::atomic ct_shutdown_in_progress{nullptr}; - // Set when the loader detaches the module: its TLS callbacks receive DLL_PROCESS_DETACH before - // its entry point, from which the C runtime runs the module's destructors (#232). - std::atomic ct_loader_detaching{false}; - CT_NOINSTR void NTAPI ct_shutdown_tls(PVOID, DWORD reason, PVOID) { if (reason == DLL_PROCESS_ATTACH) @@ -35,10 +30,6 @@ namespace GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlDllShutdownInProgress")), std::memory_order_release); } - else if (reason == DLL_PROCESS_DETACH) - { - ct_loader_detaching.store(true, std::memory_order_release); - } } } // namespace @@ -60,11 +51,6 @@ CT_NODISCARD CT_NOINSTR bool ct_dll_unloading_dynamically(void) ct_process_shutdown() == CtProcessShutdown::NotInProgress; } -CT_NODISCARD CT_NOINSTR bool ct_loader_detaching_module(void) -{ - return ct_loader_detaching.load(std::memory_order_acquire); -} - // The TLS callback that looks RtlDllShutdownInProgress up, in the file of ct_process_shutdown, so // that a module calling one links the other. #pragma comment(linker, "/INCLUDE:_tls_used") From ac7b837a998f5751435c0e56edf63c8d98382519 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:39:05 +0100 Subject: [PATCH 46/59] test(runtime): let the DLL that links the runtime fault in its own code The DLL exports a function that raises an exception that nothing handles, so that a test can check which handler reports a crash in a DLL whose copy of the runtime installs none. Refs #236 --- test/unit/reload/ct_runtime_library.cpp | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test/unit/reload/ct_runtime_library.cpp b/test/unit/reload/ct_runtime_library.cpp index b645bfe0..4a953cab 100644 --- a/test/unit/reload/ct_runtime_library.cpp +++ b/test/unit/reload/ct_runtime_library.cpp @@ -3,9 +3,14 @@ // A DLL that links the runtime, as an instrumented DLL does, and with it the runtime's watch of // the loader, its DbgHelp session and the DbgHelp lock of the process: // runtime_vtable_replace_test.cpp makes its copy of the runtime name a virtual call's target, or -// not, then unloads it, when its destructors name one, and loads other DLLs. +// not, then unloads it, when its destructors name one, and loads other DLLs, or has it fault. #define CT_RUNTIME_LIBRARY_EXPORT extern "C" __declspec(dllexport) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + extern "C" void __ct_vcall_trace(void* this_ptr, void* target, const char* site, const char* static_type); @@ -49,3 +54,17 @@ CT_RUNTIME_LIBRARY_EXPORT void ct_runtime_library_trace() __ct_vcall_trace(&gadget, reinterpret_cast(&ct_runtime_library_measure), "runtime_library:1", ""); } + +namespace +{ + volatile int faulted = 0; +} // namespace + +// Raises an exception that nothing handles, in the DLL's own code: the handler of the executable's +// copy of the runtime reports it, the DLL's copy installing none (#236). The store after the call +// keeps this frame on the stack, which a tail call would leave out. +CT_RUNTIME_LIBRARY_EXPORT void ct_runtime_library_fault() +{ + RaiseException(0xE0000225, EXCEPTION_NONCONTINUABLE, 0, nullptr); + faulted = 1; +} From d27a397cc226baedae93f69f29a19adccebca395 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:39:33 +0100 Subject: [PATCH 47/59] test(runtime): describe the crash tests under the executable's handler The comments of the crash tests that remain said that a DLL's copy installs its handler over the one before it and hands it over when the DLL unloads. They now say that the copy installs none, that the executable's handler or the program's filter reports the crash once the DLL has unloaded for real, and the copies of the DLL that links the runtime always come from that DLL. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 40 +++++++++++------------ 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index f988affb..b2ee6472 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1232,8 +1232,8 @@ namespace crashBacktrace(Backtrace::NamesTheExecutableOnly)); } - // Loads the DLL that links the runtime, whose copy installs its handler over the executable's. - // Prints why and exits with 2 when it does not load. + // Loads the DLL that links the runtime, whose copy installs no crash handler. Prints why and + // exits with 2 when it does not load. HMODULE loadRuntimeLibrary() { HMODULE runtime = LoadLibraryA(CT_RUNTIME_LIBRARY); @@ -1254,7 +1254,7 @@ namespace } // Frees a DLL that links the runtime. Prints why and exits with 2 when the DLL stays mapped: - // its copy's handler would still have its code. + // FreeLibrary must unload it for real. void unloadRuntimeLibrary(HMODULE module) { FreeLibrary(module); @@ -1312,8 +1312,8 @@ namespace faultOnAThread(&raiseUnhandledException); } - // Once FreeLibrary has unloaded the DLL, whose code is gone, the executable's handler reports - // the crash (#236). + // Once FreeLibrary has unloaded the DLL, the executable's handler reports the crash: the DLL's + // copy installed none, which would have gone with the DLL's code (#236). TEST(BacktraceReplaceDeathTest, OnceADllThatLinksTheRuntimeUnloadsTheExecutableReportsCrashes) { GTEST_FLAG_SET(death_test_style, "threadsafe"); @@ -1343,8 +1343,8 @@ namespace faultOnAThread(&raiseUnhandledException); } - // The filter that the program installed before the DLL's copy installed its handler is back - // once FreeLibrary has unloaded the DLL (#236). + // The filter that the program installed before it loaded the DLL reports the crash once + // FreeLibrary has unloaded the DLL: the DLL's copy did not replace it (#236). TEST(BacktraceReplaceDeathTest, TheHostsFilterInstalledBeforeADllIsBackOnceItUnloads) { GTEST_FLAG_SET(death_test_style, "threadsafe"); @@ -1362,7 +1362,7 @@ namespace faultOnAThread(&raiseUnhandledException); } - // Unloading the DLL leaves the filter that the program installed over its copy's handler + // Unloading the DLL leaves the filter that the program installed after it loaded the DLL // (#236). TEST(BacktraceReplaceDeathTest, ADllUnloadedLeavesTheHostsFilterInstalledAfterIt) { @@ -1373,19 +1373,19 @@ namespace "host filter e0000225"); } - // A copy of a DLL that links the runtime, by default the one of reload/ct_runtime_library.cpp, - // at a path of the child's own: the loader maps each copy as a module apart, with a copy of the - // runtime of its own. Prints why and exits with 2 when it does not load. + // A copy of the DLL of reload/ct_runtime_library.cpp, which links the runtime, at a path of the + // child's own: the loader maps each copy as a module apart, with a copy of the runtime of its + // own. Prints why and exits with 2 when it does not load. class RuntimeLibraryCopy { public: - explicit RuntimeLibraryCopy(const char* name, const char* source = CT_RUNTIME_LIBRARY) + explicit RuntimeLibraryCopy(const char* name) : path_(fs::path(testing::TempDir()) / - (fs::path(source).stem().string() + "_" + name + "_" + + (fs::path(CT_RUNTIME_LIBRARY).stem().string() + "_" + name + "_" + std::to_string(GetCurrentProcessId()) + ".dll")) { std::error_code error; - fs::copy_file(source, path_, fs::copy_options::overwrite_existing, error); + fs::copy_file(CT_RUNTIME_LIBRARY, path_, fs::copy_options::overwrite_existing, error); module_ = error ? nullptr : LoadLibraryW(path_.c_str()); if (!module_) { @@ -1414,8 +1414,8 @@ namespace HMODULE module_ = nullptr; }; - // Loads two copies of the DLL that links the runtime, whose copies of the runtime each install - // their handler over the one before, unloads them in load order or in reverse, then faults. + // Loads two copies of the DLL that links the runtime, unloads them in load order or in reverse, + // then faults. [[noreturn]] void faultAfterTwoDllsUnloaded(bool in_load_order) { RuntimeLibraryCopy first("first"); @@ -1426,8 +1426,8 @@ namespace faultOnAThread(&raiseUnhandledException); } - // The second copy's handler was installed over the first's: unloaded first, it leaves the - // first copy's handler, which leaves the executable's (#236). + // Neither DLL's copy of the runtime installs a handler: once both DLLs have unloaded, the second + // first, the executable's handler reports the crash (#236). TEST(BacktraceReplaceDeathTest, OnceTwoDllsUnloadInReverseOrderTheExecutableReportsCrashes) { GTEST_FLAG_SET(death_test_style, "threadsafe"); @@ -1436,9 +1436,7 @@ namespace crashBacktraceFrom("handler:2 ")); } - // The first copy unloads while the second's handler, installed over its own, is still there: - // once the second copy unloads too, the executable's handler reports the crash, not the first - // copy's, whose code is gone (#236). + // Nor when the first DLL unloads first (#236). TEST(BacktraceReplaceDeathTest, OnceTwoDllsUnloadInLoadOrderTheExecutableReportsCrashes) { GTEST_FLAG_SET(death_test_style, "threadsafe"); From a2395d242680e21f394c3a3a6e876b4ea9149803 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:40:05 +0100 Subject: [PATCH 48/59] test(runtime): check that the executable reports a crash in a DLL The DLL that links the runtime faults in its own code while it is loaded. The executable's copy of the runtime, the only one that installs a crash handler, prints the backtrace: its first frame lies in the executable's image, and a frame lies in the DLL's. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index b2ee6472..c48f8291 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1322,6 +1322,46 @@ namespace crashBacktraceFrom("handler:2 ")); } + // A crash backtrace with a frame in the image that the child printed after `mark`. + testing::Matcher aFrameIn(const char* mark) + { + return testing::Truly( + [mark](const std::string& output) + { + const CrashImage image = imageAfter(output, mark); + const std::vector frames = crashFramesOf(output); + return std::any_of(frames.begin(), frames.end(), [&](const CrashFrame& frame) + { return image.holds(frame.address); }); + }); + } + + // Loads the DLL that links the runtime, then faults in the DLL's own code. Prints why and exits + // with 2 when the DLL has no function that faults. + [[noreturn]] void faultInADllThatLinksTheRuntime() + { + HMODULE runtime = loadRuntimeLibrary(); + auto* fault = + reinterpret_cast(GetProcAddress(runtime, "ct_runtime_library_fault")); + if (!fault) + { + std::fprintf(stderr, "the runtime library has no function that faults\n"); + std::exit(2); + } + printImage("handler:2", GetModuleHandleW(nullptr)); + printImage("dll:2", runtime); + faultOnAThread(fault); + } + + // The executable's handler reports a crash in the code of a DLL that links the runtime, whose + // copy installs no handler (#236). + TEST(BacktraceReplaceDeathTest, ACrashInADllThatLinksTheRuntimeIsReportedByTheExecutable) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(faultInADllThatLinksTheRuntime(), endedByTheFault(), + testing::AllOf(crashBacktraceFrom("handler:2 "), aFrameIn("dll:2 "))); + } + // A filter that the program installs for the exceptions that nothing handles, as a crash // reporter does: it prints the code of the exception, then ends the process with an exit code // of its own. From 76506ab4311b6e77eaf38b512415a492370b4bb4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:40:17 +0100 Subject: [PATCH 49/59] test(runtime): check that a DLL's copy installs no crash handler In a program that does not link the runtime, which has no handler of its own, the DLL that links the runtime loads and unloads without installing a filter: the process's filter stays null. Such a program gets no crash backtrace from an instrumented DLL, the limit of the contract under which only the executable's copy installs the handler. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 39 +++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index c48f8291..3ee7ee1b 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1376,6 +1376,15 @@ namespace return EXCEPTION_CONTINUE_SEARCH; } + // The process's filter, read the only way Windows allows: by setting another one, then putting + // it back. + LPTOP_LEVEL_EXCEPTION_FILTER currentFilter() + { + const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(&hostFilter); + SetUnhandledExceptionFilter(current); + return current; + } + [[noreturn]] void faultWithTheHostsFilterInstalledBeforeADll() { SetUnhandledExceptionFilter(&hostFilter); @@ -1413,6 +1422,27 @@ namespace "host filter e0000225"); } + // Removes the executable's handler, as for a program that does not link the runtime, then loads + // the DLL that links it and unloads it. Exits with 3 when the DLL's copy installed a filter, with + // 0 otherwise. + [[noreturn]] void loadADllThatLinksTheRuntimeWithoutAHandler() + { + SetUnhandledExceptionFilter(nullptr); + HMODULE runtime = loadRuntimeLibrary(); + const bool untouched = currentFilter() == nullptr; + unloadRuntimeLibrary(runtime); + std::exit(untouched ? 0 : 3); + } + + // In a program that does not link the runtime, the DLL that links it installs no crash handler: + // an instrumented DLL that such a program loads reports no crash (#236). + TEST(BacktraceReplaceDeathTest, ADllThatLinksTheRuntimeInstallsNoHandlerInAProgramWithoutOne) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(loadADllThatLinksTheRuntimeWithoutAHandler(), testing::ExitedWithCode(0), ""); + } + // A copy of the DLL of reload/ct_runtime_library.cpp, which links the runtime, at a path of the // child's own: the loader maps each copy as a module apart, with a copy of the runtime of its // own. Prints why and exits with 2 when it does not load. @@ -1485,15 +1515,6 @@ namespace crashBacktraceFrom("handler:2 ")); } - // The process's filter, read the only way Windows allows: by setting another one, then putting - // it back. - LPTOP_LEVEL_EXCEPTION_FILTER currentFilter() - { - const LPTOP_LEVEL_EXCEPTION_FILTER current = SetUnhandledExceptionFilter(&hostFilter); - SetUnhandledExceptionFilter(current); - return current; - } - // Points the import of `function` from kernel32.dll by the loaded `module` at `replacement`, and // returns what it pointed at, as the loader resolved it: null when `module` does not import it. void* patchImport(HMODULE module, const char* function, void* replacement) From d4a9757c847cb677a67c8a06d0a822757a17d408 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:40:27 +0100 Subject: [PATCH 50/59] test(runtime): check the host's filter while a DLL is loaded The test of a filter that the program installs before it loads the DLL that links the runtime checked only the crash after the DLL unloaded. It now checks that the filter stays the process's while the DLL is loaded too, and exits with 3 if the DLL's copy replaced it. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 3ee7ee1b..35a72ad5 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1385,16 +1385,24 @@ namespace return current; } + // Installs the host's filter, loads the DLL that links the runtime, unloads it, then faults. + // Prints why and exits with 3 when the DLL's copy replaced the host's filter. [[noreturn]] void faultWithTheHostsFilterInstalledBeforeADll() { SetUnhandledExceptionFilter(&hostFilter); - unloadRuntimeLibrary(loadRuntimeLibrary()); + HMODULE runtime = loadRuntimeLibrary(); + if (currentFilter() != &hostFilter) + { + std::fprintf(stderr, "the runtime library replaced the host's filter\n"); + std::exit(3); + } + unloadRuntimeLibrary(runtime); faultOnAThread(&raiseUnhandledException); } - // The filter that the program installed before it loaded the DLL reports the crash once - // FreeLibrary has unloaded the DLL: the DLL's copy did not replace it (#236). - TEST(BacktraceReplaceDeathTest, TheHostsFilterInstalledBeforeADllIsBackOnceItUnloads) + // The filter that the program installed before it loaded the DLL stays the process's while the + // DLL is loaded, and reports the crash once FreeLibrary has unloaded the DLL (#236). + TEST(BacktraceReplaceDeathTest, TheHostsFilterInstalledBeforeADllStaysWhileItComesAndGoes) { GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; From 009745b56c09562617f715dad920c856763e2fda Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:41:08 +0100 Subject: [PATCH 51/59] test(runtime): check an unload while another thread changes the filter The release that put a DLL copy's filter back went on exchanging filters for as long as another thread changed the filter between two of its calls, with the loader lock held. The test points the copy's import of SetUnhandledExceptionFilter at a stand-in after which another thread installs one of two filters in turn, 10000 times at most, and fails with 4 when the copy kept calling for that long. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 35a72ad5..60005595 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1569,4 +1569,74 @@ namespace } return nullptr; } + + // SetUnhandledExceptionFilter as the loader resolved it for the DLL's copy of the runtime, and + // how many calls the copy made to the stand-in that replaces it. + using SetFilterFunction = LPTOP_LEVEL_EXCEPTION_FILTER(WINAPI*)(LPTOP_LEVEL_EXCEPTION_FILTER); + SetFilterFunction resolvedSetFilter = nullptr; + std::atomic copySetFilterCalls{0}; + + // Points the DLL's import of SetUnhandledExceptionFilter at `stand_in`. Prints why and exits + // with 2 when the DLL does not import it. + void replaceTheCopysSetFilter(HMODULE runtime, SetFilterFunction stand_in) + { + resolvedSetFilter = reinterpret_cast( + patchImport(runtime, "SetUnhandledExceptionFilter", reinterpret_cast(stand_in))); + if (!resolvedSetFilter) + { + std::fprintf(stderr, "the runtime library imports no SetUnhandledExceptionFilter\n"); + std::exit(2); + } + } + + // Two filters that another thread installs in turn, whose bodies differ so that the linker does + // not fold them into one. + LONG WINAPI firstAlternateFilter(EXCEPTION_POINTERS*) + { + return EXCEPTION_CONTINUE_SEARCH; + } + + LONG WINAPI secondAlternateFilter(EXCEPTION_POINTERS*) + { + return EXCEPTION_EXECUTE_HANDLER; + } + + // How many times, at most, that thread changes the filter. + constexpr int kAlternations = 10000; + + // A stand-in for the copy's SetUnhandledExceptionFilter: after each of its calls, another thread + // installs one of its two filters in turn, kAlternations times at most. + LPTOP_LEVEL_EXCEPTION_FILTER WINAPI + setFilterWhileAnotherThreadAlternates(LPTOP_LEVEL_EXCEPTION_FILTER filter) + { + const LPTOP_LEVEL_EXCEPTION_FILTER replaced = resolvedSetFilter(filter); + const int call = copySetFilterCalls.fetch_add(1); + if (call < kAlternations) + { + resolvedSetFilter(call % 2 == 0 ? &firstAlternateFilter : &secondAlternateFilter); + } + return replaced; + } + + // Loads the DLL that links the runtime and installs the host's filter. While FreeLibrary unloads + // the DLL, another thread changes the filter after each of the copy's calls to + // SetUnhandledExceptionFilter, as the stand-in does. Exits with 4 when the copy kept calling for + // as long as that thread changed the filter, with 0 otherwise. + [[noreturn]] void unloadWhileAnotherThreadChangesTheFilter() + { + HMODULE runtime = loadRuntimeLibrary(); + SetUnhandledExceptionFilter(&hostFilter); + replaceTheCopysSetFilter(runtime, &setFilterWhileAnotherThreadAlternates); + unloadRuntimeLibrary(runtime); + std::exit(copySetFilterCalls.load() >= kAlternations ? 4 : 0); + } + + // FreeLibrary, which holds the loader lock, does not wait on another thread that keeps changing + // the filter while the DLL unloads: the DLL's copy leaves the filter alone (#236). + TEST(BacktraceReplaceDeathTest, ADllUnloadsWhileAnotherThreadKeepsChangingTheFilter) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(unloadWhileAnotherThreadChangesTheFilter(), testing::ExitedWithCode(0), ""); + } } // namespace From 83c5f32acc29af9b6ca420327ef1691c77de90dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:41:21 +0100 Subject: [PATCH 52/59] test(runtime): check that the host's last filter stays after an unload When the release of a DLL copy's filter had set, for a moment, the filter that its handler replaced, and the host installed that same filter again meanwhile, the release could not tell the two apart and put back the filter the host had replaced. The test points the copy's import of SetUnhandledExceptionFilter at a stand-in after whose first call the host installs again the filter that call set, and fails with 3 when the process's filter is not then the one the host installed last. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 60005595..817d1605 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1639,4 +1639,46 @@ namespace BacktraceInChildren backtrace; EXPECT_EXIT(unloadWhileAnotherThreadChangesTheFilter(), testing::ExitedWithCode(0), ""); } + + // The filter that the host installed last, as the child and the stand-in below record it. + std::atomic hostsLastFilter{nullptr}; + + // A stand-in for the copy's SetUnhandledExceptionFilter: right after the copy's first call, the + // host installs again the filter that this call set, as a host may that puts back a filter it + // had read. + LPTOP_LEVEL_EXCEPTION_FILTER WINAPI + setFilterThenTheHostSetsItAgain(LPTOP_LEVEL_EXCEPTION_FILTER filter) + { + const LPTOP_LEVEL_EXCEPTION_FILTER replaced = resolvedSetFilter(filter); + if (copySetFilterCalls.fetch_add(1) == 0) + { + resolvedSetFilter(filter); + hostsLastFilter.store(filter); + } + return replaced; + } + + // Loads the DLL that links the runtime and installs the host's filter. While FreeLibrary unloads + // the DLL, the host installs again the filter that the copy's first call to + // SetUnhandledExceptionFilter set, as the stand-in does. Exits with 3 when the process's filter + // is not then the one the host installed last, with 0 otherwise. + [[noreturn]] void unloadWhileTheHostSetsAFilterAgain() + { + HMODULE runtime = loadRuntimeLibrary(); + SetUnhandledExceptionFilter(&hostFilter); + hostsLastFilter.store(&hostFilter); + replaceTheCopysSetFilter(runtime, &setFilterThenTheHostSetsItAgain); + unloadRuntimeLibrary(runtime); + std::exit(currentFilter() == hostsLastFilter.load() ? 0 : 3); + } + + // The filter that the host installed last stays the process's once FreeLibrary has unloaded the + // DLL, even when the host installed it during the unload, at the address of a filter that the + // DLL's copy had set for that moment (#236). + TEST(BacktraceReplaceDeathTest, TheFilterTheHostInstallsLastStaysOnceADllUnloads) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + BacktraceInChildren backtrace; + EXPECT_EXIT(unloadWhileTheHostSetsAFilterAgain(), testing::ExitedWithCode(0), ""); + } } // namespace From cb535086e1f3e17ad77fce2d5778c9fd9433c3bc Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:41:33 +0100 Subject: [PATCH 53/59] test(runtime): describe the crash handler tests in the file's header Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 817d1605..bb696ce7 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -12,10 +12,11 @@ // one forever. The crash handler's backtraces, which name frames through the same session, name // none after a DLL unloaded since (#225). The copy in a DLL frees the names it decoded when the DLL // unloads: the DLL keeps no heap block at each load, as one without the runtime. The boxes of the -// DLL's destructors still name types then, as they do when the process exits (#227). Once a DLL -// that links the runtime unloads, the handler installed before its copy's reports the process's -// crashes, the executable's or the program's own filter, and a filter installed over the copy's -// stays, whatever the order in which two such DLLs unload (#236). +// DLL's destructors still name types then, as they do when the process exits (#227). Only the +// executable's copy installs a crash handler: it reports a crash in the code of a DLL that links +// the runtime, whose copy installs none, and the crashes once such DLLs have unloaded for real, in +// either order. A DLL's copy leaves the program's filter alone while the DLL is loaded and when it +// unloads, however the program or another thread changes that filter meanwhile (#236). #include #include From 99dea850e5ecba9b3ede41ba1ddbde3447af6a56 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:42:08 +0100 Subject: [PATCH 54/59] docs(readme): say that only the executable's copy installs the handler The section on the crash handler on Windows described the hand-over of the filter between the copies of DLLs. It now says that only the executable's copy installs a handler, which reports the crashes in an instrumented DLL's code too, that a DLL's copy leaves the program's filter alone and lets FreeLibrary unload the DLL as any other, and the limit: in a program that does not link the runtime, an instrumented DLL reports no crash. Refs #236 --- README.md | 45 ++++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 3617ba8e..d688e0e0 100644 --- a/README.md +++ b/README.md @@ -286,35 +286,22 @@ runtime as follows: ## Crash handler on Windows -With `CT_BACKTRACE` set, each copy of the runtime, the executable's and that of each DLL that links -the runtime, installs its crash handler for the whole process with `SetUnhandledExceptionFilter` -when its module initializes, over the filter installed before it: the handler of another copy, or a -filter of the program. When `FreeLibrary` unloads a DLL, its copy puts that filter back if its -handler is still the process's. A filter installed over the handler since stays: - -- a filter of the program stays the process's. Once the DLL is unloaded, it must not call the - handler it replaced, whose code is gone; -- the handler of another DLL's copy gets the filter that the unloaded copy had replaced, and puts it - back when its own DLL unloads. - -Each copy keeps the filter it replaced in a section `.ctcrash` of its module. A DLL's copy links -that record to the one of the copy whose handler it replaced when it installs its own, so that -`FreeLibrary` hands the filter over without allocating anything or listing the modules. When the -copy cannot read the headers of the module that holds the filter in place, as a packer or an -anti-tamper check can leave them, it cannot tell whether that filter is another copy's handler: it -leaves its own handler out then, and says so. - -Windows has no call that reads the process's filter without setting one. The copy that unloads -finds out by setting the filter it replaced, and puts back at once the filter that this call -returns when that is not its handler. A filter that another thread installs between two of those -calls is put back in turn. That thread got from `SetUnhandledExceptionFilter`, as the filter it -replaced, the one that the copy had set for that moment. - -Nothing changes when the process terminates, nor when a DLL ends the process with its own C -runtime's `exit()`, which runs the DLL's exit-time code before the loader detaches it, nor in the -executable's copy. Before Windows 10, a DLL's copy cannot tell `FreeLibrary` apart from the -process's termination: it puts nothing back, and links its record to none, so that its handler -stays the process's filter once `FreeLibrary` has unloaded the DLL (#236). +With `CT_BACKTRACE` set, only the executable's copy of the runtime installs a crash handler for the +whole process, with `SetUnhandledExceptionFilter`, when the executable initializes. The copy in a +DLL that links the runtime installs none and leaves the process's filter alone, while the DLL is +loaded and when it unloads; `FreeLibrary` unloads the DLL as any other (#236). + +- In an executable that links the runtime, its handler reports the crashes of the whole process, + those in the code of an instrumented DLL included, while the DLL is loaded and once it has + unloaded. +- A filter that the program installs, before or after it loads such a DLL, stays the process's. +- **Limit.** In a program that does not link the runtime, an instrumented DLL reports no crash, + even with `CT_BACKTRACE` set: the program's own filter, if any, or Windows handles it. + +Windows has no call that sets the process's filter only if it still is the one expected. A DLL's +copy that installed its handler could not take it out when the DLL unloads without overwriting a +filter that the program or another thread installs meanwhile, and a filter installed over the +handler would call it once its code is gone. ## LLVM versions From 681e632fade69c85f8ef552acbab7fa93344cefc Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Nov 2026 23:42:22 +0100 Subject: [PATCH 55/59] docs(changelog): note that only the executable installs the handler The entry of #236 described the hand-over of a DLL copy's filter when FreeLibrary unloads the DLL. It now says that only the executable's copy installs the crash handler, which reports the crashes in an instrumented DLL's code too, and that an instrumented DLL that a program without the runtime loads reports no crash any more. Refs #236 --- CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7ccf23..14983d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,14 +79,13 @@ pull request. measurement of #227, whose DLL decoded two names. The copy now frees the cache when `FreeLibrary` unloads the DLL, after the DLL's destructors, whose boxes still name types (#227). - **The crash handler of an unloaded DLL, on Windows.** With `CT_BACKTRACE`, the copy of the - runtime in a DLL that links it installs its crash handler over the filter installed before it, + runtime in a DLL that links it installed its crash handler over the filter installed before it, and nothing put that filter back. Once `FreeLibrary` had unloaded the DLL, an exception that nothing handled ended the process without a backtrace: neither the executable's handler nor a - filter that the program had installed before the DLL ran. The copy now puts the filter back when - `FreeLibrary` unloads the DLL, if its handler is still the process's. A filter installed over it - since stays; when that is the handler of another DLL's copy, that copy gets the filter, and puts - it back when its own DLL unloads. A DLL's copy that cannot read the headers of the module of the - filter in place leaves its own handler out (README, "Crash handler on Windows") (#236). + filter that the program had installed before the DLL ran. Only the executable's copy installs the + crash handler now: it reports the crashes in an instrumented DLL's code too, and a DLL's copy + leaves the process's filter alone. An instrumented DLL that a program without the runtime loads + reports no crash any more (README, "Crash handler on Windows") (#236). ### Tests and CI From d6c3c1b1754d1774324a02f80e459ad049fb544f Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 3 Nov 2026 00:16:29 +0100 Subject: [PATCH 56/59] test(runtime): match a crash in a DLL with gtest's matchers alone The test of a crash in the DLL's code combined its two checks with testing::Truly and testing::AllOf, which come from gmock: the runtime unit tests link gtest only, and the file did not build on Windows. The matcher of a crash backtrace printed by a given handler now takes the mark of the image where a frame must lie too. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 45 +++++++++++++---------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index bb696ce7..73c74768 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1268,11 +1268,14 @@ namespace // A crash backtrace that names RaiseException, printed by the handler of the module whose image // the child printed after `mark`: the first frame, which the handler takes in itself, lies in - // that image. + // that image. With `fault_mark`, a frame lies in the image that the child printed after it too. class CrashBacktraceFrom : public testing::MatcherInterface { public: - explicit CrashBacktraceFrom(const char* mark) : mark_(mark) {} + CrashBacktraceFrom(const char* mark, const char* fault_mark) + : mark_(mark), fault_mark_(fault_mark) + { + } bool MatchAndExplain(const std::string& output, testing::MatchResultListener* listener) const override @@ -1282,11 +1285,22 @@ namespace { return false; } - if (!imageAfter(output, mark_).holds(crashFramesOf(output).front().address)) + const std::vector frames = crashFramesOf(output); + if (!imageAfter(output, mark_).holds(frames.front().address)) { *listener << "the first frame is not in the image after " << mark_; return false; } + if (fault_mark_) + { + const CrashImage fault = imageAfter(output, fault_mark_); + if (std::none_of(frames.begin(), frames.end(), [&](const CrashFrame& frame) + { return fault.holds(frame.address); })) + { + *listener << "no frame is in the image after " << fault_mark_; + return false; + } + } return true; } @@ -1295,15 +1309,21 @@ namespace *os << "is a crash backtrace that names RaiseException, printed by the handler in the " "image after " << mark_; + if (fault_mark_) + { + *os << ", with a frame in the image after " << fault_mark_; + } } private: const char* mark_; + const char* fault_mark_; }; - testing::Matcher crashBacktraceFrom(const char* mark) + testing::Matcher crashBacktraceFrom(const char* mark, + const char* fault_mark = nullptr) { - return testing::MakeMatcher(new CrashBacktraceFrom(mark)); + return testing::MakeMatcher(new CrashBacktraceFrom(mark, fault_mark)); } [[noreturn]] void faultAfterADllThatLinksTheRuntimeUnloaded() @@ -1323,19 +1343,6 @@ namespace crashBacktraceFrom("handler:2 ")); } - // A crash backtrace with a frame in the image that the child printed after `mark`. - testing::Matcher aFrameIn(const char* mark) - { - return testing::Truly( - [mark](const std::string& output) - { - const CrashImage image = imageAfter(output, mark); - const std::vector frames = crashFramesOf(output); - return std::any_of(frames.begin(), frames.end(), [&](const CrashFrame& frame) - { return image.holds(frame.address); }); - }); - } - // Loads the DLL that links the runtime, then faults in the DLL's own code. Prints why and exits // with 2 when the DLL has no function that faults. [[noreturn]] void faultInADllThatLinksTheRuntime() @@ -1360,7 +1367,7 @@ namespace GTEST_FLAG_SET(death_test_style, "threadsafe"); BacktraceInChildren backtrace; EXPECT_EXIT(faultInADllThatLinksTheRuntime(), endedByTheFault(), - testing::AllOf(crashBacktraceFrom("handler:2 "), aFrameIn("dll:2 "))); + crashBacktraceFrom("handler:2 ", "dll:2 ")); } // A filter that the program installs for the exceptions that nothing handles, as a crash From 001d887160086a3f3a4cbbdfac3307ef7692e399 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 3 Nov 2026 22:30:34 +0100 Subject: [PATCH 57/59] test(runtime): make the other thread's filter differ from the one set The test of an unload while another thread changes the filter passed on 0111c53, whose release looped for as long as another thread changed the filter between two of its calls. Its stand-in installed its two filters in turn by the parity of the call, so the third call returned the filter that the second had set: the release took it for its own and stopped. The stand-in now installs whichever of its two filters the copy's call did not set, as a thread that keeps changing the filter does. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 73c74768..9313d188 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1613,15 +1613,16 @@ namespace constexpr int kAlternations = 10000; // A stand-in for the copy's SetUnhandledExceptionFilter: after each of its calls, another thread - // installs one of its two filters in turn, kAlternations times at most. + // installs whichever of its two filters the call did not set, kAlternations times at most. A + // filter that the call had just set would let the copy take it for its own and stop. LPTOP_LEVEL_EXCEPTION_FILTER WINAPI setFilterWhileAnotherThreadAlternates(LPTOP_LEVEL_EXCEPTION_FILTER filter) { const LPTOP_LEVEL_EXCEPTION_FILTER replaced = resolvedSetFilter(filter); - const int call = copySetFilterCalls.fetch_add(1); - if (call < kAlternations) + if (copySetFilterCalls.fetch_add(1) < kAlternations) { - resolvedSetFilter(call % 2 == 0 ? &firstAlternateFilter : &secondAlternateFilter); + resolvedSetFilter(filter == &firstAlternateFilter ? &secondAlternateFilter + : &firstAlternateFilter); } return replaced; } From b022ebc3ec68d30d703103bfe5ee8c97a6a205ab Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Nov 2026 03:15:28 +0100 Subject: [PATCH 58/59] test(runtime): start the unload test's watchdog before the DLL loads The child of the tests that unload a DLL linking the runtime, then load another, started its watchdog thread just before LoadLibrary, without waiting for it. A thread takes the loader lock to start, to attach to the loaded modules: when the unload waits forever with that lock held before the watchdog has started, the watchdog waits too, and nothing ends the child. startWatchdog() now returns once the watchdog runs, and the child calls it before it loads the DLL. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 32 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 9313d188..d2131013 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -171,6 +171,30 @@ namespace HANDLE file_ = INVALID_HANDLE_VALUE; }; + // Starts a thread that ends the process with 7 once 20 seconds have passed, for a death + // test's child, and returns once that thread runs: a thread takes the loader lock to start, so + // one started just before a LoadLibrary or a FreeLibrary that waits forever with that lock + // held would wait too, and nothing would end the process. Exits with 2 when it cannot wait + // for the thread. + void startWatchdog() + { + HANDLE running = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!running) + { + std::exit(2); + } + std::thread( + [running] + { + SetEvent(running); + Sleep(20000); + TerminateProcess(GetCurrentProcess(), 7); + }) + .detach(); + WaitForSingleObject(running, INFINITE); + CloseHandle(running); + } + TEST(VtableReplace, ADllReplacedAtItsPathWithItsTimeStampIsNamedAgain) { const fs::path path = fs::path(testing::TempDir()) / "ct_replaced_library.dll"; @@ -739,13 +763,7 @@ namespace // having called into the DLL unloaded, cannot let it exit. [[noreturn]] void unloadARuntimeLibraryThenLoadAnother(bool trace_before) { - std::thread( - [] - { - Sleep(20000); - TerminateProcess(GetCurrentProcess(), 7); - }) - .detach(); + startWatchdog(); DllStandardError output(fs::path(testing::TempDir()) / "ct_runtime_library_unload.txt"); HMODULE runtime = LoadLibraryA(CT_RUNTIME_LIBRARY); FARPROC entry = runtime ? GetProcAddress(runtime, "ct_runtime_library_trace") : nullptr; From 2f8051adafb8eac877178687e71b5a450ad700eb Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Nov 2026 03:15:48 +0100 Subject: [PATCH 59/59] test(runtime): start the crash tests' watchdog before the fault's thread The child of the crash tests started its watchdog thread without waiting for it, then the thread that faults. A thread takes the loader lock to start: should the fault's thread take that lock and wait forever with it held before the watchdog has started, the watchdog would wait too, and nothing would end the child. The child now waits until the watchdog runs before it starts the fault's thread. Refs #236 --- test/unit/runtime_vtable_replace_test.cpp | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index d2131013..046b5fce 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -1122,13 +1122,7 @@ namespace // the process within 20 seconds. [[noreturn]] void faultOnAThread(void (*fault)()) { - std::thread( - [] - { - Sleep(20000); - TerminateProcess(GetCurrentProcess(), 7); - }) - .detach(); + startWatchdog(); std::thread(fault).join(); std::exit(8); }