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, reports no error, 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 indefinitely for a chain backend that never sends notifications. 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 node kept running without reporting the failure. promag wrote that it “should fail to start with an error message”, but that part was never built. The behavior persisted for six years, held in place by a test whose comment read “Invalid zmq arguments don’t take down the node, see #17185.” The 2019 objection was to crashing. Stopping cleanly with an error message was requested in that thread and never implemented.
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 the bind failure goes unreported.
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);
init.cpp now checks the two conditions separately. Aborting is correct exactly when the user configured ZMQ and setup then failed:
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.