Skip to content

fix(runtime): install the crash handler from the executable's copy only - #241

Merged
SizzleUnrlsd merged 59 commits into
mainfrom
fix/dll-copy-crash-filter
Nov 4, 2026
Merged

SizzleUnrlsd merged 59 commits into
mainfrom
fix/dll-copy-crash-filter

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Refs #236

Summary

With CT_BACKTRACE set, each copy of the runtime installed its crash handler with SetUnhandledExceptionFilter when its module initialized, over the filter installed before it, the copy in a DLL that links the runtime included. That copy never put the filter back: once FreeLibrary had unloaded the DLL, the process's filter was still its handler, whose code was gone, and an exception that nothing handled ended the process without a backtrace.

This PR makes only the executable's copy install the crash handler, in windows/ct_runtime_backtrace.cpp. The copy in a DLL installs none and leaves the process's filter alone: there is nothing to put back when FreeLibrary unloads the DLL, which it does as for any other DLL.

  • What it guarantees. The executable's handler reports the crashes of the whole process, those in the code of an instrumented DLL included, while the DLL is loaded and once it has unloaded. A filter that the program installs stays the process's, however the program or another thread changes it while a DLL unloads. FreeLibrary unloads the DLL for real, and a DLL's copy makes no call on the process's filter, under the loader lock or otherwise.
  • The limit, accepted. A program that does not link the runtime gets no crash backtrace from an instrumented DLL, even with CT_BACKTRACE set: its own filter, if any, or Windows handles the crash. The README's section "Crash handler on Windows" says so.

Why not put the filter back

Up to 0111c53, this PR had a DLL's copy put back the filter that its handler had replaced when FreeLibrary unloaded the DLL, through records that linked the copies. Windows has no call that reads the process's filter, or sets it only if it still is the one expected: SetUnhandledExceptionFilter exchanges it. The review of 0111c53 found two consequences, which no loop of exchanges avoids:

  • The release could go on forever with the loader lock held. It put back, in turn, each filter that another thread installed between two of its calls: a thread that kept changing its filters kept FreeLibrary, and the loader with it, waiting.
  • It could overwrite the host's choice. When the host installed again, between two of the release's calls, the filter that the release had set for that moment, the release could not tell it apart from its own, and put back the filter that the host had replaced.

A filter that the program installs over a DLL's handler, and that calls the handler it replaced, would also call code that is gone, which no release can prevent. Of the contracts considered (keep the DLL mapped, restore within a bound, a persistent trampoline, the executable's copy only), the last guarantees the three properties above without keeping the DLL mapped or allocating executable memory, at the cost of the limit above. The rework removes the records, their links, the release and the checks it needed, and brings ct_is_readable and ct_runtime_shutdown.cpp back to their state on main: the only change to the runtime is in ct_maybe_install_backtrace.

Evidence

The tests ran on main, on 0111c53 and on this branch, in the four Windows jobs of LLVM versions (LLVM 19, 20, 22, 23). Each test's child sets CT_BACKTRACE, its executable links the runtime, and "the host's filter" is one that the test installs as a crash reporter would.

Run Branch, head Sources Windows jobs
41722488438 probe/236-exe-only-red-main, 1d2c88b main 232/238 in each job: the six tests marked "fails" below
41722493903 probe/236-exe-only-red-0111c53, 3893f7c 0111c53, the reviewed head 233/238 in each job: the five tests marked "fails" below, the two of the review's findings with exit status 4 and 3
41759624900 this branch, 2f8051a this branch 238/238 in each job; the 15 jobs of LLVM versions pass, and so do Build, clang-format, license-compliance and the commit conventions

The first runs of the test of the review's first finding passed on 0111c53: its stand-in installed its two filters in turn by the parity of the call, so the release got back the filter it had just set and stopped after four calls, a case of the second finding. The stand-in now installs whichever filter the copy's call did not set, and 0111c53 fails it at the 10,000 calls the test allows. The branch's commits a2395d2 to 83c5f32 do not build on Windows: that test then used gmock's testing::Truly and testing::AllOf, and the runtime unit tests link gtest only; d6c3c1b fixed it.

Tests

Test What it checks main 0111c53
ACrashInADllThatLinksTheRuntimeIsReportedByTheExecutable The DLL faults in its own code while it is loaded: the executable's handler prints the backtrace, whose first frame lies in the executable's image, with a frame in the DLL's. fails: the DLL's handler prints it fails: the DLL's handler prints it
OnceADllThatLinksTheRuntimeUnloadsTheExecutableReportsCrashes Once the DLL has unloaded, the executable's handler reports a crash. fails: no backtrace passes
ADllThatLinksTheRuntimeInstallsNoHandlerInAProgramWithoutOne With the executable's handler removed, as in a program without the runtime, the process's filter stays null while the DLL is loaded: the limit. fails, 3 fails, 3
TheHostsFilterInstalledBeforeADllStaysWhileItComesAndGoes The host's filter, installed before the DLL loads, stays the process's while the DLL is loaded, and reports the crash once it has unloaded. fails, 3 fails, 3
ADllUnloadedLeavesTheHostsFilterInstalledAfterIt The host's filter, installed once the DLL is loaded, reports the crash once it has unloaded. passes passes
OnceTwoDllsUnloadInReverseOrderTheExecutableReportsCrashes Two copies of the DLL unload, the second first: the executable's handler reports the crash. fails: no backtrace passes
OnceTwoDllsUnloadInLoadOrderTheExecutableReportsCrashes The same, the first first. fails: no backtrace passes
ADllUnloadsWhileAnotherThreadKeepsChangingTheFilter While the DLL unloads, another thread changes the filter after each of the copy's calls to SetUnhandledExceptionFilter: the copy does not keep calling, and fails the test with 4 at 10,000 calls. The review's first finding. passes: no release fails, 4
TheFilterTheHostInstallsLastStaysOnceADllUnloads While the DLL unloads, the host installs again the filter that the copy's first call set: the filter that the host installed last stays the process's, or the test fails with 3. The review's second finding. passes: no release fails, 3

Each test frees the DLL and fails with 2 when it stays mapped: FreeLibrary unloads it for real. The last two tests point the DLL's copy's import of SetUnhandledExceptionFilter at a stand-in, in the test, after loading the DLL: the runtime has no seam for them.

The tests of the hand-over go with it: a copy over a filter whose module headers it cannot read, two copies whose handlers replaced one handler, a DLL that calls exit(), a hand-over for which nothing can be allocated, a filter installed by another thread during the release, two DLLs unloaded around a filter of the program, and a DLL's copy that reports the crashes while its DLL is loaded, which the new contract no longer offers.

The watchdogs of two test helpers

On main, unloadARuntimeLibraryThenLoadAnother and faultOnAThread start the thread that ends their child with 7 after 20 seconds without waiting for it. A thread takes the loader lock to start, to attach to the loaded modules: when the code under test waits forever with that lock held before the watchdog has started, the watchdog waits too, and nothing ends the child. #242 met this in churnAndUnload (d19eabf). startWatchdog() now returns once its thread runs, and both helpers call it (b022ebc, 2f8051a).

A probe ran each helper in a child on one CPU, its thread above the threads it starts, as on a machine whose other CPUs are busy, while the DLL that links the runtime waited forever in its unload with the loader lock held. A thread started before the helper ended the child with 11 after 60 seconds when the helper's watchdog had not ended it.

Run Branch, head unloadARuntimeLibraryThenLoadAnother faultOnAThread
41759465829 probe/241-watchdog-red, 3b04a49: this branch at 001d887 exit 11 after 60 s in each of the four Windows jobs: the watchdog never ran exit 7 after 20 s in each job
41759619034 probe/241-watchdog-green, 6f15efa: this branch at 2f8051a exit 7 after 20 s in each job exit 7 after 20 s in each job

faultOnAThread starts its watchdog before the thread that faults, so on one CPU the watchdog runs first. Its race needs several CPUs and a watchdog delayed until the fault's thread holds the loader lock, which the probe cannot force without changing the helper: the change there removes a dependence on the scheduler.

Limits

  • A program without the runtime. An instrumented DLL that it loads reports no crash, even with CT_BACKTRACE set.
  • Linux and macOS. The signal handlers that a copy in a shared library installs are neither examined nor changed when dlclose unloads it.

The copy of the runtime in a DLL that links it installs its crash
handler when the DLL loads. A child that removes the executable's
handler, as a program without the runtime has none, then faults while
the DLL is loaded gets the backtrace of the DLL's copy.

Refs #236
A program that installs a filter of its own over the handler of a DLL's
copy keeps it when FreeLibrary unloads the DLL: the child's filter, not
the runtime's handler, ends the process, with an exit code of its own.

Refs #236
With CT_BACKTRACE set, the copy of the runtime in a DLL installs its
crash handler over the filter installed before it, the executable's
handler or one of the program's, and nothing put that filter back once
FreeLibrary had unloaded the DLL.

The copy now keeps the filter it replaced, and puts it back when
FreeLibrary unloads the DLL, after the destructors of the DLL's global
objects, if its handler is still the process's: a filter installed over
it since stays. Nothing changes when the process terminates.

Refs #236
The copy of the runtime in a DLL installs its crash handler over the
executable's. Once FreeLibrary has unloaded the DLL, whose code is
gone, a crash must reach the executable's handler again.

Refs #236
A program's own filter, installed before a DLL that links the runtime
loads, must handle the crashes again once FreeLibrary has unloaded the
DLL, whose copy installed its handler over the filter.

Refs #236
Two copies of the DLL that links the runtime, each a module apart, each
install their handler over the one before. Unloaded in reverse order,
they must leave the executable's handler to report the crash.

Refs #236
Each copy of the runtime keeps the filter that its crash handler
replaced in a record, in a section .ctcrash of its module, with a magic
number and the handler's address: another copy can find the record from
the handler alone.

Refs #236
Two DLLs that link the runtime, unloaded in load order: the first
copy's handler was no longer the process's when it unloaded, so it left
the filters as they were, and the second copy, whose handler had
replaced the first one's, put that handler back when it unloaded in
turn, although its code was gone.

A copy whose handler is no longer the process's now walks back from
the process's filter through the handlers of the copies that keep a
record, to the copy whose handler went over its own, and hands it the
filter it replaced. The walk stops at the first filter that is not
such a handler.

Refs #236
The first copy unloads while the second's handler, installed over its
own, is still there. Once the second unloads too, the executable's
handler must report the crash, not the first copy's, whose code is gone.

Refs #236
A copy of the runtime that unloads while another copy's handler is
over its own looked for that copy from the process's filter back, and
stopped at the first filter that was not a copy's handler. A filter of
the program installed over the other copy's handler, as a scope that
puts that handler back afterwards does, hid the copy: once it unloaded
in turn, it put back a handler whose code was gone.

The copy now looks for the record that holds its handler among the
loaded modules, which no thread unloads while FreeLibrary holds the
loader lock. The record no longer needs the handler's address.

Refs #236
The tests that unload two copies of the DLL that links the runtime load
them through one function, for a test to come that unloads them in
another way.

Refs #236
The first copy unloads while a filter of the program is over the
second copy's handler, which had replaced the first one's. Once the
program has put the second copy's handler back and that copy has
unloaded too, the executable's handler must report the crash, not the
first copy's, whose code is gone.

Refs #236
The host's filter of the crash tests prints the code of the exception it
gets, and the tests expect the fault's: an exception raised while the
DLL unloads, such as an access violation, no longer passes for it.

Refs #236
A test whose DLL stayed mapped after FreeLibrary would find its copy's
handler still there, and pass whatever the runtime does: the children
of the crash tests now exit with 2 when a DLL that links the runtime is
still mapped once freed.

The copies of the DLL that two tests load are named after the child's
process, which deletes them once unloaded: two runs that share a
temporary directory no longer copy over a DLL that the other has
loaded.

Refs #236
The crash tests accepted a backtrace from any copy of the runtime. The
child now prints the image of the module whose handler must report the
crash, the executable's or the DLL's, and the first frame of the
backtrace, which the handler takes in itself, must lie in it.

Refs #236
ct_is_readable, which the vtable boxes use before they read the RTTI of
an object, moves out of the anonymous namespace of
windows/ct_runtime_vtable.cpp, declared with the other helpers of the
Windows runtime, for the crash handler to use as well.

Refs #236
A copy of the runtime whose handler is no longer the process's reads the
headers of the loaded modules to find the copy whose handler went over
its own. A packer or an anti-tamper check may erase a module's headers,
or make their page unreadable: the read then faulted inside DllMain,
while FreeLibrary held the loader lock.

The copy now checks each part with ct_is_readable, and the DOS and PE
signatures, before it reads further, and skips the module otherwise. A
record must also lie in a writable section, since the copy updates it.

Refs #236
A DLL without the runtime has the page of its headers made unreadable,
as a packer or an anti-tamper check can. The DLL that links the runtime,
under the program's filter, then unloads on a thread of its own: its
copy, looking for the copy above, must skip that module rather than
fault while FreeLibrary holds the loader lock.

Refs #236
The copy of the runtime that unloads looked for the copy above among
the first 1024 modules only. That copy loaded after this one, so it
comes late in the list, where a process with more modules cut it off,
and it then put back a handler whose code was gone.

The copy now asks how many modules the loader lists, and reads them all
into a block of the process's heap, which DllMain may allocate. The
comment says that a DLL calling exit() runs this without the loader
lock.

Refs #236
The copy of the runtime that unloads gave the filter it replaced to the
first copy found whose record held its handler, and looked for one only
when its handler was no longer the process's. A program that puts back
a filter it read earlier, over the handlers of copies loaded since, can
leave two records holding the handler, or one whose copy's handler is
not the process's while the unloading one's is.

The copy now hands the filter over to every record that holds its
handler, whether its handler was still the process's or not.

Refs #236
A program that puts back a filter it read earlier, the first copy's
handler, over the second copy's, then loads a third copy, leaves the
second and the third copies' records both holding the first one's
handler. Once the first copy unloads, neither the third nor the second
copy may put that handler back when they unload in turn, whatever the
order in which the loader lists their modules.

Refs #236
With CT_BACKTRACE, each copy of the runtime installs its crash handler
over the filter installed before it. The README says when a DLL's copy
puts that filter back, and what becomes of a filter installed over the
handler since: the program's own, or another copy's handler.

Refs #236
The test of a DLL unloading past unreadable module headers passed
without the readability checks too: the access violation that the read
raised during FreeLibrary did not reach the program's filter.

A vectored handler, which sees every exception before any other handler,
now records an access violation in the unreadable page while the DLL
unloads, and the child exits with 4 when there was one.

Refs #236
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Nov 1, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Nov 1, 2026
A DLL that calls its own C runtime's exit() runs its destructors before
the loader detaches it, without the loader lock: the release of its
copy's crash handler then walked the modules while other threads could
still unload them, although the DLL stays mapped until the process ends.

The TLS callback of windows/ct_runtime_shutdown.cpp now records the
loader's DLL_PROCESS_DETACH, which comes before the module's entry point
and the destructors that the C runtime runs from it, and the release
runs only once the loader detaches the DLL.

Refs #236
The release of a DLL's copy found the copies whose handlers went over
its own by listing the modules into a block of the process's heap: when
the allocation or the listing failed, nothing was handed over, and a
copy loaded later put back the unloaded copy's handler.

Each DLL's copy now links its record, when it installs its handler, to
the record of the copy whose handler it went over, found from that
handler's address. Both happen with the loader lock held, at the DLL's
attach and at FreeLibrary's detach: the release follows the links, takes
the copy out of the list below it, and hands the filter it replaced to
the copies above, without allocating or listing the modules. The
executable's copy links to none.

Refs #236
The release of a DLL's copy sets the filter its handler replaced, then
puts back the filter it found there when that is not its handler. A
filter that another thread installed between those two calls was lost:
the second call replaced it.

The second call returns that filter instead of the one the first call
set: the release now puts it back in turn, and so on, so that no filter
installed meanwhile is lost. The thread that installed it still got,
as the filter it replaced, the one set by the release for that moment:
Windows has no call that reads the process's filter, or sets it only
if it is the one expected.

Refs #236
A DLL's copy whose handler goes over a filter in a module whose headers
cannot be read, as a packer or an anti-tamper check can leave them,
cannot tell whether that filter is another copy's handler, nor link its
record to it. Installed anyway, it would put that handler back once its
DLL had unloaded.

The copy now puts the filter back in place and leaves its own handler
out, saying so.

Refs #236
The README says that a DLL's copy links its record to the copy below
when it installs its handler, that it leaves its handler out over a
filter whose module it cannot read, what becomes of a filter that
another thread installs while it puts its own back, and that a DLL
ending the process with exit() changes nothing.

Refs #236
A DLL's copy releases its handler only where RtlDllShutdownInProgress
tells FreeLibrary apart from the process's termination, from Windows 10
on. Before that, its record would stay linked in the records of other
copies once FreeLibrary had unmapped its module, and their release would
follow a link into unmapped memory.

The copy now links its record only where ct_process_shutdown() can
tell. Elsewhere it installs its handler as before this change, without
a link.

Refs #236
ACopyStaysOffAFilterWhoseModuleItCannotRead made the header page of a
loaded DLL PAGE_NOACCESS, then loaded a second DLL: the loader reads
that header under its lock while it loads the second, and hung the job
for its 45-minute limit on every Windows build.

The production path it covered stays, with a comment on why it has no
unit test. The copy of the runtime still leaves its handler out when it
cannot read the headers of the module of the filter in place.

Refs #236
The crash handler of a DLL's copy will no longer be installed, nor
put back when FreeLibrary unloads the DLL: the tests of that hand-over
go. They checked a copy that cannot read the headers of the module of
the filter in place, two copies whose handlers replaced one handler,
a DLL that calls exit(), a hand-over for which nothing can be
allocated, a filter installed by another thread during the release,
and two DLLs unloaded around a filter of the program.

Refs #236
Only the dropped tests of a DLL that calls exit() and of a hand-over
for which nothing can be allocated loaded it.

Refs #236
The test checked that the copy of the runtime in a DLL reports the
process's crashes while the DLL is loaded, in a program without a
handler of its own. That copy will no longer install its handler:
only the executable's copy will, a contract under which such a
program gets no crash backtrace from the DLL.

Refs #236
With CT_BACKTRACE, the copy of the runtime in a DLL that links it
installed its crash handler for the whole process, and had to take it
out when FreeLibrary unloaded the DLL. Windows has no call that sets
the process's filter only if it still is the one expected: the release
put the filter back by exchanges, in a loop that another thread
changing its filters could keep going with the loader lock held, and
that put back a filter over one that the program had set again to the
same address. A filter that the program installed over the handler
would call it once its code was gone, too.

The copy in a DLL now installs no handler, and leaves the process's
filter alone: the executable's copy reports the crashes of the whole
process, those of the DLL's code included, and FreeLibrary unloads the
DLL as usual. An instrumented DLL that a program without the runtime
loads reports no crash.

Refs #236
No copy of the runtime in a DLL installs the crash handler any more:
nothing remains to put back when FreeLibrary unloads the DLL. The
records in the section .ctcrash, their links between copies, the
release that put the filter back by exchanges, and the inspection of
the headers of the module of the filter in place go.

Refs #236
The crash handler records shared it with the vtable diagnostics, its
only user once the records are gone: it is local to that file again,
as on main.

Refs #236
Only the release of a DLL copy's crash handler asked whether the
loader, rather than the DLL's own exit(), runs the module's
destructors: that release is gone.

Refs #236
The DLL exports a function that raises an exception that nothing
handles, so that a test can check which handler reports a crash in a
DLL whose copy of the runtime installs none.

Refs #236
The comments of the crash tests that remain said that a DLL's copy
installs its handler over the one before it and hands it over when the
DLL unloads. They now say that the copy installs none, that the
executable's handler or the program's filter reports the crash once
the DLL has unloaded for real, and the copies of the DLL that links
the runtime always come from that DLL.

Refs #236
The DLL that links the runtime faults in its own code while it is
loaded. The executable's copy of the runtime, the only one that
installs a crash handler, prints the backtrace: its first frame lies in
the executable's image, and a frame lies in the DLL's.

Refs #236
In a program that does not link the runtime, which has no handler of
its own, the DLL that links the runtime loads and unloads without
installing a filter: the process's filter stays null. Such a program
gets no crash backtrace from an instrumented DLL, the limit of the
contract under which only the executable's copy installs the handler.

Refs #236
The test of a filter that the program installs before it loads the DLL
that links the runtime checked only the crash after the DLL unloaded.
It now checks that the filter stays the process's while the DLL is
loaded too, and exits with 3 if the DLL's copy replaced it.

Refs #236
The release that put a DLL copy's filter back went on exchanging
filters for as long as another thread changed the filter between two
of its calls, with the loader lock held. The test points the copy's
import of SetUnhandledExceptionFilter at a stand-in after which
another thread installs one of two filters in turn, 10000 times at
most, and fails with 4 when the copy kept calling for that long.

Refs #236
When the release of a DLL copy's filter had set, for a moment, the
filter that its handler replaced, and the host installed that same
filter again meanwhile, the release could not tell the two apart and
put back the filter the host had replaced. The test points the copy's
import of SetUnhandledExceptionFilter at a stand-in after whose first
call the host installs again the filter that call set, and fails with
3 when the process's filter is not then the one the host installed
last.

Refs #236
The section on the crash handler on Windows described the hand-over of
the filter between the copies of DLLs. It now says that only the
executable's copy installs a handler, which reports the crashes in an
instrumented DLL's code too, that a DLL's copy leaves the program's
filter alone and lets FreeLibrary unload the DLL as any other, and the
limit: in a program that does not link the runtime, an instrumented
DLL reports no crash.

Refs #236
The entry of #236 described the hand-over of a DLL copy's filter when
FreeLibrary unloads the DLL. It now says that only the executable's
copy installs the crash handler, which reports the crashes in an
instrumented DLL's code too, and that an instrumented DLL that a
program without the runtime loads reports no crash any more.

Refs #236
The test of a crash in the DLL's code combined its two checks with
testing::Truly and testing::AllOf, which come from gmock: the runtime
unit tests link gtest only, and the file did not build on Windows.
The matcher of a crash backtrace printed by a given handler now takes
the mark of the image where a frame must lie too.

Refs #236
The test of an unload while another thread changes the filter passed
on 0111c53, whose release looped for as long as another thread
changed the filter between two of its calls. Its stand-in installed
its two filters in turn by the parity of the call, so the third call
returned the filter that the second had set: the release took it for
its own and stopped. The stand-in now installs whichever of its two
filters the copy's call did not set, as a thread that keeps changing
the filter does.

Refs #236
@SizzleUnrlsd SizzleUnrlsd changed the title fix(runtime): put back the filter a DLL's crash handler replaced fix(runtime): install the crash handler from the executable's copy only Nov 3, 2026
The child of the tests that unload a DLL linking the runtime, then load
another, started its watchdog thread just before LoadLibrary, without
waiting for it. A thread takes the loader lock to start, to attach to
the loaded modules: when the unload waits forever with that lock held
before the watchdog has started, the watchdog waits too, and nothing
ends the child.

startWatchdog() now returns once the watchdog runs, and the child calls
it before it loads the DLL.

Refs #236
The child of the crash tests started its watchdog thread without waiting
for it, then the thread that faults. A thread takes the loader lock to
start: should the fault's thread take that lock and wait forever with it
held before the watchdog has started, the watchdog would wait too, and
nothing would end the child.

The child now waits until the watchdog runs before it starts the
fault's thread.

Refs #236
@SizzleUnrlsd
SizzleUnrlsd marked this pull request as ready for review November 4, 2026 04:45
@SizzleUnrlsd
SizzleUnrlsd merged commit 09df200 into main Nov 4, 2026
25 of 40 checks passed
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