Bug 78
Listener at its connection limit stops accepting after a local disconnect
- Status
- Open, fix under review
- Symptom
- service-loss; worst effect seen: hang
- Where seen
- Libmultiprocess tests (mptest)
- Trigger
- The process closes its own connection while the peer is still there
- First reported
- 2026-08-20 (first report)
- Fixed
- Not yet
- Fix
- mp#361
- In released binaries
- No
- Defect ever on master
- Yes
Summary
A ListenConnections listener that had reached its max_connections limit stopped accepting new connections permanently if the server then closed one of its connections locally, because the active-connection counter was only decremented on a remote disconnect. New clients would wait forever. It was found in code review and shown with a regression test; it has never been observed in practice. Bitcoin Core cannot trigger it today, since it only closes IPC connections locally at shutdown, and no release contains the connection limit. A fix is in mp#361, which fixes several disconnect bugs found in review, not yet merged.
What happened
- @enirox001, reviewing the
Connection::disconnect()commit of mp#335, which adds methods to close a connection and wait for its calls to finish (2026-08-20), noticed that the per-listener connection counter that mp#269 added to enforcemax_connectionsis decremented by a callback that a local disconnect cancels. He supplied a fix and the regression test "ListenConnections resumes after a local disconnect" (test/mp/test/listen_tests.cpp:max_connections=1, erase the first connection fromm_incoming_connections, wait for the second client to be accepted), which hangs before the fix and passes after. - ryanofsky replied that it was a separate pre-existing bug, not a practical problem for Bitcoin Core, and moved it into mp#361 (2026-09-04).
Root cause
_Serve (include/mp/proxy-io.h) called the listener's on_disconnect bookkeeping callback (the counter decrement) inside the it->onDisconnect(...) handler, together with the m_incoming_connections.erase(it) that removes the connection after a remote disconnect. Connection::onDisconnect handlers are cancelled when the Connection is destroyed locally. After a local close the erase is correctly skipped, but the counter decrement is skipped too, leaving ListenConnections at its limit.
The counter was written with only remote disconnects in mind, because that is the only way connections went away in the tests and in Bitcoin Core, which closes clients locally only in Ipc::disconnectIncoming() at shutdown, when accepting again no longer matters. mp#335 makes local disconnection an explicit operation with Connection::disconnect(). Other servers built on the library that close connections themselves could stop accepting.
Fix
No fix merged yet. 33bd6f833f5a in mp#361 (co-authored by enirox001 and Claude) registers the bookkeeping with it->addSyncCleanup(on_disconnect) instead. Sync cleanup functions run from the Connection destructor on both local and remote disconnects, so the slot is always released. The addSyncCleanup comment in src/mp/proxy.cpp is extended to say these callbacks may also update listener bookkeeping. enirox001 ACKed the PR.
Next steps
- Review and merge mp#361. Its commit 33bd6f833f5a releases the listener slot through
addSyncCleanup, with enirox001's test "ListenConnections resumes after a local disconnect". enirox001 ACKed an earlier version on 2026-09-09. The PR has been pushed twice since, so it needs a fresh ACK and more review. - Merge it before mp#335, which is stacked on it and makes local disconnection an explicit operation.
- After it is merged, update Bitcoin Core's libmultiprocess subtree. Bitcoin Core cannot trigger this bug today, so this can wait for the next routine update.
Timeline
- 2026-07-08: introduced by 39a10ce8958e ("proxy: add local connection limit to ListenConnections()"), merged in mp#269; in Bitcoin Core's subtree from 2026-07-10 (bitcoin#35684).
- 2026-08-20: found by @enirox001 in mp#335 review.
- 2026-09-04: mp#361 opened with the fix; open as of 2026-09.
- Not in any release.
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 Error paths that skip cleanup or completion: Cleanup on only one of two exit paths
- Prevented by a clear ownership model, a different API shape
- Earliest detector a unit test (did not exist then)
- Diagnosis easy
- Fix design easy
- Rust no
Would Rust have prevented it? No. Choosing the hook that fires on only one of two close paths is a logic error with no Send, Sync or lifetime violation; a Drop guard on the slot would fix it in either language, and nothing in Rust forces that design. More on Rust
Could it have been prevented?
Yes. The listener's slot is a resource acquired at accept time, and the fix moves its
release from an event handler to addSyncCleanup, which runs from the Connection
destructor on any close. Had the slot been an RAII guard owned by the Connection,
there would have been no hook to choose and no path to miss. The API also made the
wrong choice easy:
39a10ce8958e
(mp#269) put the decrement
next to the m_incoming_connections.erase(it) inside onDisconnect, whose comment said
it is "run when the peer closes the connection" and cancelled on a local close, so the
information was there but the method name does not say "remote". A name like
onRemoteDisconnect, or a single "on any close" hook as the documented place for
bookkeeping, would have steered the author. The fix's own comment in
33bd6f833f5a
now records why the decrement lives where it does.
Could it have been found sooner?
Yes, by a ListenConnections test with max_connections=1 that closes the first
connection locally and waits for a second client, which is enirox001's regression test.
mp#269's tests covered the limit only through remote disconnects, because that was the
only way a connection went away in the tests and in Bitcoin Core, which closes clients
locally only in Ipc::disconnectIncoming() at shutdown, when accepting again no longer
matters. It was found six weeks after the code landed, by
reading
the mp#335 commit that made
local disconnection an explicit operation, which is the first time the missing path
mattered.
What made it hard to debug?
Nothing. The report came with the mechanism, a fix and a test that hangs before and passes after; ryanofsky's reply agreed it was a separate pre-existing bug and not a practical problem for Bitcoin Core, and the 14-line fix went into mp#361 as written. Had it reached a server that does close connections itself, the symptom would have been a listener that silently stops accepting, with nothing logged; a periodic log of the active count against the limit, or a count exposed to tests, would have made that state visible.
Lesson
Release a counted resource from the path that runs on every exit, normally a destructor, not from a handler that fires on one kind of exit; and when an API offers two close hooks, name them for what they exclude.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | service-loss |
| Worst effect observed | hang |
| Seen in | mptest |
| Scope | production |
| Trigger | Process closes its own connection (local-disconnect) |
| Phase | steady-state |
| Side | server |
| Component | proxy-io |
| Root cause kind | assumption-violated |
| Fix kind | local-fix |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | no |
| Confidence | high |