Repository navigation
fix(runtime): free a DLL copy's allocation state at FreeLibrary - #242
Draft
SizzleUnrlsd wants to merge 29 commits into
Draft
SizzleUnrlsd wants to merge 29 commits into
SizzleUnrlsd wants to merge 29 commits into
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
FreeLibraryunloaded 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
FreeLibraryunloads the DLL, inwindows/ct_runtime_alloc.cpp:.CRT$XTYterminator, which the DLL's C runtime runs after the DLL'satexithandlers, 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.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 ownexit(), 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).ct_quarantine_release_all(), new inct_runtime_quarantine.cpp, shared with the POSIX runtime.CtRuntimeScope), as every other change of the table does. The table and the quarantine release blocks withct_alloc_lockheld, 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 leaveFreeLibrarywaiting forever with the loader lock held.ct_alloc_table()builds the table underct_alloc_lockwhenever 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_reallocand__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.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:
ct_block_index_storage_size(8192).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.probe/227-alloc-state-red, a04e828mainmainlacks, is left out.probe/227-review-red, 8667488probe/227-poison-red, a203fdbprobe/227-check-bounds-red, 36e0982ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloadsfails, with exit status 7 after 20 seconds.probe/227-index-mutant, d5f9b08probe/227-poison-green, c5dac0aLLVM versionspass, and so doBuild,clang-format,license-complianceand the commit conventions.mainmerged in at 09df200, which brought #241: the CHANGELOG conflict resolved, both entries kept, andchurnAndUnloadstarting its watchdog withstartWatchdog()LLVM versionspass, and so doBuild,clang-format,license-complianceand 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:
ct_release_alloc_state()freed the table and released the quarantine withct_alloc_lockheld, 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,ADllWhoseOperatorDeleteFreesThroughItsCopyUnloadsexits with 7 after 20 seconds, and so doesADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloads, whose DLL waits in the same release before its late terminator runs.FreeLibrary: the loader swallows an exception raised while it unloads a DLL, andFreeLibraryreturns as usual. So the death tests have the DLL report the end of its late terminator, and fail with 6 without it.ADllThatAllocatesAfterItsCopyFreedItsStateUnloadsexits 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:
ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloadsexits 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
FreeLibraryuntil 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.ReleaseAllEmptiesTheQuarantinefails (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
mainDllAllocState.ABlockADllKeepsStaysAllocatedOnceItUnloadsFreeLibraryhas unloaded the DLL.DllAllocState.ADllThatAllocatesThroughItsCopyKeepsNoHeapBlockAtEachLoadDllAllocState.ADllWhoseBlockIndexGrewKeepsNoHeapBlockAtEachLoadDllAllocStateDeathTest.ADllWhoseOperatorDeleteFreesThroughItsCopyUnloadsFreeLibraryand 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.DllAllocStateDeathTest.ADllThatAllocatesAfterItsCopyFreedItsStateUnloadsFreeLibraryand the late terminator return when the DLL allocates and frees four blocks through its copy from that terminator, which runs after the copy's.DllAllocStateDeathTest.ADllThatChecksAnAccessAfterItsCopyFreedItsStateUnloadsFreeLibraryand 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.QuarantineTest.ReleaseAllEmptiesTheQuarantinect_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.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 withreload/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
maindoes not do. Each child process starts its watchdog, waits until it runs, and loads the DLL. It exits with 0 onceFreeLibraryand the DLL's late terminator have returned; with 6 whenFreeLibraryreturned without the late terminator, as when the loader swallowed a fault there or in the copy's terminator; and with 7 whenFreeLibraryhas 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:
ct_quarantine_release_all()has set it:CT_QUARANTINE_MBis 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
VirtualAllocregions such as the shadow memory of a DLL built with it, nor handles.Before Windows 10.
ct_dll_unloading_dynamically()cannot tellFreeLibraryapart from the process's termination there: nothing is freed, as before.Linux and macOS. The POSIX runtime's state when
dlcloseunloads 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 withct_alloc_lockheld, 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 byprobe/munmap-forget-lock.