Bug 97
Failed IPC startup leaks the socket descriptor and the spawned child
- Status
- Open
- Symptom
- File descriptor leak; worst effect seen: test failure only
- Where seen
- Bitcoin Core unit tests
- First reported
- Reported privately in an independent security review, 2026-09-27
- Fixed
- Not yet
- In released binaries
- Yes (v30.0 onward)
- Defect ever on master
- Yes
Summary
When Bitcoin Core's IPC setup fails partway, it can leave resources behind. If starting the IPC event loop fails after a socket has been opened, the socket's file descriptor is never closed, and if it fails after a child process has been spawned, the child keeps running and is never waited for. It was reported privately in an independent security review, with tests that inject the failures. On master the effect is small: the callers that can hit it either exit after the error or are a test-only RPC, so this is recorded as a low-confidence bug. No fix is merged yet.
What happened
Nothing has been seen in a deployment. An independent security review reported the descriptor leak privately on 2026-09-27 and later extended the report to spawned child processes. Its unit tests inject a failure into event-loop startup and check that the descriptors are closed afterwards, and a Linux-only test makes thread creation really fail just after a child has been forked, then checks for leaked descriptors and an unreaped child. The report says the unfixed code left the descriptors open. Those runs have not been repeated independently.
Root cause
The IPC code passes raw resources from one layer to the next before any object is responsible for them. CapnpProtocol::listen() and makeStream() in src/ipc/capnp/protocol.cpp receive a raw socket descriptor and call startLoop() before handing the descriptor to libmultiprocess. If starting the loop throws, or the listen(2) call in listen() fails, the error goes to the caller and nothing closes the descriptor.
IpcImpl::spawnProcess() in src/ipc/interfaces.cpp forks the child first and then sets up the connection to it. The code that waits for the child to exit is attached to the client object that the setup returns. If setup throws, there is no client object, so nothing kills or waits for the child; and because the parent's end of the socket also leaked, the child stays alive, waiting for requests, until the parent exits.
On master, startLoop() mainly throws when the operating system refuses to create the loop thread. A failure inside the new thread aborts the process instead (a separate bug), and fixing that bug makes these leaks reachable more often. The leaks have little effect today: if bitcoin-node -ipcbind fails here the node exits anyway, bitcoin-cli exits after its one command, and spawning a child is only used by the hidden, test-only echoipc RPC.
Fix
No fix merged yet. The fix proposed with the report closes the descriptor on each failure path, and in spawnProcess() kills and reaps the child before passing the original error on:
- auto init = m_protocol->connect(m_protocol->makeStream(socket));
- ...
- return init;
+ try {
+ auto init = m_protocol->connect(m_protocol->makeStream(socket));
+ ...
+ return init;
+ } catch (...) {
+ const std::exception_ptr setup_error{std::current_exception()};
+ // No Init proxy exists to own normal child cleanup
+ (void)::kill(pid, SIGKILL);
+ ... wait for the child, retrying if interrupted ...
+ std::rethrow_exception(setup_error);
+ }
The same report also closes the socket pairs that libmultiprocess's SpawnProcess() keeps when building the child's arguments throws; that part was already tracked as a hardening item from an earlier review (AR-12).
Next steps
- Take the descriptor and child cleanup in Bitcoin Core together with the fix for the startup abort, which uses the same test hook.
- Send the
SpawnProcess()part to libmultiprocess with the other spawn error-handling fixes. - Prefer in-process tests with an injected failure over the report's Linux-only test, which wraps
fork()for the wholetest_bitcoinbinary.
Timeline
- 2021-04-27: present since the IPC implementation was added in bitcoin#19160 ("multiprocess: Add basic spawn and IPC support"); the
listen()path since bitcoin#30509 (2024-09-09). - 2026-09-27: descriptor leak reported privately in an independent security review, with fault-injection tests; later extended to spawned children.
- Releases: v30.0 and later ship
bitcoin-nodewith-ipcbindandechoipc.
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 Error paths that skip cleanup or completion: A throw skips cleanup
- Prevented by a clear ownership model
- Earliest detector a unit test (did not exist then)
- Diagnosis easy
- Fix design easy
- Rust partly
Would Rust have prevented it? Partly. An owned descriptor type (OwnedFd) closes itself when an error unwinds past it, which removes the descriptor leak; a spawned child is not killed or reaped when a std::process::Child is dropped, so the child leak would remain unless the code wrapped it in a guard. More on Rust
Could it have been prevented?
Yes, with an ownership rule: a resource that needs cleanup is owned by an object from
the moment it is created. CapnpProtocol and IpcImpl::spawnProcess() pass raw socket
descriptors and a raw process id through several steps, and only the last step creates
the object that closes or waits for them, so any failure in between leaks. A move-only
descriptor type, or a small guard object for the child, would make the cleanup
automatic; the report's fix adds the missing close() and kill()/wait calls by hand.
Could it have been found sooner?
Yes, by a unit test that makes loop startup fail and checks the descriptor is closed. No such test existed, because loop startup was assumed not to fail, and on master the callers exit or are a test RPC, so nobody would notice a leaked descriptor or child. An independent security review found it by injecting failures into the startup path.
What made it hard to debug?
Nothing in particular; the failure is silent. A leaked descriptor or a stray child process only shows up when someone counts descriptors or looks at the process table, which is why it needed a test that checks for them directly.
Lesson
Give a resource an owner as soon as it exists: if a raw descriptor or process id has to pass through code that can throw before reaching the object that cleans it up, every one of those failures is a leak.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | fd-leak |
| Worst effect observed | test-failure-only |
| Seen in | unit-test |
| Scope | production |
| Trigger | other (other) |
| Phase | startup |
| Side | both |
| Component | bitcoin-ipc |
| Root cause kind | resource-leak |
| Fix kind | none |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | low |