Configure -zmqpubhashblock or -zmqpubrawtx and bitcoind pushes new blocks and transactions out a ZMQ socket; Lightning’s LND needs exactly this to watch the chain. The bug: if that socket can’t bind at startup — port taken, bad address — bitcoind starts anyway, looks healthy, and logs the problem only at debug level.
The bug
The report that started it is issue #33715: the reporter configured ZMQ, another process already held the port, and bitcoind started cleanly anyway. Their subscriber saw nothing, and getzmqnotifications gave no hint. The cause only showed up after restarting with -debug=zmq, which nobody runs in production.
Someone setting up LND pastes two zmqpub* lines into bitcoin.conf, starts both daemons, and gets a bitcoind that reports no problem and an LND that waits forever for a chain backend that never speaks. Nothing points back at the bind error.
History
In 2019, malformed ZMQ config made bitcoind crash — an assert during cleanup, when shutdown ran against a notifier whose socket had never been created (#17185). The fix in #17445 removed the crash and nothing else, so the code kept running and said nothing. promag wrote that it “should fail to start with an error message” — but that part was never built. Six years of silent breakage followed, locked in by a test whose comment read “Invalid zmq arguments don’t take down the node, see #17185.” So this isn’t reversing a 2019 decision; the objection back then was to crashing, and stopping cleanly with an error is what the thread asked for and never got.
The design: splitting one overloaded function
The fix is open as PR #35902.
The root problem is one overloaded return value. The old code called a single function:
// old: parse config + build notifiers + bind sockets, all in one
static std::unique_ptr<CZMQNotificationInterface> Create(callback);
It returned nullptr in two different situations: the user never configured ZMQ (fine), or they configured it and socket setup didn’t work (should be fatal). The caller can’t abort on the second case without also aborting nodes that don’t use ZMQ, so it does nothing — and doing nothing is the bug.
The fix splits it in two so the cases can’t be confused:
// "what did the user ask for?" — pure config parsing, cannot error
static std::list<std::unique_ptr<CZMQAbstractNotifier>> GetNotifiers(const ArgsManager& args, callback);
// "did it work?" — start the ZMQ context, bind every socket; can error
static std::unique_ptr<CZMQNotificationInterface> Create(std::list<...>&& notifiers);
Now init.cpp asks two questions, and aborting is correct exactly when the answers are yes, then no:
auto notifiers{CZMQNotificationInterface::GetNotifiers(gArgs, ...)};
if (!notifiers.empty()) { // user asked for ZMQ
if (auto iface{CZMQNotificationInterface::Create(std::move(notifiers))}) {
g_zmq_notification_interface = std::move(iface);
} else {
return InitError(Untranslated("Initializing ZMQ interface failed.")); // asked and denied → stop
}
}
The bind error now travels up to InitError and logs at error level, visible by default. On the error path, the half-built interface is a local unique_ptr inside Create, so returning nullptr runs its destructor, which closes the sockets that did bind and skips the one that never opened — the exact safe-skip whose absence crashed bitcoind in 2019.