Bug 39

Ctrl-C kills the spawned wallet process before the node can shut it down

Status
Fixed
Symptom
Crash; worst effect seen: crash
Where seen
Branch-build
Trigger
A signal such as Ctrl-C
First reported
2020-03-05 (first report)
Fixed
2025-08-18
Fix
bitcoin#32345
In released binaries
No; the code was in source releases only
Defect ever on master
Yes

Summary

On the unmerged multiprocess branch where bitcoin-node spawns a bitcoin-wallet process, pressing Ctrl-C in the terminal also sent SIGINT to the spawned wallet, which had no handler for it and died at once. That broke the node's orderly shutdown, in which the node tells the wallet to flush and stop, and made bitcoin-node abort. The missing handler was on master from 2021 and in releases v22 through v29, but no release was affected in practice: the only process spawned on master is the short-lived echoipc child. Fixed by bitcoin#32345 ("ipc: Handle unclean shutdowns better"), which makes spawned processes ignore Ctrl-C and wait for the parent.

What happened

Testers of the bitcoin#10102 ("Multiprocess bitcoin") branch saw bitcoin-node abort on Ctrl-C: @hebasto in 2020 (comment), @Sjors in 2023 (comment), and @darosior in December 2024 (comment, which ryanofsky moved to a new issue, mp#123, "bitcoin-node segfaults when interrupted"). In 2020 ryanofsky replied that the spawned processes had no signal handlers, so Ctrl-C killed them immediately. In December 2024 he reproduced it on mp#123 with bitcoin-node -regtest: SIGTERM sent to the node alone shut down cleanly, while Ctrl-C "reaches the bitcoin-wallet process as well as bitcoin-node process causing the IPC connection to be broken as the node is shutting down".

Root cause

bitcoin-node starts bitcoin-wallet with fork/exec in the same process group, so a terminal SIGINT is delivered to both processes. A spawned process enters IpcImpl::startSpawnedProcess (src/ipc/interfaces.cpp) and serves IPC requests from the start of main, before the point where Bitcoin Core normally installs its SIGINT/SIGTERM handlers, so SIGINT had its default action and terminated the wallet process immediately. The node, meanwhile, was in the middle of its own shutdown and still about to call flush() and stop() on the wallet over IPC. Those calls hit a broken connection, and the node aborted on the uncaught exception, which is a separate bug (Shutdown() did not catch IPC errors from chain client calls).

The code

Quoted from 216099591632^. A spawned IPC process such as bitcoin-wallet enters IpcImpl::startSpawnedProcess (src/ipc/interfaces.cpp) early in main and serves requests from the parent until the connection closes:

bool startSpawnedProcess(int argc, char* argv[], int& exit_status) override
{
    exit_status = EXIT_FAILURE;
    int32_t fd = -1;
    if (!m_process->checkSpawned(argc, argv, fd)) {
        return false;
    }
    m_protocol->serve(fd, m_exe_name, m_init);
    exit_status = EXIT_SUCCESS;
    return true;
}

Bitcoin Core installs its SIGINT handler only in AppInitBasicSetup (src/init.cpp), which a spawned process never reaches:

// Clean shutdown on SIGTERM
registerSignalHandler(SIGTERM, HandleSIGTERM);
registerSignalHandler(SIGINT, HandleSIGTERM);

The spawned wallet is in the node's process group, so Ctrl-C in the terminal sends SIGINT to both:

  1. Wallet: SIGINT has its default action, and the process dies at once, closing its end of the IPC socket.
  2. Node: HandleSIGTERM starts an orderly shutdown, and Shutdown() then calls flush() and stop() on the wallet's chain client proxy.
  3. Node: those calls fail on the closed connection and throw, and the node aborts (tracked in its own issue).

The fix installs a SIGINT handler in the spawned process before it starts serving (216099591632; the #include is omitted):

@@ -26,6 +27,28 @@
+#ifndef WIN32
+std::string g_ignore_ctrl_c;
+
+void HandleCtrlC(int)
+{
+    // (void)! needed to suppress -Wunused-result warning from GCC
+    (void)!write(STDOUT_FILENO, g_ignore_ctrl_c.data(), g_ignore_ctrl_c.size());
+}
+#endif
+
+void IgnoreCtrlC(std::string message)
+{
+#ifndef WIN32
+    g_ignore_ctrl_c = std::move(message);
+    struct sigaction sa{};
+    sa.sa_handler = HandleCtrlC;
+    sigemptyset(&sa.sa_mask);
+    sa.sa_flags = SA_RESTART;
+    sigaction(SIGINT, &sa, nullptr);
+#endif
+}
@@ -53,6 +76,7 @@ public:
+        IgnoreCtrlC(strprintf("[%s] SIGINT received — waiting for parent to shut down.\n", m_exe_name));
         m_protocol->serve(fd, m_exe_name, m_init);

The wallet process now only prints a line on Ctrl-C and keeps serving, so the node's flush() and stop() calls reach it, and it exits when the node closes the connection.

Fix

Commit 216099591632 ("ipc: Add Ctrl-C handler for spawned subprocesses") installs a SIGINT handler (IgnoreCtrlC, non-Windows) in IpcImpl::startSpawnedProcess before serve(). The handler only writes "[bitcoin-wallet] SIGINT received — waiting for parent to shut down." to stdout, so the child keeps serving until the parent closes the connection and shuts it down in order (+24/-0).

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 Process spawning, file descriptors and signals: Ctrl-C reaches the child process

  • Prevented by a design rule, a written spec or documentation
  • Earliest detector a test that kills a process (did not exist then)
  • Diagnosis moderate
  • Fix design easy
  • Debugging aids a reproduction script, logging
  • Rust no

Would Rust have prevented it? No. Terminal process groups and the default SIGINT disposition are OS behaviour a Rust child process inherits identically; the fix is a signal handler or setpgid either way. More on Rust

Could it have been prevented?

Yes. The spawned-process model gives the parent control of the child's lifetime, but a child started with fork/exec stays in the terminal's process group, so Ctrl-C reaches both, and the child served requests from the top of main without ever reaching the code that installs Bitcoin Core's signal handlers. Default SIGINT then killed the wallet under a node that was about to call it. The rule is "a process whose shutdown is driven by another process must install its own signal handling before it starts serving", and the fix (216099591632) is that: a SIGINT handler that prints one line and keeps serving. ryanofsky knew this in 2020 ("the spawned processes don't have signal handlers yet"), so it was a deferred known gap rather than a surprise; the alternative, setpgid in the child, would also have worked.

Could it have been found sooner?

It was seen three times before it was fixed (2020, 2023, 2024), each time by a person pressing Ctrl-C on the branch. A functional test that sends SIGINT to the whole process group of a node with a spawned wallet and checks both exit cleanly would have made it a CI failure instead of a recurring report; none existed, and the fix added none (the PR's node_init_tests.cpp harness is in-process). The bitcoin#26606 kill test was the closest, and it kills the node, not the group.

What made it hard to debug?

The symptom belonged to another bug: what testers saw was the node's uncaught ipc::Exception (the Shutdown() catch record), and Sjors' 2023 report could not be reproduced. The signal path was only pinned down when darosior's 2024 report let ryanofsky compare SIGTERM to the node alone with Ctrl-C in the terminal, and step through it with gdb's signal SIGINT (mp#123). The difference between "the node was signalled" and "both processes were signalled" is invisible in the node's log; a line from the child saying it received SIGINT, which is exactly what the fix's handler prints, would have given it away in 2020.

Lesson

A child process inherits its parent's terminal signals; if the parent is supposed to drive the child's shutdown, the child must decide what a signal means before it starts work, and it should say so in its output.

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 crash
Seen in branch-build
Scope production
Trigger Signal (Ctrl-C) (signal)
Phase shutdown
Side server
Component bitcoin-ipc
Root cause kind ordering
Fix kind new-code-path
Detection manual-test
Needs a peer that is not libmultiprocess C++ no
Needed a sanitizer to see no
Defect ever on master yes
Released source
Confidence high