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
- 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:
- Wallet: SIGINT has its default action, and the process dies at once, closing its end of the IPC socket.
- Node:
HandleSIGTERMstarts an orderly shutdown, andShutdown()then callsflush()andstop()on the wallet's chain client proxy. - 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
- 2020-03-05: first sighting by hebasto on bitcoin#10102, which carried the spawned-process code before it reached master.
- 2021-04-27: the spawned-process serve path with no SIGINT handling merged to master in commit 10afdf0280fa ("multiprocess: Add Ipc interface implementation"), part of bitcoin#19160 ("multiprocess: Add basic spawn and IPC support"); the long-lived wallet child that exposes it exists only on bitcoin#10102.
- 2023-05-05: sighting by Sjors; 2024-12-03: report by darosior, moved to mp#123.
- 2025-08-18: fix merged in bitcoin#32345.
- Releases v22 to v29 contain the code but were not affected in practice.
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 |