From 20fe08a7babee85d41eacf6b39a0ba3694c19e42 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:07:08 +0100 Subject: [PATCH 01/28] test(runtime): check that a block a DLL keeps outlives the DLL A DLL built from reload/ct_allocating_library.cpp allocates through its copy of the runtime: it can free many blocks, which its copy keeps in its quarantine, and keep one. The block it keeps must still be in the process's heaps once FreeLibrary has unloaded the DLL: whatever its copy frees then, it must not free a block that the program may still use. Refs #227 --- test/unit/CMakeLists.txt | 8 +++++ test/unit/reload/ct_allocating_library.cpp | 34 ++++++++++++++++++++++ test/unit/runtime_vtable_replace_test.cpp | 30 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 test/unit/reload/ct_allocating_library.cpp diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index f65a072..d3d5bf3 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -169,6 +169,14 @@ if(TARGET ct_instrument_runtime) target_compile_definitions(runtime_unit_tests PRIVATE CT_RUNTIME_LIBRARY="$" CT_DLLMAIN_LIBRARY="$") + # A DLL that allocates through its copy of the runtime, which records the blocks and keeps + # those it frees in its quarantine (#227). + add_library(ct_allocating_library MODULE reload/ct_allocating_library.cpp) + target_link_libraries(ct_allocating_library PRIVATE ct_instrument_runtime coretrace_logger) + coretrace_force_msvc_runtime(ct_allocating_library) + add_dependencies(runtime_unit_tests ct_allocating_library) + target_compile_definitions(runtime_unit_tests PRIVATE + CT_ALLOCATING_LIBRARY="$") # A DLL's copy of the runtime reports its leaks when the process exits, after the DLL's # exit-time code, and says nothing when FreeLibrary unloads the DLL (#232). The program links # no runtime, so the report it prints is the DLL's copy's. diff --git a/test/unit/reload/ct_allocating_library.cpp b/test/unit/reload/ct_allocating_library.cpp new file mode 100644 index 0000000..5e4d704 --- /dev/null +++ b/test/unit/reload/ct_allocating_library.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A DLL that links the runtime and allocates through its copy, as an instrumented DLL does: the +// copy records the blocks in its allocation table and its block index, and keeps the blocks it +// frees in its quarantine. runtime_vtable_replace_test.cpp loads it, has it allocate, and unloads +// it, to check what its copy keeps once FreeLibrary has unloaded it (#227). Its allocation trace +// is off, so that many blocks print nothing. +#include + +extern "C" void* __ct_malloc(size_t size, const char* site); +extern "C" void __ct_free(void* ptr, const char* site); + +extern "C" int __ct_config_disable_alloc_trace = 1; + +// Allocates `count` blocks of `size` bytes through the copy of the runtime, then frees them all. +extern "C" __declspec(dllexport) void ct_allocating_library_churn(int count, size_t size) +{ + auto** blocks = static_cast(__ct_malloc(sizeof(void*) * count, "allocating:array")); + for (int index = 0; index < count; ++index) + { + blocks[index] = __ct_malloc(size, "allocating:block"); + } + for (int index = 0; index < count; ++index) + { + __ct_free(blocks[index], "allocating:block"); + } + __ct_free(blocks, "allocating:array"); +} + +// A block of `size` bytes allocated through the copy of the runtime, which the DLL never frees. +extern "C" __declspec(dllexport) void* ct_allocating_library_keep(size_t size) +{ + return __ct_malloc(size, "allocating:kept"); +} diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index d119318..c360708 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -545,6 +545,36 @@ namespace << describe(kept) << "\nwithout the runtime: " << describe(control); } + // Whether a busy block of the process's heaps holds `address`. + bool heapsHold(const void* address) + { + std::vector blocks; + blocks.reserve(1 << 18); + if (!listBusyBlocks(blocks)) + { + return false; + } + const auto* byte = static_cast(address); + return std::any_of(blocks.begin(), blocks.end(), [&](const HeapBlock& block) + { return byte >= block.address && byte < block.address + block.size; }); + } + + // A block that a DLL allocated through its copy of the runtime and still holds stays allocated + // once FreeLibrary has unloaded the DLL: its copy frees its record of the block, not the block, + // which the program may still use (#227). + TEST(DllAllocState, ABlockADllKeepsStaysAllocatedOnceItUnloads) + { + HMODULE library = LoadLibraryA(CT_ALLOCATING_LIBRARY); + ASSERT_NE(library, nullptr) << CT_ALLOCATING_LIBRARY; + auto* keep = reinterpret_cast( + GetProcAddress(library, "ct_allocating_library_keep")); + ASSERT_NE(keep, nullptr); + void* block = keep(48); + ASSERT_NE(block, nullptr); + FreeLibrary(library); + EXPECT_TRUE(heapsHold(block)); + } + // The line `label` of the first box of `site`, empty without one. std::string lineOf(const std::string& boxes, const char* site, const char* label) { From a61c079931fb125227373305ffc304685dbca04e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:07:44 +0100 Subject: [PATCH 02/28] refactor(runtime): keep the Windows block index at file scope The block index of the Windows allocator, and the storage it starts with, move from statics of ct_alloc_index() to the file's anonymous namespace, so that the allocator can empty the index onto that storage again. Both are still zero-initialized, in the module's image. Refs #227 --- src/runtime/windows/ct_runtime_alloc.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/runtime/windows/ct_runtime_alloc.cpp b/src/runtime/windows/ct_runtime_alloc.cpp index 477b9f7..32c8151 100644 --- a/src/runtime/windows/ct_runtime_alloc.cpp +++ b/src/runtime/windows/ct_runtime_alloc.cpp @@ -80,6 +80,12 @@ namespace constexpr size_t kIndexInitialCapacity = size_t{1} << 12; + // The index below, and the storage it starts with, in the module's image. + alignas(uintptr_t) unsigned char ct_index_storage[ct_block_index_storage_size( + kIndexInitialCapacity)]; + ct_block_index ct_index; + bool ct_index_ready = false; + } // namespace // The index of the blocks the table records, live or freed, by address, for @@ -87,16 +93,12 @@ namespace // and changes with the record, under ct_alloc_lock. CT_NODISCARD CT_NOINSTR ct_block_index* ct_alloc_index() { - alignas( - uintptr_t) static unsigned char storage[ct_block_index_storage_size(kIndexInitialCapacity)]; - static ct_block_index index; - static bool ready = false; - if (!ready) + if (!ct_index_ready) { - ct_block_index_init(&index, storage, kIndexInitialCapacity); - ready = true; + ct_block_index_init(&ct_index, ct_index_storage, kIndexInitialCapacity); + ct_index_ready = true; } - return &index; + return &ct_index; } namespace From c5f61517c4155432e4f9bc60c8557438882a698b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:10:42 +0100 Subject: [PATCH 03/28] refactor(runtime): let the quarantine release every block and its queue ct_quarantine_release_all releases every block of the quarantine, as a limit of 0 does, dropping their freed records, then frees the queue that held them. The blocks released afterwards go to the allocator at once. A copy of the runtime whose module unloads will call it. Refs #227 --- src/runtime/ct_runtime_quarantine.cpp | 11 +++++++++++ src/runtime/ct_runtime_quarantine.h | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/src/runtime/ct_runtime_quarantine.cpp b/src/runtime/ct_runtime_quarantine.cpp index 94ddaf3..c11bfe9 100644 --- a/src/runtime/ct_runtime_quarantine.cpp +++ b/src/runtime/ct_runtime_quarantine.cpp @@ -170,6 +170,17 @@ CT_NOINSTR void ct_quarantine_set_limit(size_t bytes) ct_release_over_limit(); } +CT_NOINSTR void ct_quarantine_release_all(void) +{ + ct_quarantine_set_limit(0); + ct_lock_acquire(); + std::free(ct_queue); + ct_queue = nullptr; + ct_queue_capacity = 0; + ct_queue_head = 0; + ct_lock_release(); +} + CT_NODISCARD CT_NOINSTR size_t ct_quarantine_bytes(void) { ct_lock_acquire(); diff --git a/src/runtime/ct_runtime_quarantine.h b/src/runtime/ct_runtime_quarantine.h index e4b87e1..bee658e 100644 --- a/src/runtime/ct_runtime_quarantine.h +++ b/src/runtime/ct_runtime_quarantine.h @@ -36,6 +36,11 @@ CT_NOINSTR void ct_quarantine_push(const ct_quarantine_item& item); CT_NOINSTR void ct_quarantine_set_limit(size_t bytes); CT_NODISCARD CT_NOINSTR size_t ct_quarantine_bytes(void); +// Releases every block, as a limit of 0 does, then frees the queue: for a copy of the runtime +// whose module unloads (#227). The blocks released afterwards go to the allocator at once. +// ct_alloc_lock not held. +CT_NOINSTR void ct_quarantine_release_all(void); + // Takes the oldest block out of the quarantine, its freed record dropped, when the allocation // table needs the room. False when the quarantine is empty. The caller releases the block, with // ct_quarantine_release, once it no longer holds the lock: the block is in no queue and has no From 8613661f366fbf48e45e91f39128e59f75acc467 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:10:42 +0100 Subject: [PATCH 04/28] test(runtime): check that releasing the quarantine empties it After ct_quarantine_release_all, the quarantine holds no byte and none of the blocks it held has a freed record left; a block released afterwards goes to the allocator at once, without a freed record. Refs #227 --- test/unit/runtime_quarantine_test.cpp | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/runtime_quarantine_test.cpp b/test/unit/runtime_quarantine_test.cpp index a186ef8..390bd01 100644 --- a/test/unit/runtime_quarantine_test.cpp +++ b/test/unit/runtime_quarantine_test.cpp @@ -132,4 +132,36 @@ namespace EXPECT_EQ(__ct_realloc(released, 4 * kBlockSize, "test:realloc"), nullptr); EXPECT_EQ(recordedState(released), CT_ENTRY_FREED); } + + // Releasing everything empties the quarantine, the freed records of its blocks going with + // them, as a copy of the runtime does when its module unloads (#227). A block released + // afterwards goes to the allocator at once. + TEST_F(QuarantineTest, ReleaseAllEmptiesTheQuarantine) + { + std::vector released; + for (int i = 0; i < 4; ++i) + { + void* tracked = __ct_malloc(kBlockSize, "test:tracked"); + ASSERT_NE(tracked, nullptr); + released.push_back(tracked); + } + for (void* tracked : released) + { + __ct_free(tracked, "test:free"); + } + ASSERT_EQ(recordedState(released.back()), CT_ENTRY_FREED); + + ct_quarantine_release_all(); + EXPECT_EQ(ct_quarantine_bytes(), 0u); + for (void* tracked : released) + { + EXPECT_EQ(recordedState(tracked), 0); + } + + void* later = __ct_malloc(kBlockSize, "test:tracked"); + ASSERT_NE(later, nullptr); + __ct_free(later, "test:free"); + EXPECT_EQ(recordedState(later), 0); + EXPECT_EQ(ct_quarantine_bytes(), 0u); + } } // namespace From 9666a33eb494833a0e5888d12c304d2ccec20568 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:11:55 +0100 Subject: [PATCH 05/28] fix(runtime): free a DLL copy's allocation state at FreeLibrary The copy of the runtime in a DLL never destroyed its allocation table, nor freed what its block index grew into, its quarantine's queue, or the blocks its quarantine held: a DLL that allocated and freed through its copy left them all allocated at each load and unload. A probe kept about 42 KB per cycle for 64 blocks of 64 bytes freed, and 957 KB for 5000 blocks of 16 bytes, the block index grown included. The terminator of a DLL's copy, which its C runtime runs after the DLL's atexit handlers, destructors and destructor functions, now releases the quarantine's blocks and queue, empties the block index onto the storage it starts with, and destroys the table, when FreeLibrary unloads the DLL. It keeps them when the process exits, for the leak report, and while the DLL's own exit() runs. The blocks the DLL still holds stay allocated, without their records. Refs #227 --- src/runtime/windows/ct_runtime_alloc.cpp | 32 ++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/runtime/windows/ct_runtime_alloc.cpp b/src/runtime/windows/ct_runtime_alloc.cpp index 32c8151..3f9cb1a 100644 --- a/src/runtime/windows/ct_runtime_alloc.cpp +++ b/src/runtime/windows/ct_runtime_alloc.cpp @@ -57,7 +57,8 @@ namespace // The table and its lock live until the process ends: .CRT$XT terminators, which the // CRT runs after the destructors of every static object, may still release blocks, and // the leak report runs among them (#159). The lock needs no constructor nor destructor; - // the table is built on first use, in storage nothing destroys. + // the table is built on first use, in storage that only the terminator of a DLL's copy + // destroys, when FreeLibrary unloads the DLL (#227). SRWLOCK ct_alloc_lock = SRWLOCK_INIT; using CtAllocTable = std::unordered_map; @@ -1363,9 +1364,32 @@ namespace } } + // Frees what this copy of the runtime holds for the blocks of its DLL when FreeLibrary unloads + // the DLL, from the terminator below: the blocks in its quarantine, which the program had + // released, the quarantine's queue, what its block index grew into, and its allocation table. + // The DLL's code has all run by then: its atexit handlers, the destructors of its global + // objects, its destructor functions and its own terminators. Only the C runtime's teardown + // comes after, which calls no function of the runtime. The blocks that the DLL still holds stay + // allocated, without their records (#227). + CT_NOINSTR void ct_release_alloc_state() + { + if (!ct_alloc_table_built.load(std::memory_order_acquire)) + { + return; + } + ct_quarantine_release_all(); + ct_lock_acquire(); + ct_block_index_destroy(&ct_index); + ct_block_index_init(&ct_index, ct_index_storage, kIndexInitialCapacity); + ct_alloc_table().~CtAllocTable(); + ct_alloc_table_built.store(false, std::memory_order_release); + ct_lock_release(); + } + // A DLL's C runtime runs its .CRT$XTY terminators after its atexit handlers, the destructors // of its global objects and its destructor functions, when FreeLibrary unloads it and when the - // process exits. + // process exits. When FreeLibrary unloads it, the copy then frees its allocation state; it + // keeps it when the process exits, for the leak report, and while the DLL's own exit() runs. CT_NOINSTR void ct_report_dll_leaks_at_exit() { if (!ct_dll_detaching.load(std::memory_order_acquire)) @@ -1374,6 +1398,10 @@ namespace return; } ct_report_dll_leaks_once(); + if (ct_dll_unloading_dynamically()) + { + ct_release_alloc_state(); + } } } // namespace From 8bd6b007009e4e1acfc82d68a40a9841172e3309 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:12:20 +0100 Subject: [PATCH 06/28] test(runtime): check a DLL that allocates keeps no block at each load The DLL of reload/ct_allocating_library.cpp allocates 64 blocks of 64 bytes through its copy of the runtime and frees them, at each of 20 loads and unloads. Its copy must leave no block at each load in the process's heaps, beyond what a DLL without the runtime leaves: neither the blocks its quarantine held, nor the quarantine's queue, nor its allocation table. The failure message gives the blocks kept by size, without their content. Refs #227 --- test/unit/runtime_vtable_replace_test.cpp | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index c360708..9ee375e 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -575,6 +575,89 @@ namespace EXPECT_TRUE(heapsHold(block)); } + // Loads the DLL that allocates through its copy of the runtime, has it allocate `count` blocks + // of `size` bytes and free them, then unloads it. + bool loadChurnAndUnload(int count, size_t size) + { + HMODULE library = LoadLibraryA(CT_ALLOCATING_LIBRARY); + if (!library) + { + return false; + } + auto* churn = reinterpret_cast( + GetProcAddress(library, "ct_allocating_library_churn")); + if (churn) + { + churn(count, size); + } + FreeLibrary(library); + return churn != nullptr; + } + + // The blocks of the process's heaps that `cycles` loads of that DLL leave allocated, each + // freeing `count` blocks of `size` bytes, after two to warm up, into `kept`. + bool blocksKeptAcrossChurns(int cycles, int count, size_t size, std::vector& kept) + { + std::vector before; + std::vector after; + before.reserve(1 << 20); + after.reserve(1 << 20); + if (!loadChurnAndUnload(count, size) || !loadChurnAndUnload(count, size) || + !listBusyBlocks(before)) + { + return false; + } + for (int cycle = 0; cycle < cycles; ++cycle) + { + if (!loadChurnAndUnload(count, size)) + { + return false; + } + } + if (!listBusyBlocks(after)) + { + return false; + } + kept.clear(); + std::set_difference(after.begin(), after.end(), before.begin(), before.end(), + std::back_inserter(kept)); + return true; + } + + // How many blocks of each size `blocks` holds, without what they contain. + std::string sizesOf(const std::vector& blocks) + { + std::map sizes; + size_t bytes = 0; + for (const HeapBlock& block : blocks) + { + ++sizes[block.size]; + bytes += block.size; + } + std::ostringstream out; + out << blocks.size() << " blocks, " << bytes << " bytes:"; + for (const auto& [size, count] : sizes) + { + out << " " << count << " of " << size << " bytes;"; + } + return out.str(); + } + + // A DLL that allocates through its copy of the runtime, and frees what it allocates, keeps no + // heap block at each load once FreeLibrary has unloaded it, beyond what a DLL without the + // runtime keeps: its copy releases the blocks in its quarantine and the quarantine's queue, and + // destroys its allocation table (#227). + TEST(DllAllocState, ADllThatAllocatesThroughItsCopyKeepsNoHeapBlockAtEachLoad) + { + std::vector control; + ASSERT_TRUE(blocksKeptAcrossLoads(CT_RELOAD_LIBRARY_A, control)) << CT_RELOAD_LIBRARY_A; + std::vector kept; + ASSERT_TRUE(blocksKeptAcrossChurns(kLoadCycles, 64, 64, kept)) << CT_ALLOCATING_LIBRARY; + // A block kept at each load would add kLoadCycles of them. + EXPECT_LT(kept.size(), control.size() + kLoadCycles / 2) + << sizesOf(kept) << "\nwithout the runtime: " << sizesOf(control); + } + // The line `label` of the first box of `site`, empty without one. std::string lineOf(const std::string& boxes, const char* site, const char* label) { From 207460547339c15ec81cdde42dfea302c028ceee Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:12:46 +0100 Subject: [PATCH 07/28] test(runtime): check a DLL whose block index grew keeps no block At each of 4 loads and unloads, the DLL built from reload/ct_allocating_library.cpp allocates and frees 5000 blocks through its copy of the runtime, more than the 4096 that its block index holds before it grows. The copy must leave no block at each load, the storage the index grew into included. Refs #227 --- test/unit/runtime_vtable_replace_test.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 9ee375e..a9cb8bb 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -658,6 +658,21 @@ namespace << sizesOf(kept) << "\nwithout the runtime: " << sizesOf(control); } + // Nor when its copy's block index grew past the storage it starts with, which holds 4096 + // blocks: the copy frees what the index grew into (#227). + TEST(DllAllocState, ADllWhoseBlockIndexGrewKeepsNoHeapBlockAtEachLoad) + { + constexpr int kCycles = 4; + constexpr int kBlocks = 5000; + std::vector control; + ASSERT_TRUE(blocksKeptAcrossLoads(CT_RELOAD_LIBRARY_A, control)) << CT_RELOAD_LIBRARY_A; + std::vector kept; + ASSERT_TRUE(blocksKeptAcrossChurns(kCycles, kBlocks, 16, kept)) << CT_ALLOCATING_LIBRARY; + // A block kept at each load would add kCycles of them. + EXPECT_LT(kept.size(), control.size() + kCycles) + << sizesOf(kept) << "\nwithout the runtime: " << sizesOf(control); + } + // The line `label` of the first box of `site`, empty without one. std::string lineOf(const std::string& boxes, const char* site, const char* label) { From 5e5d11f0d5d84d6442f2e14aa97e361f2d5ff58b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:13:14 +0100 Subject: [PATCH 08/28] docs(readme): say what a DLL's copy frees when FreeLibrary unloads it Refs #227 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cda69d4..3330915 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,9 @@ reports the DLL's leaks when the process exits, after the DLL's `atexit` handler of its global objects and its destructor functions: from a terminator of the DLL, or, when the DLL itself ends the process with `exit()`, when the loader detaches it. That comes before the exit-time code of the modules that detach after the DLL. It reports nothing when `FreeLibrary` unloads the -DLL (#232). +DLL (#232). It frees its table then, after that exit-time code, with the blocks that its quarantine +held, which the DLL had released, and what its block index grew into; the blocks that the DLL still +holds stay allocated (#227). ## DbgHelp on Windows From 7c09bf010b54320862889665470eb03b93ea1686 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:13:14 +0100 Subject: [PATCH 09/28] docs(changelog): note the allocation state a DLL kept after it unloaded Refs #227 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1743325..40b3592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,13 @@ 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). +- **Memory kept by a DLL that allocates, after it unloaded, on Windows.** The copy of the runtime in + a DLL that links it never destroyed its allocation table, nor freed what its block index grew + into, its quarantine's queue or the blocks that its quarantine held. A DLL that allocated and + freed through its copy left them allocated at each load and unload: about 42 KB per cycle for 64 + blocks of 64 bytes freed, and 957 KB for 5000 blocks of 16 bytes, in a probe of #227. The copy now + frees them when `FreeLibrary` unloads the DLL, after the DLL's exit-time code, and keeps them when + the process exits, for the leak report (#227). ### Tests and CI From 5911fdf379d1fce9d4d1b773d2c099cdb7527412 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:46:26 +0100 Subject: [PATCH 10/28] test(runtime): check the block index over as many loads as the others Refs #227 A block kept at each load added 4 over 4 cycles, against fewer than 4 beyond the control: the check passed when the control kept more blocks than the DLL besides that one. Over 20 cycles against fewer than 10, as the test beside it, it fails whatever those few blocks. --- 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 a9cb8bb..7723a95 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -662,14 +662,15 @@ namespace // blocks: the copy frees what the index grew into (#227). TEST(DllAllocState, ADllWhoseBlockIndexGrewKeepsNoHeapBlockAtEachLoad) { - constexpr int kCycles = 4; constexpr int kBlocks = 5000; std::vector control; ASSERT_TRUE(blocksKeptAcrossLoads(CT_RELOAD_LIBRARY_A, control)) << CT_RELOAD_LIBRARY_A; std::vector kept; - ASSERT_TRUE(blocksKeptAcrossChurns(kCycles, kBlocks, 16, kept)) << CT_ALLOCATING_LIBRARY; - // A block kept at each load would add kCycles of them. - EXPECT_LT(kept.size(), control.size() + kCycles) + ASSERT_TRUE(blocksKeptAcrossChurns(kLoadCycles, kBlocks, 16, kept)) + << CT_ALLOCATING_LIBRARY; + // A block kept at each load, such as what the index grew into, would add kLoadCycles of + // them, whatever the few blocks that the DLL without the runtime leaves. + EXPECT_LT(kept.size(), control.size() + kLoadCycles / 2) << sizesOf(kept) << "\nwithout the runtime: " << sizesOf(control); } From 53591829030abef7cda8316dada5b34158d795cd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:47:02 +0100 Subject: [PATCH 11/28] test(runtime): build a DLL whose operator delete frees through its copy Refs #227 The same DLL as the one that allocates through its copy of the runtime, with an operator delete that frees through the copy: the blocks that the runtime releases for itself then reach its entry points, as through the copies of the library's inline functions that an instrumented DLL compiles (#197). --- test/unit/CMakeLists.txt | 17 +++++++++----- .../unit/reload/ct_delete_through_runtime.cpp | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 test/unit/reload/ct_delete_through_runtime.cpp diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index d3d5bf3..3d4ab72 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -170,13 +170,20 @@ if(TARGET ct_instrument_runtime) CT_RUNTIME_LIBRARY="$" CT_DLLMAIN_LIBRARY="$") # A DLL that allocates through its copy of the runtime, which records the blocks and keeps - # those it frees in its quarantine (#227). + # those it frees in its quarantine (#227); and the same DLL, whose operator delete frees + # through its copy, as the copies of the library's inline functions that an instrumented DLL + # compiles may (#197). add_library(ct_allocating_library MODULE reload/ct_allocating_library.cpp) - target_link_libraries(ct_allocating_library PRIVATE ct_instrument_runtime coretrace_logger) - coretrace_force_msvc_runtime(ct_allocating_library) - add_dependencies(runtime_unit_tests ct_allocating_library) + add_library(ct_delete_through_runtime_library MODULE reload/ct_allocating_library.cpp + reload/ct_delete_through_runtime.cpp) + foreach(library ct_allocating_library ct_delete_through_runtime_library) + target_link_libraries(${library} PRIVATE ct_instrument_runtime coretrace_logger) + coretrace_force_msvc_runtime(${library}) + add_dependencies(runtime_unit_tests ${library}) + endforeach() target_compile_definitions(runtime_unit_tests PRIVATE - CT_ALLOCATING_LIBRARY="$") + CT_ALLOCATING_LIBRARY="$" + CT_DELETE_THROUGH_RUNTIME_LIBRARY="$") # A DLL's copy of the runtime reports its leaks when the process exits, after the DLL's # exit-time code, and says nothing when FreeLibrary unloads the DLL (#232). The program links # no runtime, so the report it prints is the DLL's copy's. diff --git a/test/unit/reload/ct_delete_through_runtime.cpp b/test/unit/reload/ct_delete_through_runtime.cpp new file mode 100644 index 0000000..b580c4c --- /dev/null +++ b/test/unit/reload/ct_delete_through_runtime.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Linked into a DLL with ct_allocating_library.cpp: the DLL's operator delete frees through its +// copy of the runtime, as the copies of the library's inline functions that an instrumented DLL +// compiles may, which the linker can keep for the runtime too (#197). The blocks that the runtime +// releases for itself, the nodes of its allocation table among them, then reach its entry points. +#include + +extern "C" void __ct_free(void* ptr, const char* site); + +void operator delete(void* ptr) noexcept +{ + if (ptr) + { + __ct_free(ptr, "delete-through-runtime"); + } +} + +void operator delete(void* ptr, std::size_t) noexcept +{ + operator delete(ptr); +} From 38a3965e6fe901a636eb11a345b3426eb42cc5dc Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:47:31 +0100 Subject: [PATCH 12/28] test(runtime): let the DLL allocate after its copy's terminator Refs #227 When asked, the DLL allocates and frees through its copy of the runtime from a terminator of its own, which its C runtime runs after the copy's: as code of the DLL that runs then, a terminator of a library it links for instance, would. --- test/unit/reload/ct_allocating_library.cpp | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/unit/reload/ct_allocating_library.cpp b/test/unit/reload/ct_allocating_library.cpp index 5e4d704..ecba9ab 100644 --- a/test/unit/reload/ct_allocating_library.cpp +++ b/test/unit/reload/ct_allocating_library.cpp @@ -32,3 +32,32 @@ extern "C" __declspec(dllexport) void* ct_allocating_library_keep(size_t size) { return __ct_malloc(size, "allocating:kept"); } + +namespace +{ + int churn_at_unload_count = 0; + size_t churn_at_unload_size = 0; + + void churnAtUnload() + { + if (churn_at_unload_count > 0) + { + ct_allocating_library_churn(churn_at_unload_count, churn_at_unload_size); + } + } +} // namespace + +// Has the DLL allocate `count` blocks of `size` bytes through the copy of the runtime and free them +// when it unloads, after the terminator of the copy: as code that runs after it would. +extern "C" __declspec(dllexport) void ct_allocating_library_churn_at_unload(int count, size_t size) +{ + churn_at_unload_count = count; + churn_at_unload_size = size; +} + +// .CRT$XTYZ comes after .CRT$XTY, where the copy places its terminator, and before .CRT$XTZ, which +// ends the terminators. +#pragma comment(linker, "/INCLUDE:ct_allocating_library_late_terminator") +#pragma section(".CRT$XTYZ", read) +extern "C" __declspec(allocate(".CRT$XTYZ")) +__attribute__((used)) void (*const ct_allocating_library_late_terminator)() = churnAtUnload; From 7dcd1ed0a64c3f58420b2a67ec749f0850915c93 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:48:01 +0100 Subject: [PATCH 13/28] fix(runtime): free a DLL copy's allocation state as its own work Refs #227 The copy frees its table and releases its quarantine with ct_alloc_lock held. The blocks those release may reach the runtime's entry points, through the copies of the library's inline functions that an instrumented DLL compiles (#197). Outside a runtime scope, the entry point took ct_alloc_lock again, and FreeLibrary waited forever with the loader lock held. The release now runs in a scope, as the other changes of the table do. --- src/runtime/windows/ct_runtime_alloc.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/runtime/windows/ct_runtime_alloc.cpp b/src/runtime/windows/ct_runtime_alloc.cpp index 3f9cb1a..e8bf674 100644 --- a/src/runtime/windows/ct_runtime_alloc.cpp +++ b/src/runtime/windows/ct_runtime_alloc.cpp @@ -1373,6 +1373,9 @@ namespace // allocated, without their records (#227). CT_NOINSTR void ct_release_alloc_state() { + // The table and the quarantine release blocks with ct_alloc_lock held, which may reach the + // runtime's entry points (#197). + CtRuntimeScope scope; if (!ct_alloc_table_built.load(std::memory_order_acquire)) { return; From 70c0e44bc3cbb50c92f3f533000ed9a39f29ac82 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:48:58 +0100 Subject: [PATCH 14/28] test(runtime): check a DLL whose operator delete frees through its copy Refs #227 The DLL allocates and frees through its copy of the runtime, then FreeLibrary unloads it while the copy's table and quarantine release their blocks through the DLL's operator delete. The child process exits with 0 once FreeLibrary has returned, and with 7 when it has not within 20 seconds. --- test/unit/runtime_vtable_replace_test.cpp | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 7723a95..406b938 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -674,6 +674,50 @@ namespace << sizesOf(kept) << "\nwithout the runtime: " << sizesOf(control); } + // Loads `library`, has it allocate 64 blocks of 64 bytes through its copy of the runtime and + // free them, and `late` blocks of 16 bytes after its copy's terminator, then unloads it. Exits + // with 0 once FreeLibrary has returned, 2 when the DLL or its functions are missing, and 7 when + // FreeLibrary has not returned within 20 seconds, as when it waits on a lock that its own + // thread holds. + [[noreturn]] void churnAndUnload(const char* library, int late) + { + std::thread( + [] + { + Sleep(20000); + TerminateProcess(GetCurrentProcess(), 7); + }) + .detach(); + using Churn = void (*)(int, size_t); + HMODULE module = LoadLibraryA(library); + if (!module) + { + std::exit(2); + } + auto churn = reinterpret_cast(GetProcAddress(module, "ct_allocating_library_churn")); + auto churn_at_unload = reinterpret_cast( + GetProcAddress(module, "ct_allocating_library_churn_at_unload")); + if (!churn || !churn_at_unload) + { + std::exit(2); + } + churn(64, 64); + churn_at_unload(late, 16); + FreeLibrary(module); + std::exit(0); + } + + // The copy of an instrumented DLL may see the blocks it releases for itself come back to its + // entry points, through the copies of the library's inline functions that the DLL compiled + // (#197), as through this DLL's operator delete: it frees its table and releases its quarantine + // at FreeLibrary as its own work, without waiting on its own lock (#227). + TEST(DllAllocStateDeathTest, ADllWhoseOperatorDeleteFreesThroughItsCopyUnloads) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + EXPECT_EXIT(churnAndUnload(CT_DELETE_THROUGH_RUNTIME_LIBRARY, 0), + testing::ExitedWithCode(0), ""); + } + // The line `label` of the first box of `site`, empty without one. std::string lineOf(const std::string& boxes, const char* site, const char* label) { From 12cc78909d68d181630f032767c85c24deb0cc7f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:49:35 +0100 Subject: [PATCH 15/28] fix(runtime): let code after a DLL copy's terminator find a table Refs #227 The copy of a DLL destroys its allocation table at FreeLibrary, from its terminator, but ct_alloc_table() kept returning it: code of the DLL that ran after the terminator and allocated or released through the copy used the destroyed table. Every use of the table holds ct_alloc_lock, so the table is now built under it whenever it is not built; such code builds a new one, which stays allocated. --- src/runtime/windows/ct_runtime_alloc.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/runtime/windows/ct_runtime_alloc.cpp b/src/runtime/windows/ct_runtime_alloc.cpp index e8bf674..cc8b488 100644 --- a/src/runtime/windows/ct_runtime_alloc.cpp +++ b/src/runtime/windows/ct_runtime_alloc.cpp @@ -58,25 +58,26 @@ namespace // CRT runs after the destructors of every static object, may still release blocks, and // the leak report runs among them (#159). The lock needs no constructor nor destructor; // the table is built on first use, in storage that only the terminator of a DLL's copy - // destroys, when FreeLibrary unloads the DLL (#227). + // destroys, when FreeLibrary unloads the DLL, and built again for the code of the DLL that + // uses it after that terminator (#227). SRWLOCK ct_alloc_lock = SRWLOCK_INIT; using CtAllocTable = std::unordered_map; - // Set once the table is built. A copy of the runtime that never built it has nothing to + // Set while the table is built. A copy of the runtime that never built it has nothing to // report, and building it at process exit would allocate with the loader lock held (#232). std::atomic ct_alloc_table_built{false}; + // ct_alloc_lock held, as for every use of the table. CT_NOINSTR CtAllocTable& ct_alloc_table() { alignas(CtAllocTable) static unsigned char storage[sizeof(CtAllocTable)]; - static CtAllocTable* const table = [] + if (!ct_alloc_table_built.load(std::memory_order_relaxed)) { - CtAllocTable* built = new (storage) CtAllocTable(); + new (storage) CtAllocTable(); ct_alloc_table_built.store(true, std::memory_order_release); - return built; - }(); - return *table; + } + return *std::launder(reinterpret_cast(storage)); } constexpr size_t kIndexInitialCapacity = size_t{1} << 12; From 707ab8c1eee2aa3aa7a40b276b6a393d2344b755 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 06:49:48 +0100 Subject: [PATCH 16/28] test(runtime): check a DLL that allocates after its copy freed its state Refs #227 The DLL allocates and frees four blocks through its copy of the runtime from a terminator that its C runtime runs after the copy's, which has destroyed the table by then. The child process exits with 0 once FreeLibrary has returned. --- test/unit/runtime_vtable_replace_test.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 406b938..44e608a 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -718,6 +718,15 @@ namespace testing::ExitedWithCode(0), ""); } + // Code of the DLL that runs after its copy's terminator, a terminator of a library that the DLL + // links for instance, and allocates and frees through the copy, finds a table: the copy builds + // a new one, which stays allocated (#227). + TEST(DllAllocStateDeathTest, ADllThatAllocatesAfterItsCopyFreedItsStateUnloads) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + EXPECT_EXIT(churnAndUnload(CT_ALLOCATING_LIBRARY, 4), testing::ExitedWithCode(0), ""); + } + // The line `label` of the first box of `site`, empty without one. std::string lineOf(const std::string& boxes, const char* site, const char* label) { From 68fa6f70ffb52fcea5b185ffac6b80c9935ff63c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:20:28 +0100 Subject: [PATCH 17/28] test(runtime): let the DLL's operator new allocate through its copy The DLL whose operator delete frees through its copy of the runtime now allocates through it too, as an instrumented DLL may when the linker keeps its copies of the library's inline functions for the runtime (#197). The blocks that the runtime allocates for itself, such as those of a table it builds, then reach its entry points. Refs #227 --- .../unit/reload/ct_delete_through_runtime.cpp | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/unit/reload/ct_delete_through_runtime.cpp b/test/unit/reload/ct_delete_through_runtime.cpp index b580c4c..1cdf68f 100644 --- a/test/unit/reload/ct_delete_through_runtime.cpp +++ b/test/unit/reload/ct_delete_through_runtime.cpp @@ -1,13 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 // -// Linked into a DLL with ct_allocating_library.cpp: the DLL's operator delete frees through its -// copy of the runtime, as the copies of the library's inline functions that an instrumented DLL -// compiles may, which the linker can keep for the runtime too (#197). The blocks that the runtime -// releases for itself, the nodes of its allocation table among them, then reach its entry points. +// Linked into a DLL with ct_allocating_library.cpp: the DLL's operators new and delete allocate +// and free through its copy of the runtime, as the copies of the library's inline functions that +// an instrumented DLL compiles may, which the linker can keep for the runtime too (#197). The +// blocks that the runtime allocates and releases for itself, the nodes of its allocation table +// among them, then reach its entry points. #include +#include +extern "C" void* __ct_malloc(size_t size, const char* site); extern "C" void __ct_free(void* ptr, const char* site); +void* operator new(std::size_t size) +{ + if (void* ptr = __ct_malloc(size, "new-through-runtime")) + { + return ptr; + } + throw std::bad_alloc(); +} + void operator delete(void* ptr) noexcept { if (ptr) From e60c53c220d3bb5cc7dc397a0b4deadbb26a5201 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:20:51 +0100 Subject: [PATCH 18/28] test(runtime): name the DLL after both operators it replaces The fixture replaces operator new as well as operator delete: its file, its target and the macro that names the DLL say so. Refs #227 --- test/unit/CMakeLists.txt | 14 +++++++------- ...ntime.cpp => ct_new_delete_through_runtime.cpp} | 0 test/unit/runtime_vtable_replace_test.cpp | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) rename test/unit/reload/{ct_delete_through_runtime.cpp => ct_new_delete_through_runtime.cpp} (100%) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index 3d4ab72..aaf079b 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -170,20 +170,20 @@ if(TARGET ct_instrument_runtime) CT_RUNTIME_LIBRARY="$" CT_DLLMAIN_LIBRARY="$") # A DLL that allocates through its copy of the runtime, which records the blocks and keeps - # those it frees in its quarantine (#227); and the same DLL, whose operator delete frees - # through its copy, as the copies of the library's inline functions that an instrumented DLL - # compiles may (#197). + # those it frees in its quarantine (#227); and the same DLL, whose operators new and delete + # go through its copy, as the copies of the library's inline functions that an instrumented + # DLL compiles may (#197). add_library(ct_allocating_library MODULE reload/ct_allocating_library.cpp) - add_library(ct_delete_through_runtime_library MODULE reload/ct_allocating_library.cpp - reload/ct_delete_through_runtime.cpp) - foreach(library ct_allocating_library ct_delete_through_runtime_library) + add_library(ct_new_delete_through_runtime_library MODULE reload/ct_allocating_library.cpp + reload/ct_new_delete_through_runtime.cpp) + foreach(library ct_allocating_library ct_new_delete_through_runtime_library) target_link_libraries(${library} PRIVATE ct_instrument_runtime coretrace_logger) coretrace_force_msvc_runtime(${library}) add_dependencies(runtime_unit_tests ${library}) endforeach() target_compile_definitions(runtime_unit_tests PRIVATE CT_ALLOCATING_LIBRARY="$" - CT_DELETE_THROUGH_RUNTIME_LIBRARY="$") + CT_NEW_DELETE_THROUGH_RUNTIME_LIBRARY="$") # A DLL's copy of the runtime reports its leaks when the process exits, after the DLL's # exit-time code, and says nothing when FreeLibrary unloads the DLL (#232). The program links # no runtime, so the report it prints is the DLL's copy's. diff --git a/test/unit/reload/ct_delete_through_runtime.cpp b/test/unit/reload/ct_new_delete_through_runtime.cpp similarity index 100% rename from test/unit/reload/ct_delete_through_runtime.cpp rename to test/unit/reload/ct_new_delete_through_runtime.cpp diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 44e608a..66e99c3 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -714,7 +714,7 @@ namespace TEST(DllAllocStateDeathTest, ADllWhoseOperatorDeleteFreesThroughItsCopyUnloads) { GTEST_FLAG_SET(death_test_style, "threadsafe"); - EXPECT_EXIT(churnAndUnload(CT_DELETE_THROUGH_RUNTIME_LIBRARY, 0), + EXPECT_EXIT(churnAndUnload(CT_NEW_DELETE_THROUGH_RUNTIME_LIBRARY, 0), testing::ExitedWithCode(0), ""); } From d57754957d886d87a2c2fca0399bfc52dedfaeb5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:21:09 +0100 Subject: [PATCH 19/28] test(runtime): let the DLL check an access after its copy's terminator When asked, the late terminator of the DLL that allocates through its copy of the runtime checks the bounds of an access through the copy, before the blocks it allocates then. Once the copy's terminator has freed its allocation table, that check is the first use of the table, which takes the table's lock outside a runtime scope. Refs #227 --- test/unit/reload/ct_allocating_library.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/test/unit/reload/ct_allocating_library.cpp b/test/unit/reload/ct_allocating_library.cpp index ecba9ab..204aaa7 100644 --- a/test/unit/reload/ct_allocating_library.cpp +++ b/test/unit/reload/ct_allocating_library.cpp @@ -9,6 +9,8 @@ extern "C" void* __ct_malloc(size_t size, const char* site); extern "C" void __ct_free(void* ptr, const char* site); +extern "C" void __ct_check_bounds(const void* base, const void* ptr, size_t access_size, + const char* site, int is_write); extern "C" int __ct_config_disable_alloc_trace = 1; @@ -37,9 +39,15 @@ namespace { int churn_at_unload_count = 0; size_t churn_at_unload_size = 0; + bool check_at_unload = false; - void churnAtUnload() + void atUnload() { + if (check_at_unload) + { + __ct_check_bounds(&check_at_unload, &check_at_unload, sizeof(check_at_unload), + "allocating:check", 0); + } if (churn_at_unload_count > 0) { ct_allocating_library_churn(churn_at_unload_count, churn_at_unload_size); @@ -55,9 +63,17 @@ extern "C" __declspec(dllexport) void ct_allocating_library_churn_at_unload(int churn_at_unload_size = size; } +// Has the DLL check an access through the copy of the runtime when it unloads, after the terminator +// of the copy and before the blocks it allocates then: the first use of the copy's allocation table +// once the terminator has freed it. +extern "C" __declspec(dllexport) void ct_allocating_library_check_at_unload() +{ + check_at_unload = true; +} + // .CRT$XTYZ comes after .CRT$XTY, where the copy places its terminator, and before .CRT$XTZ, which // ends the terminators. #pragma comment(linker, "/INCLUDE:ct_allocating_library_late_terminator") #pragma section(".CRT$XTYZ", read) extern "C" __declspec(allocate(".CRT$XTYZ")) -__attribute__((used)) void (*const ct_allocating_library_late_terminator)() = churnAtUnload; +__attribute__((used)) void (*const ct_allocating_library_late_terminator)() = atUnload; From dc7e6dbc06ccd222595650b3288a02288b5f5013 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:21:25 +0100 Subject: [PATCH 20/28] fix(runtime): build a DLL copy's table as the runtime's own work A bounds check takes the allocation table's lock outside a runtime scope, as do the lookups of realloc and munmap. Once the terminator of a DLL's copy has freed the table, the first of them to come builds a new one, with the lock held. Building it allocates: when the allocations reach the runtime's entry points, as in an instrumented DLL (#197), the entry point waits for the lock that its own thread holds, the loader lock held too. The table is now built in a runtime scope, whichever use builds it. Refs #227 --- src/runtime/windows/ct_runtime_alloc.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/runtime/windows/ct_runtime_alloc.cpp b/src/runtime/windows/ct_runtime_alloc.cpp index cc8b488..61d4b52 100644 --- a/src/runtime/windows/ct_runtime_alloc.cpp +++ b/src/runtime/windows/ct_runtime_alloc.cpp @@ -68,12 +68,15 @@ namespace // report, and building it at process exit would allocate with the loader lock held (#232). std::atomic ct_alloc_table_built{false}; - // ct_alloc_lock held, as for every use of the table. + // ct_alloc_lock held, as for every use of the table. Building the table allocates, which may + // reach the runtime's entry points (#197): it is the runtime's own work, whichever use builds + // it, a bounds check for one, which holds the lock outside a runtime scope. CT_NOINSTR CtAllocTable& ct_alloc_table() { alignas(CtAllocTable) static unsigned char storage[sizeof(CtAllocTable)]; if (!ct_alloc_table_built.load(std::memory_order_relaxed)) { + CtRuntimeScope scope; new (storage) CtAllocTable(); ct_alloc_table_built.store(true, std::memory_order_release); } From 8c9c0abeb628123bb03777cfb932ba11aa0842d5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:21:59 +0100 Subject: [PATCH 21/28] test(runtime): let the unload helper ask for a late access check The helper that loads a DLL that allocates through its copy of the runtime, and unloads it in a child process, can now have the DLL check an access after its copy's terminator. The tests that use it ask for no check. Refs #227 --- test/unit/runtime_vtable_replace_test.cpp | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 66e99c3..f3ce8f1 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -675,11 +675,11 @@ namespace } // Loads `library`, has it allocate 64 blocks of 64 bytes through its copy of the runtime and - // free them, and `late` blocks of 16 bytes after its copy's terminator, then unloads it. Exits - // with 0 once FreeLibrary has returned, 2 when the DLL or its functions are missing, and 7 when - // FreeLibrary has not returned within 20 seconds, as when it waits on a lock that its own - // thread holds. - [[noreturn]] void churnAndUnload(const char* library, int late) + // free them, then, after its copy's terminator, check an access through the copy when `check` + // and allocate and free `late` blocks of 16 bytes, and unloads it. Exits with 0 once FreeLibrary + // has returned, 2 when the DLL or its functions are missing, and 7 when FreeLibrary has not + // returned within 20 seconds, as when it waits on a lock that its own thread holds. + [[noreturn]] void churnAndUnload(const char* library, int late, bool check) { std::thread( [] @@ -697,12 +697,18 @@ namespace auto churn = reinterpret_cast(GetProcAddress(module, "ct_allocating_library_churn")); auto churn_at_unload = reinterpret_cast( GetProcAddress(module, "ct_allocating_library_churn_at_unload")); - if (!churn || !churn_at_unload) + auto check_at_unload = reinterpret_cast( + GetProcAddress(module, "ct_allocating_library_check_at_unload")); + if (!churn || !churn_at_unload || !check_at_unload) { std::exit(2); } churn(64, 64); churn_at_unload(late, 16); + if (check) + { + check_at_unload(); + } FreeLibrary(module); std::exit(0); } @@ -714,7 +720,7 @@ namespace TEST(DllAllocStateDeathTest, ADllWhoseOperatorDeleteFreesThroughItsCopyUnloads) { GTEST_FLAG_SET(death_test_style, "threadsafe"); - EXPECT_EXIT(churnAndUnload(CT_NEW_DELETE_THROUGH_RUNTIME_LIBRARY, 0), + EXPECT_EXIT(churnAndUnload(CT_NEW_DELETE_THROUGH_RUNTIME_LIBRARY, 0, false), testing::ExitedWithCode(0), ""); } @@ -724,7 +730,8 @@ namespace TEST(DllAllocStateDeathTest, ADllThatAllocatesAfterItsCopyFreedItsStateUnloads) { GTEST_FLAG_SET(death_test_style, "threadsafe"); - EXPECT_EXIT(churnAndUnload(CT_ALLOCATING_LIBRARY, 4), testing::ExitedWithCode(0), ""); + EXPECT_EXIT(churnAndUnload(CT_ALLOCATING_LIBRARY, 4, false), testing::ExitedWithCode(0), + ""); } // The line `label` of the first box of `site`, empty without one. From 85837e2a3e81aabedbaa7adbf4ff8edd8a336ddc Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:22:07 +0100 Subject: [PATCH 22/28] test(runtime): check a bounds check after a DLL copy's terminator A bounds check that the DLL makes after its copy's terminator is the first use of the copy's allocation table since the terminator freed it, and builds a new table with the table's lock held. The DLL's operator new allocates through the copy, as an instrumented DLL may (#197): the blocks of the new table reach the copy's entry points. The child process exits with 0 once FreeLibrary has returned, and with 7 when it has not within 20 seconds. Refs #227 --- test/unit/runtime_vtable_replace_test.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index f3ce8f1..c38f377 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -734,6 +734,17 @@ namespace ""); } + // A bounds check that comes after the copy's terminator is the first use of the copy's table + // since the terminator freed it, and builds a new one with the table's lock held. The blocks of + // the new table come back to the copy's entry points through the DLL's operator new: the copy + // builds the table as its own work, so that they do not wait on that lock (#197, #227). + TEST(DllAllocStateDeathTest, ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloads) + { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + EXPECT_EXIT(churnAndUnload(CT_NEW_DELETE_THROUGH_RUNTIME_LIBRARY, 0, true), + testing::ExitedWithCode(0), ""); + } + // The line `label` of the first box of `site`, empty without one. std::string lineOf(const std::string& boxes, const char* site, const char* label) { From fcb21717e65fdcb06ab07ada9b2e4465b7e6ed9c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:22:56 +0100 Subject: [PATCH 23/28] docs(runtime): say what code after a DLL copy's terminator keeps The comment of the release said that only the C runtime's teardown runs after the terminator of a DLL's copy. The terminators of later sections run after it too, and code there that goes through the copy finds a new table. The comment now says what that code leaves allocated once the DLL is gone, from what it does: the new table, a record for each block it still holds, buckets for the most blocks it held at once and, past 4096 of them, what the block index grew into. A block it releases leaves no record, the quarantine's limit being 0. Refs #227 --- src/runtime/windows/ct_runtime_alloc.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/runtime/windows/ct_runtime_alloc.cpp b/src/runtime/windows/ct_runtime_alloc.cpp index 61d4b52..cb743fb 100644 --- a/src/runtime/windows/ct_runtime_alloc.cpp +++ b/src/runtime/windows/ct_runtime_alloc.cpp @@ -1371,10 +1371,17 @@ namespace // Frees what this copy of the runtime holds for the blocks of its DLL when FreeLibrary unloads // the DLL, from the terminator below: the blocks in its quarantine, which the program had // released, the quarantine's queue, what its block index grew into, and its allocation table. - // The DLL's code has all run by then: its atexit handlers, the destructors of its global - // objects, its destructor functions and its own terminators. Only the C runtime's teardown - // comes after, which calls no function of the runtime. The blocks that the DLL still holds stay - // allocated, without their records (#227). + // The DLL's atexit handlers, the destructors of its global objects, its destructor functions + // and the terminators of its code, in .CRT$XTU, have run by then. The terminators of later + // sections, which a library that the DLL links may add, run after it, then the C runtime's + // teardown, which calls no function of the runtime. Code there that goes through the copy + // finds a new table (ct_alloc_table), and what that code leaves stays allocated once the DLL + // is gone: the new table, a record for each block it allocated and still holds, buckets for + // the most blocks it held at once and, when it held more than the 4096 that the block index + // holds in the module's image, the storage the index grew into. A block that code releases + // leaves no record: the quarantine's limit stays 0, so the block goes to the allocator at once + // (ct_quarantine_release_all). The blocks that the DLL still holds stay allocated, without + // their records (#227). CT_NOINSTR void ct_release_alloc_state() { // The table and the quarantine release blocks with ct_alloc_lock held, which may reach the From ad71ceafdeef50847e347f2c3534d5922806218b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:23:10 +0100 Subject: [PATCH 24/28] docs(runtime): say that nothing raises the quarantine's limit again The release of a DLL copy's state relies on the quarantine's limit staying 0 once ct_quarantine_release_all has set it: CT_QUARANTINE_MB is read only while no limit is set, and no other function of the runtime sets one, only tests do. Every block released afterwards then goes to the allocator at once without its freed record, whatever the thread, and the queue is empty when it is freed. The comments of both functions now say so. Refs #227 --- src/runtime/ct_runtime_quarantine.h | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/runtime/ct_runtime_quarantine.h b/src/runtime/ct_runtime_quarantine.h index bee658e..7b69b85 100644 --- a/src/runtime/ct_runtime_quarantine.h +++ b/src/runtime/ct_runtime_quarantine.h @@ -32,12 +32,16 @@ struct ct_quarantine_item CT_NOINSTR void ct_quarantine_push(const ct_quarantine_item& item); // The byte limit, read from CT_QUARANTINE_MB until set. Lowering it releases the oldest -// blocks down to the new limit; 0 releases every block at once. ct_alloc_lock not held. +// blocks down to the new limit; 0 releases every block at once. Besides tests, only +// ct_quarantine_release_all sets it, to 0 for good. ct_alloc_lock not held. CT_NOINSTR void ct_quarantine_set_limit(size_t bytes); CT_NODISCARD CT_NOINSTR size_t ct_quarantine_bytes(void); // Releases every block, as a limit of 0 does, then frees the queue: for a copy of the runtime -// whose module unloads (#227). The blocks released afterwards go to the allocator at once. +// whose module unloads (#227). The limit stays 0: CT_QUARANTINE_MB is read only while no limit is +// set, and nothing in the runtime sets a limit but this function, only tests do. From then on, +// every block released, whatever the thread, goes to the allocator at once without its freed +// record: the queue is empty when this function frees it and is not allocated again. // ct_alloc_lock not held. CT_NOINSTR void ct_quarantine_release_all(void); From d19eabfb2ddd01a24cb238d9a4359f0dbba65839 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 21:31:17 +0100 Subject: [PATCH 25/28] test(runtime): start the unload watchdog before the DLL loads The child process of the DLL tests 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 FreeLibrary waits forever with that lock held before the watchdog has started, the watchdog waits too, and nothing ends the process. In a probe run where the copy waited for its own lock, three Windows jobs saw the child exit with 7 after 20 seconds, and the fourth waited until the job's 45-minute timeout cancelled it. The child now waits until the watchdog runs before it loads the DLL. Refs #227 --- test/unit/runtime_vtable_replace_test.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index c38f377..0827155 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -677,17 +677,27 @@ namespace // Loads `library`, has it allocate 64 blocks of 64 bytes through its copy of the runtime and // free them, then, after its copy's terminator, check an access through the copy when `check` // and allocate and free `late` blocks of 16 bytes, and unloads it. Exits with 0 once FreeLibrary - // has returned, 2 when the DLL or its functions are missing, and 7 when FreeLibrary has not - // returned within 20 seconds, as when it waits on a lock that its own thread holds. + // has returned, 2 when the DLL, its functions or the watchdog are missing, and 7 when + // FreeLibrary has not returned within 20 seconds, as when it waits on a lock that its own + // thread holds. [[noreturn]] void churnAndUnload(const char* library, int late, bool check) { + // The watchdog runs before the DLL loads: a thread takes the loader lock to start, and + // would wait for it behind a FreeLibrary that waits with that lock held. + HANDLE watching = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!watching) + { + std::exit(2); + } std::thread( - [] + [watching] { + SetEvent(watching); Sleep(20000); TerminateProcess(GetCurrentProcess(), 7); }) .detach(); + WaitForSingleObject(watching, INFINITE); using Churn = void (*)(int, size_t); HMODULE module = LoadLibraryA(library); if (!module) From c770881e76fd5e4d85f8ca4f5b3b8427a4f7b7af Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 22:04:08 +0100 Subject: [PATCH 26/28] test(runtime): let the DLL say when its late terminator returns When asked, the DLL that allocates through its copy of the runtime calls a function of the program at the end of its late terminator. The loader swallows an exception raised while it unloads a DLL, and FreeLibrary then returns as usual: that call is what tells that the copy's terminator and the late one ran to their end. Refs #227 --- test/unit/reload/ct_allocating_library.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/unit/reload/ct_allocating_library.cpp b/test/unit/reload/ct_allocating_library.cpp index 204aaa7..d61767b 100644 --- a/test/unit/reload/ct_allocating_library.cpp +++ b/test/unit/reload/ct_allocating_library.cpp @@ -40,6 +40,7 @@ namespace int churn_at_unload_count = 0; size_t churn_at_unload_size = 0; bool check_at_unload = false; + void (*unload_returned)() = nullptr; void atUnload() { @@ -52,6 +53,10 @@ namespace { ct_allocating_library_churn(churn_at_unload_count, churn_at_unload_size); } + if (unload_returned) + { + unload_returned(); + } } } // namespace @@ -71,6 +76,14 @@ extern "C" __declspec(dllexport) void ct_allocating_library_check_at_unload() check_at_unload = true; } +// Has the DLL call `returned`, a function of the program, at the end of its late terminator. The +// loader swallows an exception raised while it unloads a DLL: without the call, FreeLibrary returns +// as if that terminator, or the copy's before it, had not faulted. +extern "C" __declspec(dllexport) void ct_allocating_library_report_unload(void (*returned)()) +{ + unload_returned = returned; +} + // .CRT$XTYZ comes after .CRT$XTY, where the copy places its terminator, and before .CRT$XTZ, which // ends the terminators. #pragma comment(linker, "/INCLUDE:ct_allocating_library_late_terminator") From 7541d976902f3da2705ae8b2ca2160a0343a7b65 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 22:04:35 +0100 Subject: [PATCH 27/28] test(runtime): fail a DLL test whose late terminator does not return The loader swallows an exception raised while it unloads a DLL, and FreeLibrary returns as usual. On 7c09bf0, whose code after the copy's terminator used the table that the terminator had destroyed, the late allocation test passed even with that table's memory filled with 0xA5, which no use of it gets through: it took 0.3 seconds instead of 0.01. The child process now has the DLL report the end of its late terminator, and exits with 6 when FreeLibrary returned without it. Refs #227 --- 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 0827155..05deba3 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -674,12 +674,16 @@ namespace << sizesOf(kept) << "\nwithout the runtime: " << sizesOf(control); } + // Set by the DLL at the end of its late terminator. + std::atomic late_terminator_returned{false}; + // Loads `library`, has it allocate 64 blocks of 64 bytes through its copy of the runtime and // free them, then, after its copy's terminator, check an access through the copy when `check` // and allocate and free `late` blocks of 16 bytes, and unloads it. Exits with 0 once FreeLibrary - // has returned, 2 when the DLL, its functions or the watchdog are missing, and 7 when - // FreeLibrary has not returned within 20 seconds, as when it waits on a lock that its own - // thread holds. + // and the DLL's late terminator have returned, 2 when the DLL, its functions or the watchdog are + // missing, 6 when FreeLibrary returned but the late terminator did not, as when the loader + // swallowed a fault there or in the copy's terminator, and 7 when FreeLibrary has not returned + // within 20 seconds, as when it waits on a lock that its own thread holds. [[noreturn]] void churnAndUnload(const char* library, int late, bool check) { // The watchdog runs before the DLL loads: a thread takes the loader lock to start, and @@ -709,7 +713,9 @@ namespace GetProcAddress(module, "ct_allocating_library_churn_at_unload")); auto check_at_unload = reinterpret_cast( GetProcAddress(module, "ct_allocating_library_check_at_unload")); - if (!churn || !churn_at_unload || !check_at_unload) + auto report_unload = reinterpret_cast( + GetProcAddress(module, "ct_allocating_library_report_unload")); + if (!churn || !churn_at_unload || !check_at_unload || !report_unload) { std::exit(2); } @@ -719,8 +725,9 @@ namespace { check_at_unload(); } + report_unload([] { late_terminator_returned.store(true); }); FreeLibrary(module); - std::exit(0); + std::exit(late_terminator_returned.load() ? 0 : 6); } // The copy of an instrumented DLL may see the blocks it releases for itself come back to its From ce70bac71b41991fe534dc25738e9fa996c8d186 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Nov 2026 05:46:44 +0100 Subject: [PATCH 28/28] test(runtime): start the DLL churn test's watchdog with startWatchdog churnAndUnload() started its watchdog inline, with the same event that confirms the thread runs before the DLL loads. startWatchdog(), which came with main, does exactly that, and the crash and unload helpers already call it: churnAndUnload() calls it too. Refs #227 --- test/unit/runtime_vtable_replace_test.cpp | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/test/unit/runtime_vtable_replace_test.cpp b/test/unit/runtime_vtable_replace_test.cpp index 7177841..ca8da3c 100644 --- a/test/unit/runtime_vtable_replace_test.cpp +++ b/test/unit/runtime_vtable_replace_test.cpp @@ -714,22 +714,7 @@ namespace // within 20 seconds, as when it waits on a lock that its own thread holds. [[noreturn]] void churnAndUnload(const char* library, int late, bool check) { - // The watchdog runs before the DLL loads: a thread takes the loader lock to start, and - // would wait for it behind a FreeLibrary that waits with that lock held. - HANDLE watching = CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (!watching) - { - std::exit(2); - } - std::thread( - [watching] - { - SetEvent(watching); - Sleep(20000); - TerminateProcess(GetCurrentProcess(), 7); - }) - .detach(); - WaitForSingleObject(watching, INFINITE); + startWatchdog(); using Churn = void (*)(int, size_t); HMODULE module = LoadLibraryA(library); if (!module)