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

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: 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