fix(asio): don't lose the read wakeup - #882
Merged
Merged
Conversation
got3nks
force-pushed
the
fix/asio-event-latch
branch
from
August 10, 2026 09:10
d7e5247 to
affea64
Compare
got3nks
force-pushed
the
fix/asio-event-latch
branch
from
August 10, 2026 09:19
affea64 to
76a8327
Compare
m_eventPending was a plain bool, set from the ASIO thread and cleared on the main thread, tested and set in separate steps. The damaging interleaving is the ASIO thread seeing the latch set and skipping its post while the main thread is about to clear it: the event it deferred to belongs to the previous delivery, so the bytes it just buffered end up with nothing left to announce them. Captured on a live amulegui/amuled pair with the connection dead and 4.6 kB still unread in the kernel (c = buffered, p = read pending, e = event pending): handleRead(10)[c=10 p=0 e=1] bytes land, latch already set postSkip(2) [c=10 p=0 e=1] post skipped block(8) [c=10 p=0 e=0] main consumes that event, latch clear leaving kernel_unread=4669 event_pending=0 read_pending=0 buffered=10, with no read armed, no event outstanding, and nothing that can recover. Make it atomic and use an acq_rel exchange to post. The exchange makes test-and-set a single step, so exactly one caller wins the right to notify. It also writes unconditionally, so even the skipping path releases the buffer and the cleared m_readPending; EventProcessed acquires that same value, without which the reader is still free to observe a stale read state and block on data that has already arrived. Check the wrapper before taking the latch rather than after. With no wrapper nothing delivers a notification, and EventProcessed only runs off a delivered one, so claiming the latch there would leave it set for the life of the socket and make every later post skip -- the same wedge from the other side. HandleRead reaches that state deliberately: it logs "wrapper gone" and carries on to post. Returning early also avoids the window a take-then-release would open, where a concurrent post on the other thread sees the latch set and drops itself.
got3nks
force-pushed
the
fix/asio-event-latch
branch
from
August 10, 2026 09:37
76a8327 to
855a1f3
Compare
got3nks
marked this pull request as ready for review
August 10, 2026 09:41
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The remaining hole in the
CAsioSocketImplread handoff, after #871 closed the visibility one. Observed wedging a live amulegui/amuled pair five times in six hours, each ending in the client's 30-second watchdog reconnect.m_eventPendingwas a plainbool, set from the ASIO thread and cleared on the main thread, tested and set in separate steps:The damaging interleaving is the ASIO thread seeing the latch set and skipping its post, while the main thread is about to clear it. The event it deferred to belongs to the previous delivery, so the bytes it just buffered end up with nothing left to announce them (
c= buffered,p= read pending,e= event pending):Final state:
kernel_unread=4669 event_pending=0 read_pending=0 buffered=10. No read armed, no event outstanding, data on both sides of the boundary, and nothing in the design that can recover it.The fix
Both sides now work on an atomic.
PostReadEventuses anacq_relexchange, which makes test-and-set a single step so exactly one caller wins the right to notify — a plain test-then-set lets both threads see it clear and post twice, or lets the ASIO thread skip on a latch the main thread is about to clear.Less obviously,
exchange(true)writes unconditionally, so even the skipping path releases everything written before it: the filled buffer and the clearedm_readPending.EventProcessedacquires that same value. Without the acquire on the clearing side there is no happens-before edge from the ASIO thread's writes to the reader, and the reader stays free to observe a stalem_readPendingand block on data that has already arrived.PostReadEventalso now checks the wrapper before taking the latch rather than after. With no wrapper nothing delivers a notification, andEventProcessedonly runs off a delivered one, so claiming the latch there left it set for the life of the socket and made every later post skip — the same wedge reached from the other side.HandleReadgets there deliberately: it logs"wrapper gone"and carries on to post. Returning early also avoids the window a take-then-release would open, where a concurrent post on the other thread sees the latch set and drops itself.Scope
CAsioSocketImplis TCP-only and backs eD2k peers, server links, proxies and EC alike, so this is a hot path for every TCP socket rather than an EC-only one. UDP goes throughCAsioUDPSocketImpl, which has neither flag, so Kad is untouched.Testing
Throughput measured on a 30 GB LAN transfer over 2.5 GbE, patched daemon on both ends, against master under the same conditions. Median download rate over T+30…T+90 of a 90 s ramp:
Like for like (both warm) the two are identical: 123.3 vs 123.3. The first row is the same branch measured on a cold seeder cache and is listed to show why a single pair is not enough — run-to-run spread on an unchanged build has been measured at 13 MB/s on this rig, so a lone −5% reading says nothing.
Measured on
76a832781; the tip adds only a debug log line on the no-wrapper path, which expands todo {} while(false)outsideCONFIG:DEBUGand so is absent from the Release binaries that were benchmarked.The unit suite passes, but that is not evidence for this change — nothing in it exercises
CAsioSocketImpl. Nor would a throughput run reproduce the fault, which needs a specific interleaving rather than load; the measurement is here to show the change costs nothing on a path every transfer takes, not to demonstrate the fix.Not included
An earlier revision of this branch also re-armed a background read from the blocking branch of
Read(), intended to reap EC connections that linger insocket_listbecause a socket with no read armed never sees the peer's FIN. It was dropped: it reloadedm_readPendingafter the branch condition, so a completion landing between the two loads would be discarded byStartBackgroundRead()zeroingm_readBufferContent— losing bytes already taken from the kernel, which desyncs EC framing rather than merely stalling. Its trigger state was also close to unreachable:HandleReadonly clearsm_readPendingwithbytes_transferred > 0, and both error paths return with it still set, sopending == false && content == 0is essentially the pre-SetWrapSocketstate alone. The lingering-socket problem is real and still open, but wants its own evidence — the error paths leavingm_readPendingset look like the more direct cause.