Bug 89
On Windows, a thread exiting after IPC calls can deadlock the process
- Status
- Open, fix under review
- Symptom
- Deadlock; worst effect seen: deadlock
- Where seen
- Bitcoin Core unit tests, Bitcoin Core functional tests, libmultiprocess tests (mptest)
- Trigger
- Shutdown
- First reported
- 2026-04-20 (first report)
- Fixed
- Not yet
- Fix
- mp#368
- In released binaries
- No
- Defect ever on master
- Yes
Summary
On Windows, a program using libmultiprocess can hang when one of its threads that made IPC calls exits. The thread's per-thread IPC state is cleaned up by a thread_local destructor, which Windows runs while holding its global loader lock, and that cleanup waits for the event loop. If the event loop is at that moment waiting for a worker thread to start or exit, the worker needs the loader lock, and all three threads wait on each other forever. It was found with the Windows port of the Stratum v2 template provider (sv2-tp) and reproduced with native MSVC and MinGW builds. libmultiprocess does not support Windows on master yet, so no release is affected. The fix is mp#368, a draft.
What happened
- In April 2026, @Sjors's Windows branch of sv2-tp (stratum-mining/sv2-tp#93) had a test teardown that "intermittently deadlocks in std::thread::join during libmultiprocess thread-local cleanup", and was worked around by never destroying the test object on Windows. ryanofsky replied that this was not a known issue and asked for a stack trace. The description fits this bug, but no trace was posted, and MinGW builds at the time could also hang from a different
thread_localbug, so it may have been that one. - On 2026-09-28 a new native Windows integration test in sv2-tp#93 found it again, and Sjors opened mp#368 with the diagnosis.
- After ryanofsky's questions, Sjors confirmed, with his agent, that both MSVC 19.44 and MinGW GCC 15.2 run
thread_localdestructors under the loader lock, and posted stacks: the exiting thread owns the loader lock and waits inEventLoop::sync(), the event loop is joining a worker, and the worker waits inside Windows thread exit. The new test hung every time with MSVC; with MinGW, 3 of 10 runs hung, 6 crashed withSTATUS_HEAP_CORRUPTION, and 1 passed.
Root cause
libmultiprocess gives every application thread that makes IPC calls a dedicated worker thread on the server side, and keeps the handles for those workers in a thread_local ThreadContext. When the application thread exits, the ThreadContext destructor releases the handles, and releasing each one waits for the event loop with sync().
On Windows, the loader lock is held while threads start and exit and while thread_local destructors run. The event loop sometimes waits for a thread to start (when it creates a worker) or to finish exiting (when it destroys one, after a client releases it or a connection closes). If an application thread is exiting at the same moment, it holds the loader lock and waits for the event loop, the event loop waits for the worker, and the worker waits for the loader lock. On Linux and macOS, thread exit does not take a shared lock, so the same sequence completes.
Fix
No fix is merged yet. ryanofsky suggested fixing it on the side that holds the lock: the exiting thread should not wait for anything, which covers every place the event loop waits on a thread, including future ones. mp#368 does this. The ThreadContext destructor now hands the handles to the event loop's async cleanup thread and returns without waiting. To make that safe, the handle maps move to a heap object that disconnect handlers can still use after the thread is gone, and cleanup can be queued from any thread. The PR also adds portable tests for these paths.
A last commit also joins workers on the async cleanup thread instead of the event loop. ryanofsky noted that, once the exiting thread no longer waits, this is a separate improvement that keeps the event loop responsive, not part of the deadlock fix. On 2026-10-13 ryanofsky asked for the PR to be rebased on mp#361, which reworks the same code to fix a thread map teardown race, and Sjors marked it a draft until then.
Next steps
- Merge mp#361, then rebase mp#368 on it and review the simplified cleanup ryanofsky proposed (queue each handle on its own event loop).
- Decide whether the worker-join commit stays in mp#368 or becomes a follow-up.
- Track the MinGW
STATUS_HEAP_CORRUPTIONcrashes, which Sjors says this PR does not fix and which occur only in native builds, separately. They may be the older MinGWthread_localheap corruption. - A native Windows CI job would be needed to catch a regression, since the portable tests pass on Linux with or without the fix.
Timeline
- 2019-06-25: the
thread_local ThreadContextwith a destructor that waits for the event loop exists since the libmultiprocess initial commit 06e1045baba0. - 2026-04-20: possible first sighting in sv2-tp#93, worked around in the test.
- 2026-09-28: found again by a native Windows integration test; mp#368 opened.
- 2026-10-07: cause confirmed with stack traces on MSVC and MinGW.
- Releases: not affected. libmultiprocess master does not build for Windows (mp#231 adds support), and Bitcoin Core does not build IPC for Windows.
Lessons
What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.
Pattern CI, toolchain and platform: A thread-local variable with a destructor
- Prevented by a design rule
- Earliest detector a unit test (did not exist then)
- Diagnosis hard
- Fix design hard
- Debugging aids symbolized stack traces, thread names
- Rust no
Would Rust have prevented it? No. A destructor that blocks on another thread while the OS holds a lock that thread needs is a deadlock, which Rust does not prevent; Rust's thread_local! destructors on Windows run in the same loader-locked TLS callback, so the same design would hang the same way. More on Rust
Could it have been prevented?
Yes, with a rule Microsoft documents for code that runs under the loader lock: do not
wait for other threads there. libmultiprocess's per-thread cleanup is a thread_local
destructor that releases remote thread handles and waits for the event loop to do it,
and Bitcoin Core's own tidy check forbids nontrivial thread_local destructors; the
library silenced it as harmless "on modern platforms". The fix in
mp#368 follows the rule:
the destructor queues the cleanup on another thread and returns. As ryanofsky
put it,
avoiding waits in the few places that run under the lock is easier to keep correct than
checking every place the event loop might wait on a thread.
Could it have been found sooner?
Only by running on Windows, which libmultiprocess did not support before the port in mp#231. A test in which a thread makes an IPC call and exits while the event loop joins a worker hangs reliably on MSVC; such a test now exists in mp#368, but on Linux it passes with or without the fix, so only a native Windows CI job would catch a regression. The first sighting, in sv2-tp's Windows tests in April 2026, was worked around in the test instead of investigated, because no stack trace was captured.
What made it hard to debug?
A hang in teardown, intermittent on MinGW and mixed with heap-corruption crashes from
another cause, with no trace at first. The loader lock is invisible in application
code; the cycle only became clear from Windows stacks showing the exiting thread
owning LdrpLoaderLock while waiting in EventLoop::sync(), the event loop joining a
worker, and the worker in LdrShutdownThread. Thread names that encode which client
thread a worker serves ("(from ...)") made the stack readable.
Lesson
Code that runs at thread exit, in a thread_local destructor or a DLL detach hook,
must not wait for other threads: on Windows it runs under a process-wide lock that
thread creation and exit also need, so hand the work to another thread instead.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | deadlock |
| Worst effect observed | deadlock |
| Seen in | unit-test, functional-test, mptest |
| Scope | production |
| Trigger | Shutdown (shutdown) |
| Phase | any |
| Side | both |
| Component | proxy-io |
| Root cause kind | lock-order |
| Fix kind | new-code-path |
| Detection | ci-intermittent |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | no |
| Confidence | medium |