Bug 1
Process spawning leaks a socket if closing the child's end fails
- Status
- Fixed
- Symptom
- File descriptor leak; worst effect seen: nothing observed
- Where seen
- Never observed
- Trigger
- Starting a child process (fork and exec)
- First reported
- 2026-01-09 (first report)
- Fixed
- 2026-01-13
- Fix
- mp#237
- Shipped to Bitcoin Core
- bitcoin#34363
- In released binaries
- Yes (v30.0-v30.3)
- Defect ever on master
- Yes
Summary
If SpawnProcess() failed to close the child's end of its socketpair in the parent process, it threw without closing the parent's end, leaking a file descriptor. close() on a freshly created socket essentially never fails, so this was never observed in practice. Found in code review and fixed in mp#237 ("Made SpawnProcess() behavior safe post fork()"). In Bitcoin Core releases through v30.x the only caller of SpawnProcess is the hidden echoipc test RPC.
What happened
Never observed at runtime. ryanofsky spotted it (2026-01-09) while reviewing mp#237, @Sjors's fix for a deadlock in spawned child processes: "pre-existing bug here where if close(fds[0]) fails in the parent fd[1] will not be closed either and could be leaked". Sjors fixed it in a separate commit of the same PR.
Root cause
SpawnProcess() in src/mp/util.cpp creates a socketpair, forks, and then closes one end in each process: the parent closes fds[0] (the child's end) and keeps fds[1], the child closes fds[1]. Both used one statement:
if (close(fds[pid ? 0 : 1]) != 0) {
throw std::system_error(errno, std::system_category(), "close");
}
In the parent, a failure threw before fds[1] was returned to the caller, so nothing ever closed it. The code has had this error path since SpawnProcess was added.
The code
SpawnProcess in src/mp/util.cpp, quoted from 30a8681de626^:
int SpawnProcess(int& pid, FdToArgsFn&& fd_to_args)
{
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
throw std::system_error(errno, std::system_category(), "socketpair");
}
pid = fork();
// ...
// Parent process closes the descriptor for socket 0, child closes the descriptor for socket 1.
if (close(fds[pid ? 0 : 1]) != 0) {
throw std::system_error(errno, std::system_category(), "close");
}
// ...
return fds[1];
}
In the parent, fds[1] is the caller's end of the socket and is only handed over by the final return. If closing the child's end fds[0] fails, the function throws first, and nothing else holds fds[1], so the descriptor stays open for the life of the process.
The fix (30a8681de626) closes it on that path:
@@ -128,6 +128,7 @@ int SpawnProcess(int& pid, FdToArgsFn&& fd_to_args)
// Parent process closes the descriptor for socket 0, child closes the descriptor for socket 1.
if (close(fds[pid ? 0 : 1]) != 0) {
+ if (pid) (void)close(fds[1]);
throw std::system_error(errno, std::system_category(), "close");
}
The parent now releases both socket descriptors before throwing. The child side of this path is left alone here; a companion commit makes the child report the error and _exit instead of throwing.
Fix
The fix commit 30a8681de626 (Sjors, co-authored by ryanofsky) adds if (pid) (void)close(fds[1]); before the throw (+1). The companion commit in the same PR changes the child side of this path to write a message and _exit instead of throwing.
Timeline
- 2019-08-06: present since f324c666150b ("Add Connnect/Serve/Spawn/Wait functions"), merged in mp#8.
- 2026-01-09: found by ryanofsky in review of mp#237.
- 2026-01-13: fix merged in mp#237.
- 2026-01-21: shipped to Bitcoin Core master in the subtree update bitcoin#34363; not backported, so v30.x binaries have the code.
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 different API shape
- Earliest detector code review (existed then)
- Diagnosis easy
- Fix design easy
- Rust partly
Would Rust have prevented it? Partly. An OwnedFd would close the socket when the error return unwinds, so the leak disappears with idiomatic types, but that is RAII the C++ could have used too; leaking a resource is not a compile error in Rust and raw fds from libc stay raw. More on Rust
Could it have been prevented?
Yes, by holding the two socketpair descriptors in an RAII owner from the moment
socketpair() returns, so that any throw between creation and the final return fds[1]
closes whatever has not been handed over. SpawnProcess() kept them as a raw int
fds[2] and threw std::system_error from the middle of the function, which is how a
failed close(fds[0]) in the parent leaked fds[1]
(f324c666150b, 2019).
The one-line fix (30a8681de626)
closes the other end on that path by hand, and the same shape leaked again on the
fork() == -1 path until mp#312
closed both ends there seven months later; a wrapper would have covered both at once.
Could it have been found sooner?
Only by reading the code, which is how it was found: ryanofsky
noticed it
while reviewing the post-fork rewrite of the same lines, six and a half years after they
were written. close() on a fresh socket does not fail in practice, so no test or
sanitizer would ever take the path, and LeakSanitizer does not track descriptors. A
static checker that follows resource ownership across throw edges could flag it, but
the project runs none, and the value of catching it is small.
What made it hard to debug?
Nothing; it was never observed. The only judgement was whether to fix it at all, which ryanofsky's own comment hedged ("if one close fails, not sure what chances of other one succeeding could be"), and Sjors fixed it in a separate commit in the same PR. It is in the ledger because the survey asked for leaks and borderline cases.
Lesson
When a function creates two resources and throws in between, the error path needs the
same care as the success path; put the resources in an owner whose destructor releases
them, and every future throw in that function is covered without anyone remembering to
add a close.
Notes
- One error path still leaked after this fix: when
fork()itself failed,SpawnProcess()threw without closing either end of the socketpair. 4a56c1837a78 in mp#312 ("report back child errors to parent and throw", merged 2026-08-11) closes both before throwing.
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 | none |
| Seen in | never |
| Scope | production |
| Trigger | Starting a child process (fork-exec) |
| Phase | any |
| Side | spawn |
| Component | spawn |
| Root cause kind | resource-leak |
| Fix kind | one-line-tweak |
| 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 |