Audit report AR-79
Reported privately in an independent security review, 2026-10-15.
- Report date
- 2026-10-15
- Kind
- Bug
- Status on master
- Present
- Proposed issue state
- Open
- Linked bugs
- Bug 88: IPC listener stops for good after one temporary accept failure
Summary
If an IPC listener's accept() fails once, even with a temporary error such as running out of file descriptors (EMFILE), libmultiprocess stops accepting for good: no new client is served until the process restarts. New clients hang while other IPC connections are open, and are refused once those close and the listener is freed. The report is accurate, and its fix (retry only temporary errors, after a short delay) is the right shape. Bug page: IPC listener stops for good after one temporary accept failure.
The report
ListenConnections() schedules one asynchronous accept at a time. If that accept fails with a temporary resource error such as EMFILE, the failed task is logged and removed and nothing schedules another, so a temporary shortage permanently disables the listener even after descriptors become available again.
The reproduction is a Linux unit test in src/ipc/test/ipc_tests.cpp. It lowers the process's descriptor limit, fills the descriptor table, has a child process connect a client, waits until the real accept task reports EMFILE, then frees the descriptors. A plain POSIX listener in the same test sees the same error and accepts its queued client once descriptors are free; the unpatched IPC listener never serves its client, while the fixed one accepts it and answers an Echo request.
The report rates it low. The trigger needs local descriptor exhaustion while an enabled Unix IPC listener is accepting a connection. The node stays up, but that listener stops serving clients until restart. Only local access to an operator-enabled socket is involved, and no remote trigger was demonstrated.
Proposed change
Treat kj "overloaded" accept errors as temporary resource pressure: log the first failure, wait 100 milliseconds, and schedule another accept. Reset the logged state after a successful connection so a later failure is logged again. Any other error still ends the listener, so permanent configuration or programming errors cannot cause an endless retry loop.
- loop.m_task_set->add(receiver->accept().then(
+ auto accept = receiver->accept().then(
[&loop, &init, listener](kj::Own<kj::AsyncIoStream>&& stream) {
+ listener->m_accept_failure_logged = false;
...
- }));
+ });
+ loop.m_task_set->add(accept.catch_(
+ [&loop, &init, listener](kj::Exception&& exception) -> kj::Promise<void> {
+ if (exception.getType() != kj::Exception::Type::OVERLOADED) return kj::mv(exception);
+ if (!listener->m_accept_failure_logged) {
+ listener->m_accept_failure_logged = true;
+ MP_LOG(loop, Log::Warning) << "IPC listener accept hit a temporary resource limit. Retrying after 100 ms.";
+ }
+ return loop.m_io_context.provider->getTimer().afterDelay(100 * kj::MILLISECONDS).then([&loop, &init, listener] {
+ _Listen<InitInterface>(listener, loop, init);
+ });
+ }));
The report's version is built on the proposed listener-stop change (AR-21) and also checks that the listener has not been stopped before and after the delay; the excerpt above is the standalone form. The report considered retrying every accept error (rejected: it could loop forever on a permanent failure) and recreating the listener in Bitcoin Core (rejected: it would duplicate libmultiprocess state).
Assessment
Accurate, with one correction. On libmultiprocess master, _Listen() schedules the next accept only after a successful one, and a failed accept goes to the event loop's catch-all handler for background tasks, which logs "Uncaught exception in daemonized task" and drops it. The report says the listening socket stays valid. That is true while other connections are open, because each holds a reference to the listener for its disconnect handling, so new clients wait in the kernel's queue and are never accepted. When the last of them closes, or right away if none are open, the listener is freed and its socket closed, and clients are refused at once. (An earlier version of this page said the socket always closes; a check in the review's 2026-10-27 reclassification pointed out the references held by open connections.) kj reports EMFILE, ENFILE, ENOBUFS and ENOMEM from accept() as "overloaded", so retrying only that error type is the right filter.
The approach is consistent with the earlier decision in the review of mp#310 not to retry every failed accept: the concern there was an immediate retry that would spin on permanent errors and on resource exhaustion, and a delayed retry limited to temporary errors does neither. bitcoin#35037 (merged 2026-10-08) counts IPC listeners and connection slots in the node's file descriptor budget, so running out in bitcoin-node now needs a descriptor leak or other open files; other users of libmultiprocess have no such budget.
The report's test is far larger than needed: it changes process-wide descriptor limits inside the multithreaded test_bitcoin and forks and re-executes the test binary. A 20-line libmultiprocess test that wraps the listening socket so its first accept() fails with an "overloaded" error reproduces it on every platform. It fails at once without the fix and passes with it. ryanofsky, replying to the report, agreed with the fix, suggested the smaller test and dropping the listener-stop dependency, and said a public libmultiprocess issue would follow.
Status
Present on master. Next step: a libmultiprocess issue or PR with the standalone fix and the small test. No public issue or PR yet. A 2026-10-27 reclassification in the review kept the severity at low: the listener is opt-in and local, one service is affected, and a restart recovers it.
Reported privately in an independent security review on 2026-10-15.