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

Timeline

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