You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-ups from #772: the standalone driver event loop reads a reused descriptor number after UnregisterConn (#710 there), the WorkerLoop contract sentence, a test for the check-and-read critical section, UnregisterConn doc #784
The review of #772 (celeris#710) left these minor findings and nits. None of them blocks #772, whose failing-first result and suites the review reproduced. Item 1 is a defect in code #772 does not touch; Fixes #710 would otherwise close the only record near it. Each item says what to do.
1. The standalone driver event loop has #710's defect, in a worse form (minor per the review; a defect, not yet fixed)
driver/internal/eventloop is the loop the drivers use when they are not given an engine (no celeris server running). Its worker reads a driver conn by descriptor number after UnregisterConn has returned, as the epoll engine did before #772:
worker.handleReadable (origin/main driver/internal/eventloop/loop_linux.go:1058-1095) holds c.recvMu and reads fd in a loop until EAGAIN, calling c.onRecv after every read, and never checks c.closed.
worker.UnregisterConn (:239-264) deletes the map entry, runs EPOLL_CTL_DEL, sets c.closed under c.mu and fires onClose. It takes neither recvMu nor anything else a worker inside handleReadable holds, so it does not wait for the read loop.
So after UnregisterConn(A) has returned, the caller closes A and another file X takes the number, a worker still inside A's read loop reads X. On the engine (#710) those bytes were dropped; here they are handed to A's onRecv, i.e. into A's protocol parser, and X never sees them. A blocking X with nothing to read parks the worker and every conn on it.
The review reproduced it with a probe in the shape of #772's tests (the worker parked in A's first onRecv after a full 16 KiB read, then UnregisterConn(A), close A, dup3 a new socketpair X onto the number, 10 bytes written to X's peer, the worker released). Its results, Docker linux/arm64, -race: A.onRecv after UnregisterConn returned: ["XXXXXXXXXX"]; X still holds "" (read err resource temporarily unavailable) FAIL 5/5 on #772's head 086c5b1, FAIL 3/3 on main 5936dd8, and FAIL on main + #772 + #773 + #774 + #776; the blocking variant fails with "the worker served no other conn for 3 s". The probe files are kept in the maintainer's evidence tree (evidence/lanes-20260927/EPOLL-HIJACK/followups/review-probes/zz_review710_standalone_test.go, and the second reviewer's version next to it); this lane has not re-run them.
To do: a failing-first test from that probe, then the same fix as #772: check c.closed and issue each read in one critical section with the flag UnregisterConn sets before it returns (here recvMu already serialises the reads with WriteAndPoll's caller-side reads, so the check can go under it, or UnregisterConn can wait on it). Check the WriteAndPoll* caller-side read paths for the same number reuse. A read-path lock change needs a measurement.
2. The engine.WorkerLoop contract says the opposite of what epoll guarantees once #772 merges (minor)
engine/provider.go says the epoll worker "still reads fd by number afterwards, so a number reused at once can lose its first bytes to it (celeris#710)". #772 left the sentence alone because #744 rewrote that paragraph and kept the sentence. #744 has since merged (f0886ca, 2026-09-28T03:05Z), so the sentence is on main, and #772 (based on 698bed6, before #744) does not touch the file: once #772 merges, main tells driver authors something epoll no longer does.
To do: when #772 merges (or in a merge of main into it), replace the sentence: the epoll engine fires onClose before UnregisterConn returns, and its worker reads fd no more once UnregisterConn has returned (celeris#710). While item 1 is open, add that the standalone driver event loop still reads by number after it.
3. No test pins that the closed check and the read share one critical section (minor)
#772's mutant m1-toctou (check dc.closed under dc.mu, release the lock, then read) survives 3/3: the tests park the worker so that the whole unregister happens before the worker's next check, which proves a check comes before each read, not that the check and the read are atomic. The fix is right by construction (driver.go:340-347 at 086c5b1: lock, check, read, unlock), but the suite cannot tell it from the broken check-then-read version.
To do: a nil-by-default test hook between the check and the read (for example a package-level func(fd int) the test sets), and a test that calls UnregisterConn, closes the fd and reuses the number from inside that hook. The hook is on the driver read path, so its cost (one load and a nil branch per read) needs a measurement, or a build-tagged hook.
4. UnregisterConn's doc: "the worker reads fd no more" is not "onRecv is not called again" (nit)
With #772, an UnregisterConn that races a read waits on dc.mu for the read syscall to finish, then fires onClose on the caller's goroutine while the worker calls onRecv with the bytes it just read (driver.go:340-361 at 086c5b1: unlock at :347, cb(...) at :360). Before #772, the worker's check after the read dropped those bytes, so an onRecv concurrent with or after onClose had a window of a few instructions; now it is as long as a read syscall. The contract does not forbid it and the redis driver serialises the two through its bridge, but the doc #772 wrote invites the stronger reading.
To do: one sentence in UnregisterConn's doc: a read that completed before UnregisterConn took dc.mu is still delivered, so onRecv can run once more, concurrently with or after onClose.
The body said "The lock/unlock count per read is unchanged". It is not: the head takes dc.mu before every read, including the final one that returns EAGAIN, EOF or an error, where main locked only after a read with n > 0 (a full 32 KiB read then EAGAIN: 1 lock on main, 2 on the head). The body now says so; the cost is what the benchmarks measure.
The review of #772 (celeris#710) left these minor findings and nits. None of them blocks #772, whose failing-first result and suites the review reproduced. Item 1 is a defect in code #772 does not touch;
Fixes #710would otherwise close the only record near it. Each item says what to do.1. The standalone driver event loop has #710's defect, in a worse form (minor per the review; a defect, not yet fixed)
driver/internal/eventloopis the loop the drivers use when they are not given an engine (no celeris server running). Its worker reads a driver conn by descriptor number afterUnregisterConnhas returned, as the epoll engine did before #772:worker.handleReadable(origin/maindriver/internal/eventloop/loop_linux.go:1058-1095) holdsc.recvMuand readsfdin a loop until EAGAIN, callingc.onRecvafter every read, and never checksc.closed.worker.UnregisterConn(:239-264) deletes the map entry, runsEPOLL_CTL_DEL, setsc.closedunderc.muand firesonClose. It takes neitherrecvMunor anything else a worker insidehandleReadableholds, so it does not wait for the read loop.So after
UnregisterConn(A)has returned, the caller closes A and another file X takes the number, a worker still inside A's read loop reads X. On the engine (#710) those bytes were dropped; here they are handed to A'sonRecv, i.e. into A's protocol parser, and X never sees them. A blocking X with nothing to read parks the worker and every conn on it.The review reproduced it with a probe in the shape of #772's tests (the worker parked in A's first
onRecvafter a full 16 KiB read, thenUnregisterConn(A), close A,dup3a new socketpair X onto the number, 10 bytes written to X's peer, the worker released). Its results, Docker linux/arm64,-race:A.onRecv after UnregisterConn returned: ["XXXXXXXXXX"]; X still holds "" (read err resource temporarily unavailable)FAIL 5/5 on #772's head 086c5b1, FAIL 3/3 on main 5936dd8, and FAIL on main + #772 + #773 + #774 + #776; the blocking variant fails with "the worker served no other conn for 3 s". The probe files are kept in the maintainer's evidence tree (evidence/lanes-20260927/EPOLL-HIJACK/followups/review-probes/zz_review710_standalone_test.go, and the second reviewer's version next to it); this lane has not re-run them.To do: a failing-first test from that probe, then the same fix as #772: check
c.closedand issue each read in one critical section with the flagUnregisterConnsets before it returns (hererecvMualready serialises the reads withWriteAndPoll's caller-side reads, so the check can go under it, orUnregisterConncan wait on it). Check theWriteAndPoll*caller-side read paths for the same number reuse. A read-path lock change needs a measurement.2. The
engine.WorkerLoopcontract says the opposite of what epoll guarantees once #772 merges (minor)engine/provider.gosays the epoll worker "still reads fd by number afterwards, so a number reused at once can lose its first bytes to it (celeris#710)". #772 left the sentence alone because #744 rewrote that paragraph and kept the sentence. #744 has since merged (f0886ca, 2026-09-28T03:05Z), so the sentence is on main, and #772 (based on 698bed6, before #744) does not touch the file: once #772 merges, main tells driver authors something epoll no longer does.To do: when #772 merges (or in a merge of main into it), replace the sentence: the epoll engine fires
onClosebeforeUnregisterConnreturns, and its worker reads fd no more onceUnregisterConnhas returned (celeris#710). While item 1 is open, add that the standalone driver event loop still reads by number after it.3. No test pins that the closed check and the read share one critical section (minor)
#772's mutant
m1-toctou(checkdc.closedunderdc.mu, release the lock, then read) survives 3/3: the tests park the worker so that the whole unregister happens before the worker's next check, which proves a check comes before each read, not that the check and the read are atomic. The fix is right by construction (driver.go:340-347at 086c5b1: lock, check, read, unlock), but the suite cannot tell it from the broken check-then-read version.To do: a nil-by-default test hook between the check and the read (for example a package-level
func(fd int)the test sets), and a test that callsUnregisterConn, closes the fd and reuses the number from inside that hook. The hook is on the driver read path, so its cost (one load and a nil branch per read) needs a measurement, or a build-tagged hook.4.
UnregisterConn's doc: "the worker reads fd no more" is not "onRecv is not called again" (nit)With #772, an
UnregisterConnthat races a read waits ondc.mufor the read syscall to finish, then firesonCloseon the caller's goroutine while the worker callsonRecvwith the bytes it just read (driver.go:340-361at 086c5b1: unlock at :347,cb(...)at :360). Before #772, the worker's check after the read dropped those bytes, so anonRecvconcurrent with or afteronClosehad a window of a few instructions; now it is as long as a read syscall. The contract does not forbid it and the redis driver serialises the two through its bridge, but the doc #772 wrote invites the stronger reading.To do: one sentence in
UnregisterConn's doc: a read that completed beforeUnregisterConntookdc.muis still delivered, soonRecvcan run once more, concurrently with or afteronClose.Done in #772's body (no action left)
dc.mubefore every read, including the final one that returns EAGAIN, EOF or an error, where main locked only after a read with n > 0 (a full 32 KiB read then EAGAIN: 1 lock on main, 2 on the head). The body now says so; the cost is what the benchmarks measure.measure/710-driverread-base(main 698bed6 + the bench file) andmeasure/710-driverread-fix(fix(epoll): never read a driver conn's descriptor number after UnregisterConn has returned (celeris#710) #772's head 086c5b1 + the same file), which the queued cluster timing run uses.