HyperAnalyzer
Deep dive

Anatomy of HA001: how DllMain deadlocks the loader lock

HyperAnalyzer Team ·
#windows#dllmain#cpp#ha001

When we sat down to pick the first rule to ship, we did not pick the easy one. We picked the one we kept seeing Claude write. That rule is HA001: forbidden Win32 APIs invoked from DllMain.

The setup

DllMain is the entry point Windows calls when a DLL is loaded into a process, when a thread starts or exits, and when the DLL is unloaded. It runs while the OS is holding the loader lock, a global serialisation mutex that protects the in-memory module list. Anything that takes the loader lock recursively will deadlock the process forever.

The list of things that take the loader lock is longer than most people think:

Microsoft’s own DllMain best practices page lists most of these and ends with the line “do as little as possible”. Almost nobody who has not been bitten by it before reads that page before writing their first DLL.

Why Claude writes the bug

LLMs see millions of CreateThread examples in the training data and very few examples of “this is a DllMain context, do not call CreateThread here”. The local pattern (initialise something at load time, kick off a worker thread) is a perfectly natural shape that the model has seen a thousand times in main() functions. Transposing it into DllMain is a one-token difference and the model has no way to know the surrounding context changes the rules.

What HA001 actually checks

The rule is implemented as a libclang AST visitor. It walks every translation unit looking for a FunctionDecl named DllMain, then descends into the body and visits every CallExpr. For each call it resolves the callee name through the type system (handling typedefs, function pointers and GetProcAddress patterns when it can) and checks against a hand-curated list of loader-lock-sensitive APIs. The list lives in the rule file and is derived from Microsoft’s documentation, not from any proprietary source.

When it fires, the finding includes the file, line, called function, and a one-line fix hint that suggests moving the call into a delayed init path triggered from DLL_THREAD_ATTACH or, better, from the first user-facing API call. That hint is what Claude reads on its next turn, and in our tests it produces a clean refactor about 95% of the time.

HA001 is the simplest rule we ship and also the highest-value one. Every C++ DLL Claude has ever written for us in the last six months has triggered it on the first run.

← Back to all posts