Bug 19
Closing a connection writes to freed memory and leaks server objects
- Status
- Fixed
- Symptom
- Undefined behavior; worst effect seen: sanitizer report only
- Where seen
- Bitcoin Core unit tests
- Trigger
- A peer disconnects cleanly
- First reported
- 2024-09-26 (first report)
- Fixed
- 2024-10-03
- Fix
- mp#116
- Shipped to Bitcoin Core
- bitcoin#31105
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
When a libmultiprocess Connection was destroyed while the other side still held references to objects this side was serving, the cleanup of those objects wrote into memory that had already been freed. This is undefined behavior, and the cleanups queued that way never ran, so the wrapped objects leaked. The cause was the order in which ~Connection destroyed its members: the lists of cleanup functions went before the Cap'n Proto RpcSystem, and it is the RpcSystem's teardown that disposes of the remaining ProxyServer objects and queues their cleanups. It showed up only as a MemorySanitizer report in Bitcoin Core CI. Every connection teardown with undestroyed server objects hit it, in source builds of v22 through v28 with multiprocess enabled. Fixed in mp#116.
What happened
@Sjors's bitcoin#30975 ("ci: build multiprocess on most jobs") built multiprocess on most CI jobs, which ran ipc_tests under MemorySanitizer for the first time. It failed with MemorySanitizer: use-of-uninitialized-value /usr/src/mp/proxy.cpp:146:25 in mp::Connection::addAsyncCleanup, with a second stack ("Member fields were destroyed") showing the std::list already destroyed by ~Connection. @maflcko quoted the summary line the day the PR was opened (2024-09-26), and Sjors posted it again with the Cirrus task on 2024-10-01. ryanofsky opened mp#115 ("MSAN Error during connection shutdown") with the full trace and analysis the same day.
Root cause
Connection (include/mp/proxy-io.h) declared m_rpc_system before the m_sync_cleanup_fns and m_async_cleanup_fns lists, so on destruction the lists went first, then m_threads and m_thread_map, then m_rpc_system, and m_network last. Destroying the capnp::RpcSystem drops its exports table, which disposes of every ProxyServer the peer had not released: objects of interfaces without a destroy method, or any object whose peer went away. ~ProxyServerBase then calls m_context.connection->addAsyncCleanup(...) to schedule destruction of m_impl, inserting into a list whose storage is gone. Besides the invalid write, cleanups queued this way were never run, so the m_impl objects leaked.
The failing test destroyed its connection from an onDisconnect handler, the same pattern that ServeStream, the library function serving a connection on a socket, uses, so the bug was not specific to the test. The write to destroyed list storage is silent under a normal allocator, which is why it was only noticed under MSan.
The code
Quoted from 2350843a3196^. Connection in include/mp/proxy-io.h declares its RPC system before its cleanup lists:
::capnp::TwoPartyVatNetwork m_network;
::capnp::RpcSystem<::capnp::rpc::twoparty::VatId> m_rpc_system;
// ...
CleanupList m_sync_cleanup_fns;
CleanupList m_async_cleanup_fns;
};
C++ destroys members in reverse order, so after the body of ~Connection runs, m_async_cleanup_fns is destroyed first and m_rpc_system later. Destroying the RPC system releases every ProxyServer the peer never called destroy on, and ~ProxyServerBase in the same file queues its implementation's destruction on the connection:
ProxyServerBase<Interface, Impl>::~ProxyServerBase()
{
if (m_impl) {
// ...
m_context.connection->addAsyncCleanup([impl=std::move(m_impl), c=std::move(m_context.cleanup)]() mutable {
Connection::addAsyncCleanup (src/mp/proxy.cpp) then does
m_async_cleanup_fns.emplace(m_async_cleanup_fns.end(), std::move(fn));
on a list that has already been destroyed. MSan reports the use of the dead list, and the queued callback never runs, so the m_impl object leaks.
The fix destroys the RPC system explicitly at the start of ~Connection, while the lists are alive and before the loop that hands the async list to the cleanup thread (2350843a3196; the -> changes at the other m_rpc_system uses are omitted):
@@ -333,7 +333,7 @@ public:
- ::capnp::RpcSystem<::capnp::rpc::twoparty::VatId> m_rpc_system;
+ std::optional<::capnp::RpcSystem<::capnp::rpc::twoparty::VatId>> m_rpc_system;
@@ -48,6 +48,12 @@ void LoggingErrorHandler::taskFailed(kj::Exception&& exception)
Connection::~Connection()
{
+ // Shut down RPC system first, since this will garbage collect Server
+ // objects that were not freed before the connection was closed, some of
+ // which may call addAsyncCleanup and add more cleanup callbacks which can
+ // run below.
+ m_rpc_system.reset();
+
Any ProxyServer released by the RPC system now adds its callback to a live list, and the rest of ~Connection moves those callbacks to the async cleanup thread, so the implementations are destroyed instead of leaked.
Fix
The fix commit 2350843a3196, in mp#116 ("shutdown bugfix: destroy RPC system before running cleanup callbacks"), makes m_rpc_system a std::optional and resets it as the first statement of ~Connection, before the cleanup callbacks run, with a comment that garbage collection may add more cleanup callbacks, which then run below (+8/-2). Sjors confirmed the MSan job passed with the fix.
Timeline
- 2019-06-25: member order present since the library's initial commit 06e1045baba0, in which
~ProxyServerBasealready calledaddAsyncCleanup. - 2024-09-26: MSan failure first quoted on bitcoin#30975 by @maflcko; 2024-10-01: reposted by @Sjors and filed as mp#115.
- 2024-10-03: fix merged in mp#116.
- 2024-10-21: shipped to Bitcoin Core in bitcoin#31105 ("Update libmultiprocess library").
- Releases: v28.x and earlier have the bug in source builds with multiprocess enabled.
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 Built or torn down in the wrong order: Teardown in the wrong order
- Prevented by a design rule
- Earliest detector other (did not exist then)
- Diagnosis easy
- Fix design easy
- Debugging aids sanitizer output, symbolized stack traces
- Rust yes
Would Rust have prevented it? Yes. A ProxyServer's Drop could not reach its Connection through a plain reference (the struct would be self-referential), so it would go through Rc or Weak, where the cleanup list is either still alive or the upgrade fails and the callback is skipped; the write into a dead member of a live struct is unwritable in safe Rust, though the impl leak could remain. More on Rust
Could it have been prevented?
Yes. ~ProxyServerBase calls back into its Connection (addAsyncCleanup), and the
RpcSystem that disposes of those servers was a plain member declared before the
cleanup lists, so implicit member destruction ran the callbacks into lists that were
already gone. The rule the fix applies is a standard one: a member whose destructor can
call back into the enclosing object must be destroyed explicitly at the top of the
destructor, while everything it may touch is still alive. The fix commit
(2350843a3196)
does exactly that, wrapping m_rpc_system in std::optional and resetting it as the
first statement of ~Connection with a comment saying why. This was knowable in 2019,
when the same commit wrote both the callback and the member order; it is the kind of
dependency that a class comment listing "members that run callbacks on destruction"
would have kept visible.
Could it have been found sooner?
Yes, by running ipc_tests under MemorySanitizer, and by nothing weaker. The write
lands in a destroyed std::list whose storage is still inside the live Connection
object, so ASan sees no freed memory and a normal allocator is silent; only MSan's
use-after-destructor poisoning flags it, and the leaked m_impl objects were never
counted. Bitcoin Core had an MSan job in 2024 but did not build multiprocess in it
until bitcoin#30975 turned
multiprocess on for most CI jobs; the failure appeared on that PR's first run, five
years after the order was set and with every connection teardown in every ipc_tests
run having hit it silently.
What made it hard to debug?
Nothing, once seen. The MSan report named the line in addAsyncCleanup and its second
stack ("Member fields were destroyed") named the destructor that had already run, so the
analysis in mp#115
and a proposed diff came the day the report was picked up, and the fix merged two days
later. The delay was in the reporting: maflcko
quoted the summary line
on 2024-09-26 and it was not acted on until Sjors reposted it with the CI link five days
later, a small cost of the sanitizer running in a repository other than the one that
owned the code.
Lesson
When a member's destructor can call back into its owner, destroy that member first and by hand; implicit reverse-declaration order only protects dependencies that run one way. Run the code that owns your destructors under MSan, because a write into a destroyed member of a still-live object is invisible to everything else.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | ub |
| Worst effect observed | sanitizer-report-only |
| Seen in | unit-test |
| Scope | production |
| Trigger | Clean disconnect (clean-disconnect) |
| Phase | shutdown |
| Side | server |
| Component | connection |
| Root cause kind | ordering |
| Fix kind | local-fix |
| Detection | msan |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | yes |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |