Skip to content

fix(runtime): free a DLL copy's allocation state at FreeLibrary - #242

Draft
SizzleUnrlsd wants to merge 29 commits into
mainfrom
fix/dll-copy-alloc-state
Draft

SizzleUnrlsd wants to merge 29 commits into
mainfrom
fix/dll-copy-alloc-state

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Refs #227

Summary

A DLL that links the runtime has a copy of the runtime of its own, with its own allocation table, block index and quarantine. None of them was ever freed: the table is built in static storage that nothing destroys, so that the exit-time code of the program and the leak report can still use it (#159, #232). When FreeLibrary unloaded a DLL that allocated and freed through its copy, the table's nodes and buckets, the storage its block index grew into, the quarantine's queue and the blocks the quarantine held all stayed allocated, and the next load built new ones. #240 freed the copy's cache of names; this PR frees the rest that the probe below found.

This PR makes the DLL's copy free that state when FreeLibrary unloads the DLL, in windows/ct_runtime_alloc.cpp:

  • Where. In the copy's .CRT$XTY terminator, which the DLL's C runtime runs after the DLL's atexit handlers, the destructors of its global objects, its destructor functions and the terminators of its own code, in .CRT$XTU. The terminators of later sections, which a library linked into the DLL may add, run after it, then the C runtime's teardown, which calls no function of the runtime: see Afterwards.
  • When. Only when the loader detaches the DLL (ct_dll_detaching, set by the TLS callback, which runs before the C runtime's detach) and the unload is dynamic (ct_dll_unloading_dynamically()). When the process exits, the copy keeps everything for its leak report; when the DLL ends the process with its own exit(), the terminator runs before the detach and keeps everything too, for the report that the TLS callback makes then (Windows: a DLL's copy of the runtime may never report its leaks at exit #232, fix(runtime): report the leaks of a DLL's copy when the process exits #234).
  • What.
    • The blocks in the quarantine, which the program had released, are released for real, and the quarantine's queue is freed: ct_quarantine_release_all(), new in ct_runtime_quarantine.cpp, shared with the POSIX runtime.
    • The block index is emptied back onto the storage it starts with, in the module's image, freeing what it grew into. Its statics move to the file's anonymous namespace for that.
    • The allocation table is destroyed.
  • What stays. The blocks that the DLL still holds stay allocated: the copy frees its records of them, not the blocks, which the program may still use.
  • As the runtime's own work. The release runs in a runtime scope (CtRuntimeScope), as every other change of the table does. The table and the quarantine release blocks with ct_alloc_lock held, and those releases may reach the runtime's entry points through the copies of the library's inline functions that an instrumented DLL compiles (Windows runtime: its own allocations re-enter it through instrumented inline STL code #197). Outside a scope, the entry point would take the lock again and leave FreeLibrary waiting forever with the loader lock held.
  • Afterwards. ct_alloc_table() builds the table under ct_alloc_lock whenever it is not built, instead of once through a function-local static: every use of the table holds that lock. Code of the DLL that runs after the copy's terminator and goes through the copy finds a new table instead of the destroyed one. Building the table allocates with the lock held, and some uses that may build it hold the lock outside a runtime scope: a bounds check (__ct_check_bounds), and the lookups of __ct_realloc and __ct_munmap. The build therefore runs in a runtime scope of its own, whichever use comes first.

Measurement

A probe measured the busy blocks of every heap of the process across loads and unloads of a DLL that allocates and frees through its copy (probe/227-alloc-state, 76bafcf, run 41274637940), with the method of #227's first probe. The four Windows jobs gave the same results.

Variant Cycles Kept
A DLL without the runtime 20 nothing
A DLL that links the runtime and allocates nothing 20 nothing that accumulates
64 blocks of 64 bytes allocated and freed per load 20 about 42 KB per cycle
5000 blocks of 16 bytes allocated and freed per load 4 957 to 960 KB per cycle

The blocks kept per cycle match, by their sizes, the state of one copy per load. The probe shows no allocation stacks, so this attribution rests on exact sizes:

  • 24,576 bytes: the quarantine's queue, 1,024 entries of 24 bytes; 196,608 bytes once it grew to 8,192 entries.
  • 229,376 bytes: the block index grown to 8,192 entries, ct_block_index_storage_size(8192).
  • Blocks of 56 bytes, one per freed block plus two: the table's nodes. Blocks of 8,231 and 131,111 bytes: its buckets.
  • Blocks of 64 and 16 bytes, and of 512 and 40,000 bytes: the blocks that the DLL freed and the quarantine held, and the arrays that held their pointers.

Blocks of about 12 KB appeared and disappeared without accumulating, as in #227's first probe.

Evidence

Each probe runs the tests of this branch in the four Windows jobs of LLVM versions, LLVM 19, 20, 22 and 23, against other sources. All but the mutant ran the tests of 7541d97.

Run Branch, head Sources Windows jobs
41350156893 probe/227-alloc-state-red, a04e828 main 233/235 in each job: the two accumulation tests fail, with 2,661 blocks (834,601 bytes) kept over 20 loads and, for the index test, 200,121 blocks (19,156,521 bytes), against fewer than 10. The three death tests pass. The quarantine test, whose function main lacks, is left out.
41350145170 probe/227-review-red, 8667488 7c09bf0, the first fix 233/236 in the jobs of LLVM 19, 20 and 22, 234/236 in LLVM 23's: the two defects below.
41350148036 probe/227-poison-red, a203fdb 7c09bf0, the destroyed table's memory filled with 0xA5 233/236 in each job: the same three death tests fail.
41350154003 probe/227-check-bounds-red, 36e0982 707ab8c, the table built again outside a runtime scope 235/236 in each job: only ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloads fails, with exit status 7 after 20 seconds.
41341735151 probe/227-index-mutant, d5f9b08 this branch without the release of the block index, with the tests of ad71cea 235/236 in each job: only the index test fails, with 21 blocks kept over 20 loads, 20 of them of 229,376 bytes, one per load: what the index grew into.
41350151253 probe/227-poison-green, c5dac0a this branch, the destroyed table's memory filled with 0xA5 236/236 in each job.
41350135507 this branch, 7541d97 this branch 236/236 in each job; the 15 jobs of LLVM versions pass, and so do Build, clang-format, license-compliance and the commit conventions.
41775275514 this branch, ce70bac this branch with main merged in at 09df200, which brought #241: the CHANGELOG conflict resolved, both entries kept, and churnAndUnload starting its watchdog with startWatchdog() 245/245 in each job; the 15 jobs of LLVM versions pass, and so do Build, clang-format, license-compliance and the commit conventions.

The two defects of the first fix, 7c09bf0. A review of 7c09bf0 found two defects, which its own tests did not exercise. Its probe reproduces both:

  • The release waited for its own lock. ct_release_alloc_state() freed the table and released the quarantine with ct_alloc_lock held, outside a runtime scope. The blocks it released came back to the copy's entry points through the DLL's operator delete, which took the lock again. In each job, ADllWhoseOperatorDeleteFreesThroughItsCopyUnloads exits with 7 after 20 seconds, and so does ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloads, whose DLL waits in the same release before its late terminator runs.
  • Code after the terminator used the destroyed table. The function-local static still returned the table that the terminator had destroyed. A fault there does not show in FreeLibrary: the loader swallows an exception raised while it unloads a DLL, and FreeLibrary returns as usual. So the death tests have the DLL report the end of its late terminator, and fail with 6 without it. ADllThatAllocatesAfterItsCopyFreedItsStateUnloads exits with 6 in the jobs of LLVM 19, 20 and 22, and passes in LLVM 23's: using a destroyed object is undefined, and it did not fault there. With the destroyed table's memory filled with 0xA5, as a debug heap fills freed memory, the test exits with 6 in each job on 7c09bf0, and passes on this branch, whose next use of the table builds a new one in that memory first.

The defect of the rebuild, 707ab8c. The fix of the second defect, 707ab8c, built the new table outside a runtime scope when a bounds check was its first use. The bounds check takes the lock outside a runtime scope, and the new table's blocks come back to the copy's entry points through the DLL's operator new: ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloads exits with 7 in each job.

The index mutant. It is no defect of any head: 7c09bf0 already freed what the block index grew into. The index test now runs over 20 loads, as the other accumulation tests do, instead of 4, so that one block kept at each load stands out from the few blocks that a DLL without the runtime leaves. The mutant removes the index's release from this branch, every fix kept: only the index test fails, on the 20 blocks of 229,376 bytes that it keeps, one per load.

The watchdog. The child process of each death test ends itself with 7 after 20 seconds, from a watchdog thread. A thread takes the loader lock to start: the child waits until the watchdog runs before it loads the DLL. In a first run of the check-bounds probe, whose child did not wait (run 41341732725), the job of LLVM 23 waited behind the stuck FreeLibrary until its 45-minute timeout cancelled it, while the three others exited with 7.

ct_quarantine_release_all() is shared with the POSIX runtime, and its test ran RED then GREEN on macOS: with an empty body, QuarantineTest.ReleaseAllEmptiesTheQuarantine fails (280 bytes left in the quarantine, a freed record kept); with the fix, the 76 runtime unit tests pass, five times in shuffled order.

Tests

Test What it checks On main
DllAllocState.ABlockADllKeepsStaysAllocatedOnceItUnloads A block that the DLL allocated through its copy and still holds is in the process's heaps once FreeLibrary has unloaded the DLL. Passes
DllAllocState.ADllThatAllocatesThroughItsCopyKeepsNoHeapBlockAtEachLoad Over 20 loads that each allocate and free 64 blocks of 64 bytes, the DLL leaves fewer than 10 blocks beyond a DLL without the runtime. Fails
DllAllocState.ADllWhoseBlockIndexGrewKeepsNoHeapBlockAtEachLoad Over 20 loads that each allocate and free 5000 blocks, more than the 4096 the block index starts with, the DLL leaves fewer than 10 blocks beyond a DLL without the runtime. Fails
DllAllocStateDeathTest.ADllWhoseOperatorDeleteFreesThroughItsCopyUnloads FreeLibrary and the DLL's late terminator return within 20 seconds when the DLL's operators new and delete go through its copy while the copy frees its table and releases its quarantine. Passes: nothing is released
DllAllocStateDeathTest.ADllThatAllocatesAfterItsCopyFreedItsStateUnloads FreeLibrary and the late terminator return when the DLL allocates and frees four blocks through its copy from that terminator, which runs after the copy's. Passes: nothing is destroyed
DllAllocStateDeathTest.ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloads FreeLibrary and the late terminator return when the DLL, whose operators new and delete go through its copy, checks an access through the copy from that terminator: the first use of the table since the copy freed it. Passes: nothing is destroyed
QuarantineTest.ReleaseAllEmptiesTheQuarantine ct_quarantine_release_all() leaves no byte in the quarantine and no freed record of its blocks, and a block released afterwards goes to the allocator at once. No function yet

The DLL tests use a new fixture, reload/ct_allocating_library.cpp, whose allocation trace is off. Their failure messages give the blocks kept by size only. The fixture is built a second time with reload/ct_new_delete_through_runtime.cpp, whose operators new and delete go through the copy: the blocks that the runtime allocates and releases for itself then reach its entry points, as through an instrumented DLL's copies of the library's inline functions (#197). When asked, the fixture also works through its copy from a terminator in .CRT$XTYZ, which its C runtime runs after the copy's in .CRT$XTY: it checks an access, then allocates and frees blocks, then calls a function that the test gave it.

The three death tests guard the release and what comes after it, which main does not do. Each child process starts its watchdog, waits until it runs, and loads the DLL. It exits with 0 once FreeLibrary and the DLL's late terminator have returned; with 6 when FreeLibrary returned without the late terminator, as when the loader swallowed a fault there or in the copy's terminator; and with 7 when FreeLibrary has not returned within 20 seconds, as when it waits for a lock that its own thread holds.

Limits

  • Code after the copy's terminator. Code of the DLL that runs there and goes through the copy, such as a terminator that a library linked into the DLL puts in a later section, finds a new table. What that code leaves stays allocated once the DLL is gone, and depends on what it does:

    • Any use of the copy that looks the table up, even a bounds check: the new table, empty.
    • A block it allocates and still holds when the DLL is gone: the block's record, a node of 56 bytes in the measurement above.
    • The most blocks it holds at once: the table's buckets, which do not shrink, and, past 4,096 blocks, the storage that the block index grows into; the index holds 4,096 blocks in the module's image.
    • A block it releases: nothing. The quarantine's limit stays 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. So the block goes to the allocator at once, its freed record dropped, from any thread, and the queue is not allocated again.

    Nothing stays when no code goes through the copy after its terminator, as in every test but the two that ask for it.

  • Memory outside the heaps. The probe and the tests see the heaps only, not VirtualAlloc regions such as the shadow memory of a DLL built with it, nor handles.

  • Before Windows 10. ct_dll_unloading_dynamically() cannot tell FreeLibrary apart from the process's termination there: nothing is freed, as before.

  • Linux and macOS. The POSIX runtime's state when dlclose unloads a shared library that links it is neither measured nor changed, ct_quarantine_release_all() aside, which it does not call.

  • __ct_munmap. It drops the freed record of a page with ct_alloc_lock held, outside a runtime scope, and waits for its own lock when the node it frees reaches the runtime's entry points (Windows runtime: its own allocations re-enter it through instrumented inline STL code #197). This PR does not change that path: Windows runtime: __ct_munmap drops a freed record under the table's lock outside a runtime scope #243, reproduced on ad71cea by probe/munmap-forget-lock.

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
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
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
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
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
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
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
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.
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).
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.
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.
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.
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.
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.
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
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
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
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
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
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
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
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
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Nov 1, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Nov 1, 2026
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
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
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant