Bug 91
Signal during spawned-process cleanup can terminate the parent
- Status
- Open
- Symptom
- Crash; worst effect seen: test failure only
- Where seen
- Bitcoin Core unit tests
- Trigger
- A signal such as Ctrl-C
- First reported
- Reported privately in an independent security review, 2026-08-07
- Fixed
- Not yet
- In released binaries
- Yes
- Defect ever on master
- Yes
Summary
When an IPC parent waits for a spawned child process to exit, libmultiprocess's WaitProcess calls waitpid once and treats every failure as final, including EINTR, which only means a signal handler interrupted the call. In Bitcoin Core that wait runs as a cleanup step inside a destructor that cannot throw, so the error terminates the parent process; other callers that catch the error are left with a child that is never reaped. Bitcoin Core's own SIGTERM, SIGINT and SIGHUP handlers can cause the interruption, but the signal has to be handled on the waiting thread, which is rare, and on Bitcoin Core master only a test-only RPC spawns processes. Not seen in a running node. No fix merged yet.
What happened
Nothing has been seen in a deployment. A 2026-08 private audit of libmultiprocess listed the missing retry among minor error-handling gaps in the process helpers (AR-12), without describing an effect. An independent security review reported it privately on 2026-09-27 (AR-66), with a Bitcoin Core unit test that installs a SIGUSR1 handler without SA_RESTART, sends the signal to the thread waiting for a child, and shows the wait failing with EINTR while the child still runs. That test catches the exception; reading Bitcoin Core's real cleanup path shows the exception would terminate the process there.
Root cause
WaitProcess in src/mp/util.cpp is:
int status;
if (::waitpid(pid, &status, /*options=*/0) != pid) {
throw std::system_error(errno, std::system_category(), "waitpid");
}
When a signal arrives during a blocking waitpid and its handler was installed without SA_RESTART, the call returns -1 with errno == EINTR, and the caller is expected to call it again. Bitcoin Core installs its SIGTERM, SIGINT and SIGHUP handlers that way (registerSignalHandler in src/init.cpp).
Ipc::spawnProcess (src/ipc/interfaces.cpp) registers waitSpawned(pid), which calls WaitProcess, as a cleanup step on the client object for the spawned process. Client cleanup steps run from the client's destructor (~ProxyClientBase in include/mp/proxy-io.h), which is noexcept, so an exception escaping it makes C++ call std::terminate. The spawning process aborts.
The report proposes retrying waitpid while it fails with EINTR. libmultiprocess master already does that in the helper that kills and reaps a child after a failed spawn, added in mp#312, but not in WaitProcess. A separate proposal (AR-63) catches exceptions from cleanup steps on the server side; this client-side path is not covered by it.
Fix
No fix merged yet. The proposed change is five lines in WaitProcess:
- if (::waitpid(pid, &status, /*options=*/0) != pid) {
+ int result;
+ do {
+ result = ::waitpid(pid, &status, /*options=*/0);
+ } while (result < 0 && errno == EINTR);
+ if (result != pid) {
Next steps
- Fix it in libmultiprocess together with the other process-helper items in the planned error-handling issue (AR-12), with a spawn test that interrupts the wait.
- Decide whether cleanup steps may throw at all (the question shared by AR-35, AR-63 and AR-75); catching in the shared cleanup runner would also turn this abort into a logged error.
- After it is merged, update Bitcoin Core's libmultiprocess subtree.
Timeline
- 2019-08-06: present since
WaitProcesswas added in f324c666150b ("Add Connnect/Serve/Spawn/Wait functions"), merged in mp#8. - 2021-04-27: reachable in Bitcoin Core through the
echoipctest RPC, added with the multiprocess binaries in bitcoin#19160. - 2026-08-07: missing retry listed in a private libmultiprocess audit (AR-12).
- 2026-09-27: reported privately in an independent security review, with a test (AR-66).
- Releases:
bitcoin-nodebinaries from v30.0 contain it, reachable only throughechoipc.
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: Fallible work in destructor
- Prevented by a design rule
- Earliest detector signal test (did not exist then)
- Diagnosis easy
- Fix design easy
- Debugging aids logging
- Rust partly
Would Rust have prevented it? Partly. Rust's standard library retries EINTR inside Child::wait, so the interruption would not surface; but a raw libc::waitpid call has the same EINTR contract, and an error in Drop has nowhere to go either, so the destructor half of the lesson still applies. More on Rust
Could it have been prevented?
Yes, with two common rules. First, any blocking system call in a library that runs
inside someone else's process must retry EINTR, because the library cannot know
which signal handlers the host installs or whether they use SA_RESTART;
libmultiprocess already follows this rule in its newer kill-and-reap helper
(mp#312) but not in the
2019 WaitProcess. Second, work that can fail should not run inside a noexcept
destructor: Bitcoin Core attaches the wait to a client object's cleanup list, which
runs from ~ProxyClientBase, so any waitpid error becomes std::terminate.
Could it have been found sooner?
A test that interrupts the wait with a signal finds it at once, as the report's test
does by sending SIGUSR1 to the waiting thread. No spawn test sent signals. Ordinary
tests cannot hit it by chance, because Linux delivers a signal sent to the process to
the main thread, not to the thread blocked in waitpid. A private audit in 2026-08
listed the missing retry from reading the code but did not connect it to the
destructor, which is what makes it a crash rather than a stray zombie.
What made it hard to debug?
Not seen in practice. If it happened, std::terminate from a destructor gives an abort
with a system_error "waitpid: Interrupted system call" message and no sign of which
cleanup step threw; a log line before each cleanup step, or a catch in the cleanup
runner that names the failing step, would point at it directly.
Lesson
Library code that blocks in system calls should retry EINTR instead of assuming the
host uses SA_RESTART, and cleanup that can fail should not run in a destructor that
cannot report the failure.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | crash |
| Worst effect observed | test-failure-only |
| Seen in | unit-test |
| Scope | production |
| Trigger | Signal (Ctrl-C) (signal) |
| Phase | shutdown |
| Side | spawn |
| Component | spawn |
| Root cause kind | unhandled-exception |
| 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 | medium |