fix(engine): own the wakeup eventfd so no producer can write it after shutdown closed it (celeris#655) - #666
Merged
Merged
Conversation
… shutdown closed it (celeris#655) Both engines published the NUMBER of their wakeup eventfd and let producers on other goroutines write it, then closed that descriptor at shutdown without resetting the field. Every producer that ran afterwards wrote 8 bytes into whatever the process had opened next: a spurious wakeup on another eventfd, bytes injected into a client socket, silent corruption of a file. Nothing reported any of it. celeris#658 put the two ADOPT writers under the queue mutex shutdown takes before the close. Seven producers were left: io_uring addDriverAction (a driver's RegisterConn / UnregisterConn / Write), io_uring enqueueDetach, the runAsyncHandler wakeups in both engines, the detached WS/SSE write and PauseRecv / ResumeRecv closures in both engines, and the H2 write queue, which has no engine lock at all and is drained by pool goroutines no engine joins. io_uring is the sharper of the two: Worker.shutdown closes the eventfd BEFORE it joins its dispatch goroutines. The fix generalises #658's rule — every write happens under a lock shutdown takes before it closes the fd — by moving the descriptor into one handle, internal/wakefd.WakeFD, that every producer signals through. Signal and Close share an RWMutex, so a signal either completes before the close or is dropped; Close waits for the signals already in flight. The mutex is a leaf, so Signal stays legal under adoptQMu and driverActionMu, and the coalescing that limits it to the queue's empty-to-non-empty edge is untouched. Holding the handle instead of the number also removes epoll's data race on Loop.eventFD: the lazy re-creation after a failed startup eventfd wrote the field on the loop thread while AdoptConn, the detached closures and switchToH2Local read it unsynchronised. Set now takes the same lock Signal reads under, and refuses once the handle is closed so the caller closes the descriptor it just created. A nil *WakeFD is a valid, permanently disabled handle, which is what a loop whose eventfd could not be created needs — and what the engine test fixtures that used to set the field to -1 now express by leaving it unset. Raw eventfd writes in engine/epoll, engine/iouring and internal/conn: 24 before, 0 after. WakeFD.Signal is the only one left. internal/wakefd carries its own tests for the barrier, for the lazy-Set refusal that hands the descriptor back to the caller, for the nil handle, and for a -race Signal-vs-Close storm.
… the driver's epoll_ctl the same way (celeris#655) Review follow-up on #666. WakeFD.FD is no longer a lock. The epoll event loop calls it for every event it dispatches, as the FIRST branch of `for i := range n`, where main did a plain field load; routing that through the producers' RWMutex put two atomic RMWs on the engine's hottest branch and shared a cache line with every goroutine signalling. FD is now a relaxed atomic load of a number written exactly twice in a loop's life (Set at startup, Close at shutdown), padded off the mutex's line so the producers never dirty it. Signal keeps the barrier — that is where the fix lives — and the PR body's cost claim is replaced by a measurement (internal/wakefd/wakefd_bench_test.go benchmarks all four shapes: plain field, RWMutex, atomic sharing the line, atomic padded). The static guard was too narrow, and it hid a live defect. The class is not "a raw write to the wakeup eventfd", it is "a syscall from a goroutine the loop does not join, on a descriptor the loop closes at shutdown and whose number it does not reset". epoll has a second such descriptor: its own epoll fd. Loop.shutdown closed it and left the number in place, while a driver goroutine's RegisterConn / UnregisterConn / Write issues epoll_ctl on it — deterministically after shutdown, since shutdownDrivers nils driverConns and RegisterConn rebuilds the map from nil. Every driver-path epoll_ctl now goes through driverEpollCtl, which holds a read lock closeEpollFD takes as a writer before closing, so the operation either completes first or is refused with errLoopShutdown. A registration after shutdown now also fails instead of being silently accepted and never getting its onClose. Also from the review: - internal/wakefd: New and Set force O_NONBLOCK on the descriptor, so Signal's bounded-time guarantee — which Close depends on, since it waits behind the signals in flight — is a property of the type rather than a convention every caller has to remember. - prepareH2Poll: guard efd < 0 before arming, like every other FD() consumer. The handle can now report -1 where the raw field could not, and encodeUserData would have smeared it across the fd bits. - Tests: the epoll field race (Set vs Signal) and io_uring's runAsyncHandler wake now each have a regression test, and the new epoll_ctl defect has one whose witness is a second epoll instance parked on the recycled number. The guarded writeFn is deliberately left untested and the reason is recorded next to the tests: shutdown sets detachClosed under the same mutex the closure takes first, so a post-shutdown test would pass on unfixed main too. - Committed comments no longer cite line numbers this diff shifted; they name the functions instead.
…eFailedInit celeris#664 added releaseFailedInit, which closed w.h2EventFD directly. This branch replaces that field with a wakefd.WakeFD. The two merged cleanly because they touch different lines, and the result did not compile (worker.go:5267-5269, w.h2EventFD undefined). Use w.wakeFD.Close(), which is what shutdown already calls on this branch: it waits behind the signals in flight, retires the number before the close, and is idempotent.
This was referenced Sep 26, 2026
FumingPower3925
added a commit
that referenced
this pull request
Oct 3, 2026
…ses the worker's eventfd or epoll fd number after shutdown closed it (celeris#862) The standalone driver loop's worker published its wakeup eventfd's number in a plain field. A Write that left bytes pending read it with no lock (wake, via enqueueFlush, after c.mu is released) while shutdown closed the eventfd and stored -1, so a Write running alongside Loop.Close was a data race, and could write 8 bytes to the number after the close, into whatever had taken it. The worker now holds the eventfd through internal/wakefd.WakeFD, the handle the engines adopted for the same defect (celeris#655, #666): Signal and Close share a lock, so a wake either completes before the close or writes nothing. RegisterConn had the same shape for the other descriptor a caller reaches: it read epollFD under w.mu, released w.mu, and issued EPOLL_CTL_ADD after. A shutdown in between closed the epoll fd, so the ADD went to the epoll instance that had taken the number, or to a closed number (RegisterConn then returned an epoll_ctl error for a conn whose onClose had already fired). The ADD is now issued under w.mu's read lock and c.mu, after a check that the conn has not been torn down, like every other epoll_ctl of a conn; shutdown marks every conn closed and closes the epoll fd under the write lock. A read lock, so a registration does not hold up the worker's lookups. A conn whose ADD fails is marked closed before it leaves the map. BenchmarkWritePending862 (the wake path) and BenchmarkRegisterChurn862 (a conn's round trip while other goroutines register and unregister on its worker) measure the cost; both run on the base too.
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.
Summary
Both engines published the number of their wakeup eventfd and let producers on other goroutines write it, then closed that descriptor at shutdown without resetting the field. Every producer that ran afterwards wrote 8 bytes into whatever the process had opened next. This is celeris#655, found by reading and now reproduced.
Review follow-up (round 2) widened this: epoll has a second descriptor with the same defect — its own epoll fd, which driver goroutines call
epoll_ctlon. That is fixed here too, and the guard that was supposed to prove "no producer was missed" has been widened to the real class.Closes #655
What was wrong
celeris#658 (PR #661) put the two adopt writers under the queue mutex shutdown takes before the close. That left every other producer, and on current main
df1269cthere were 24 rawunix.Writesites on those descriptors: 11 inengine/epoll, 12 inengine/iouring, 1 ininternal/conn.addDriverAction(a driver'sRegisterConn/UnregisterConn/Write)driverActionMu, no closed check.shutdownDriverssetsdriverConns = nilandRegisterConnrebuilds the map from nil, so it always reaches the writeenqueueDetachrunAsyncHandlerwakeups (5 sites)Worker.shutdowncloses the eventfd before it joins the dispatch goroutines withasyncWG.Wait()writeFn/PauseRecv/ResumeRecvasyncWGnever tracked; the pause/resume pair has nodetachClosedcheck at allh2ShardedQueue.EnqueueglobalH2Pool, which no engine joins, andCloseH2→Manager.Closeonly cancels the streams. This queue has no engine lock of its ownepoll_ctl(RegisterConn/UnregisterConn/Write→flushDriverSendLocked/closeDriver)Loop.shutdownclosesl.epollFDand never resets it; driver goroutines are joined by nothing. Deterministic for the same reason as row 1:shutdownDriversnils the map,RegisterConnrebuilds it from nil and always reaches theepoll_ctlTwo things in the original report did not hold up against the code, and the fix is shaped accordingly:
runAsyncHandleranddrainDetachQueueare not the hazard.Loop.shutdownjoins its dispatch goroutines in phase 2, before the close in phase 3, anddrainDetachQueuewrites no eventfd. epoll's exposure is the producersasyncWGnever tracked: the detached callbacks and the H2 queue. io_uring has the inverted, sharper ordering.h2EventFDis assigned once beforereadyand never again, so a naive "set it to -1 in shutdown" would create one. The handle avoids that.epoll does have a field race, and it is fixed here too: the lazy re-creation after a failed startup eventfd wrote
l.eventFDon the loop thread whileAdoptConn, the detached closures andswitchToH2Localread it with no lock.l.timerFDis loop-thread-only and is not a defect.The fix
Generalise #661's rule — every write happens under a lock shutdown takes before it closes the fd — by moving the descriptor into one handle,
internal/wakefd.WakeFD, that every producer signals through.SignalandCloseshare anRWMutex, so a signal either completes before the close or is dropped, andClosewaits for the signals already in flight.Setlets the loop create its eventfd lazily and refuses once closed, which is how the caller learns the descriptor it just created is its own to close.FD()serves the loop thread, the only goroutine allowed to touch the number directly. Producers never see a number, which is what removes epoll's field race.New/SetforceO_NONBLOCKon the descriptor, soSignal's write cannot park while holding the read lockClosewaits behind.*WakeFDis a valid, permanently disabled handle — what a loop whose eventfd creation failed needs, and what the engine test fixtures that used to set the field to-1now express by leaving it unset.The same rule, applied to epoll's other shared descriptor: every driver-path
epoll_ctlgoes throughLoop.driverEpollCtl, which holds a read lock thatcloseEpollFDtakes as a writer before closing. A driver that reaches a loop after shutdown is refused witherrLoopShutdownrather than operating on a recycled number. A side effect worth naming:RegisterConnafter shutdown now fails instead of being silently accepted and never getting itsonClose.The coalescing is untouched:
detachQPending.Swapand the H2pendingCAS still limitSignalto the queue's empty-to-non-empty edge.Cost: measured, not asserted
The previous revision of this PR claimed "the added cost is one
RLock/RUnlockbeside awrite(2)that was already there". That was wrong forFD(), andFD()is the call the epoll event loop makes for every event it dispatches, as the first branch offor i := range n:where main did a plain field load. It is fixed rather than excused:
FD()is now a relaxed atomic load of a number written exactly twice in a loop's life (Setat startup,Closeat shutdown), padded onto its own cache line so the producers'RLockinSignalnever dirties the line the loop thread reads. The barrier stays where the defect is — onSignal.internal/wakefd/wakefd_bench_test.gomeasures all four shapes, idle and with 4 goroutines signalling without pause (the WS-broadcast / H2 fan-out shape this engine is tuned for).golang:1.27,--cpus 4, linux/arm64,-benchtime=300ms -count=5:FD()shapedf1269c)RWMutexRLock (this PR, as reviewed)Median of 5,
ns/op. The reviewed shape cost 5.2x idle and 26x under fourproducers — 18.16 ns against 0.70 ns, paid once per epoll event, on the branch that
runs before every other one. The lock-free load puts that back within ~0.2 ns of
main's plain field.
Reported honestly: the padding is not a measurable win on this box (0.92 vs
0.84 ns contended, 0.84 vs 0.70 idle — both inside the run-to-run spread), because
each producer spends ~145 ns inside
write(2)and so dirties the line rarely. Itis kept as insurance for a busier producer mix, costs one 60-byte hole per event
loop, and is recorded here as insurance rather than claimed as a speedup.
Signal— the producer side, which does keep the barrier — is the path where the original claim was true:Signalwrite(2)on the published number (main)WakeFD.Signal(RLock + the samewrite(2))+2.0 ns, +1.4%, on a path that was already making a syscall — which is what the
original "one
RLock/RUnlockbeside awrite(2)" sentence described, and theonly path it was true for.
Lock order
WakeFD.muis a leaf: nothing is acquired while it is held.Signalis therefore legal underadoptQMu(epollAdoptConn) anddriverActionMu(io_uringaddAdoptAction), and both keep taking those locks across the signal because they also publish the queue entry.Closeruns on the loop thread holding no producer lock. #661'se.mu → wakeMuorder is unchanged, andSignalis never called underwakeMu.Loop.ctlMu(the new epoll_ctl guard) is a leaf on the same terms: held only across theepoll_ctlitself, and taken while holdingdriverMu(RegisterConn) ordc.mu(flushDriverSendLocked), never the reverse.closeEpollFDtakes it as a writer at the very end ofshutdown, holding nothing.Worker.shutdown's ordering is deliberately not reordered: the conn-fd close beforeasyncWG.Wait()is protected bydetachMu+asyncClosed, and moving the join would have fixed only two of the seven producers.Evidence
All runs in a CI-shape container:
golang:1.27,--cpus 4,-race, one container at a time. epoll andinternal/connat memlock 8 MiB, io_uring at 128 MiB. Tallies come only from--- PASS:/--- FAIL:lines. Every number below is reproducible fromall-runs.shandguard.shin the run's log directory.Each test drives a producer after shutdown has closed the descriptor, having first parked a stand-in on that exact descriptor number (
F_DUPFD_CLOEXEC, which returns the lowest free number ≥ n, so it can never clobber a live fd). The stand-in is created before the close, or it would take the freed number itself. Every test also runs its producer on a live loop first and asserts it reaches the syscall — so a pass cannot mean the producer is simply inert.Regression tests,
-count=5on the fixTestRegisterConnAfterShutdownDoesNotWriteTheClosedWakeupFDengine/iouringaddDriverActionTestEnqueueDetachAfterShutdownDoesNotWriteTheClosedWakeupFDengine/iouringenqueueDetachTestRunAsyncHandlerAfterShutdownDoesNotWriteTheClosedWakeupFDengine/iouringrunAsyncHandler(new this round)TestDetachedResumeRecvAfterShutdownDoesNotWriteTheClosedWakeupFDengine/iouringResumeRecvTestDetachedResumeRecvAfterShutdownDoesNotWriteTheClosedWakeupFDengine/epollResumeRecvTestRegisterConnAfterShutdownDoesNotTouchTheClosedEpollFDengine/epollepoll_ctl(new this round)TestH2WriteQueueDoesNotSignalAClosedWakeupFDinternal/conn./engine/iouringPASS=20 FAIL=0 SKIP=0,./engine/epoll+./internal/connPASS=15 FAIL=0 SKIP=0, 0 data races (4 tests × 5 and 3 tests × 5).The epoll_ctl test's witness is not "an error was returned": a second epoll instance is parked on the closed descriptor's number, and the test asserts that instance did not silently gain the connection.
Negative controls — two, each targeting one barrier
Both neuters are applied to the fix and restored by
cp, with the sha256 checked before, after and after restore.A — remove the
WakeFDbarrier (deleteclosed = true/fd = -1/num.Store(-1)fromClose, leaving main's "close the descriptor, keep the number" semantics; the API is untouched so everything still compiles):internal/wakefd/wakefd.go7333ec73d905d230…8da378ecf31bf0f4…cp)7333ec73d905d230…B — remove the epoll_ctl guard (restore main's semantics exactly: unguarded
epoll_ctl, and a close that leaves the number in the field — otherwiseEpollCtl(-1)would fail withEBADFand the test would pass for the wrong reason):engine/epoll/driver.go45338a015ffeef6b…e350c615ab34be7c…cp)45338a015ffeef6b…The two controls are specific: A fails the 6 eventfd tests and leaves the epoll_ctl test passing; B fails the epoll_ctl test and leaves the eventfd tests passing. Neither neuter is a blanket break.
Static guard, widened (review MAJOR 2)
The old guard grepped only for
unix.Writeto a wakeup eventfd, so it could not have caught the epoll_ctl defect it was being cited to rule out. The class it now covers: a syscall from a goroutine the loop does not join, on a descriptor the loop closes at shutdown and whose number it does not reset.df1269cwrite(2)on a wakeup eventfd (engine/epoll+engine/iouring+internal/conn)WakeFD.Signalis the only one leftepoll_ctlonl.epollFDin the driver path (engine/epoll/driver.go)driverEpollCtl, the single guarded chokepointunix.Write/EpollCtlinengine/iouring/driver.go(io_uring's driver path enqueues; the worker does the ring work)guard.shalso enumerates every remaining rawepoll_ctlonl.epollFDwith its enclosing function, so the "all of these are loop-thread" claim is auditable rather than asserted. The 13 remaining sites are inrun,acceptAll,drainRead,hijackConn,initProtocol,drainDetachQueue,armEpollOut,disarmEpollOut,closeConn,attachAdoptedFDanddetachFromEpoll— all loop-thread, excepthijackConn, which in async mode runs on a dispatch goroutine that epoll joins in shutdown phase 2, before the phase-3 close.flushWrites(the one path a detached middleware goroutine reaches) touches no epoll fd.Packages on the fix (
-race -count=1 -v)./engine/iouring./engine/epoll+./internal/conn+./internal/wakefdok engine/iouring 109.890s|ok engine/epoll 20.608s|ok internal/conn 1.322s|ok internal/wakefd 1.007sThe previous revision recorded 126 and 97. The +1 and +3 are exactly the tests added
this round:
runAsyncHandlerin io_uring, and the epollepoll_ctlregression testplus
TestConcurrentSetAndSignalandTestNewForcesNonBlockingininternal/wakefd.The five SKIPs are the tests' own pre-existing gates, the same ones #661 recorded:
TestWriteBufBackpressureClosesSlowConsumerneedsGOTEST_BACKPRESSURE=1, and the fourTestSendfile*tests build a 1-worker engine that config validation rejects. None is caused by this change, and no test was removed or newly skipped.#661's own tests still pass:
TestAdoptConnWakesASuspendedWorker,TestAdoptConnWakesASuspendedLoopandTestShutdownClosesAQueuedAdoption(both engines).gofmtclean,go vet ./...clean on linux/arm64 and linux/amd64,golangci-lint runon the four packages:0 issues..Review points answered
RWMutexin the epoll hot path, false cost claimFD()is lock-free and cache-line isolated; the claim is replaced by the benchmark table aboveTestConcurrentSetAndSignalracesSetagainstSignalunder-race, which is the E4 shape (TestConcurrentSignalAndCloseonly coveredSignalvsClose)runAsyncHandleramong themrunAsyncHandler(new test; the handler-panic teardown is the one of its five wake sites reachable without a live ring). Disputed for the guardedwriteFn, in both engines: after shutdown it cannot reach its wakeup write at all, because the closure's first act ismu.Lock(); if cs.detachClosed { return }and shutdown setsdetachClosedunder that same mutex before closing the eventfd. A post-shutdown test would pass on unfixed main too — the producer is inert, not barred. Its real exposure is the in-flight window (passed the check, released the mutex, insideSignalwhenCloseruns), which is a race rather than a sequence a deterministic test can pin; that window is whatClose's wait for in-flight signals shuts, covered byTestConcurrentSignalAndClose. The reasoning is recorded beside the tests, not only hereSignal's "bounded time" is a caller contract the type does not enforceNew/SetforceO_NONBLOCK;TestNewForcesNonBlockinghands the type a deliberately blocking, already-full pipe and asserts both the flag and thatClosestill returnsprepareH2Pollis the onlyFD()consumer with no>= 0guardGetSQE, so a disabled handle consumes no SQE eitherbug,engine/epoll,engine/iouring, milestone v1.6.0 — matching #661 and #663Out of scope
driver/internal/eventloop/loop_linux.gohas the same bug class —wake()readsw.eventFDwithoutw.muwhileClosecloses it and sets-1underw.mu— but it is the driver's own event loop, not the engine wakeup path celeris#655 is about. Worth a separate issue.What this change does not alter: a producer that runs after shutdown still enqueues onto a queue nobody will drain (driver actions, detach entries, H2 frames). That costs memory until the engine is collected; no descriptor is involved.
Test plan
internal/wakefd, one regression test per producer class in both engines, the H2 queue, and the epoll driverepoll_ctlpath)internal/wakefd/wakefd_bench_test.go)go vet ./...+golangci-lint run+gofmtTested on: [ ] std [x] epoll [x] io_uring — [ ] amd64 [x] arm64
Every run above executed on
linux/arm64containers.linux/amd64andlinux/arm64are bothcompile- and vet-checked (
GOOS=linux GOARCH=... go vet ./...), but onlylinux/arm64was executedhere; the other arch needs a cluster run.
Release notes
bug