Bug 96

Failed startup does not exit while a mining client is waiting

Status
Open
Symptom
Hang; worst effect seen: hang
Where seen
Bitcoin Core functional tests
Trigger
Node startup
First reported
Reported privately in an independent security review, 2026-09-27
Fixed
Not yet
In released binaries
Yes (v30.0 onward)
Defect ever on master
Yes

Summary

If bitcoind or bitcoin-node fails to start while an IPC mining client is waiting inside the node, for example in makeMining for the chain to load, the node logs "Shutdown done" and then stays running until it is killed. With -daemon, the stuck process also keeps the data directory locked, so a corrected restart fails. The cause is that the startup-failure path never raises the shutdown signal that every mining wait checks. It was reported privately in an independent security review, with a functional test. No fix is merged yet.

What happened

Nothing has been seen in a deployment. An independent security review reported it privately on 2026-09-27. Its functional test replaces peers.dat with a named pipe, so startup blocks after the IPC socket is listening, waits until the node is executing the client's makeMining request, and then writes a corrupt peers.dat so startup fails. Without the fix the node logs "Shutdown done" and does not exit. The report also describes earlier runs, not repeated on its final branch: the same hang after a later -bind failure with a client waiting in waitTipChanged, and a -daemon run in which the parent had already reported a successful start while the failed child kept the data directory lock.

Root cause

main in src/bitcoind.cpp, which bitcoin-node also uses, ends like this:

if (!AppInit(node) || !Assert(node.shutdown_signal)->wait()) {
    node.exit_status = EXIT_FAILURE;
}
Interrupt(node);
Shutdown(node);

When AppInit fails, nothing raises the shutdown signal. The IPC mining waits treat that signal as their only shutdown condition. Since bitcoin#34661, makeMining waits for the chainstate to load or for the signal. waitTipChanged, waitNext and the stages of createNewBlock check the chain manager's interrupt flag, which is the same signal. Interrupt() wakes these waiters, but each finds nothing changed and waits again.

Shutdown() then closes the IPC connections, but the worker thread is still inside node code. The parked request keeps the event loop alive, and the IPC teardown that runs after "Shutdown done" waits forever for the event loop thread to finish.

This is the mirror image of an earlier bug where Ctrl-C did not stop a node with a waiting mining client. There the signal was raised but nothing woke the waiters; here the waiters are woken but the signal is never raised. The GUI does not have this problem, because its startup failure path calls startShutdown(), which raises the signal.

Fix

No fix merged yet. The fix proposed with the report raises the shutdown signal when AppInit fails, before Interrupt() runs. The woken waits then see it and return, makeMining returns no interface, and the process exits with status 1. Because every mining wait checks the same signal, this one change covers all of them.

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 Locks, waits and wakeups: Wakeup without interrupt

  • Prevented by a design rule
  • Earliest detector startup test (did not exist then)
  • Diagnosis moderate
  • Fix design easy
  • Debugging aids logging, symbolized stack traces
  • Rust no

Would Rust have prevented it? No. Whether a shutdown path sets the flag that waiters check is control flow; a Rust program with a Condvar and an AtomicBool can skip the store on one path just the same. More on Rust

Could it have been prevented?

Yes, with one rule: every shutdown path goes through the same "request shutdown" step that sets the flag waiters check, and only then wakes them. Bitcoin Core had three paths: the stop RPC, signals (Ctrl-C, SIGTERM), and a failed start. The first two were brought together by bitcoin#33511, which moved the wake-up into Interrupt(). The failed-start path in bitcoind.cpp calls Interrupt() without ever raising the signal. Raising it inside Interrupt() would make the paths unable to differ.

Could it have been found sooner?

Yes, by a startup-failure test with an IPC client waiting. The functional tests cover failed starts (feature_init.py) and early IPC connections (since bitcoin#34661), but not both together. The reporter's test needed a named pipe in place of peers.dat to hold startup at the right moment, which is the main difficulty.

What made it hard to debug?

The process looks finished: it logs "Shutdown done", and only a thread dump shows the worker parked in the wait and the main thread joining the event loop. That trace was already known from bitcoin#33511, which makes this one quicker to recognize. In daemon mode the visible symptom is a later "cannot obtain a lock" error from a fresh start, far from the cause.

Lesson

Waking waiters is only half of a shutdown; the condition they wait for must also become true on every exit path, including a failed start, or the wake-up does nothing.

Classification

The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.

Field Value
Symptom hang
Worst effect observed hang
Seen in functional-test
Scope production
Trigger Node startup (node-startup)
Phase startup
Side server
Component bitcoin-init
Root cause kind missing-cancellation
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 high