Skip to content

fix(eventloop): dispatch each epoll event to the registration it was collected for, so a closed conn's stale event never reaches the conn that took its number (celeris#842) - #933

Merged
FumingPower3925 merged 2 commits into
mainfrom
fix/celeris-842-eventloop-registration-generation
Oct 5, 2026
Merged

FumingPower3925 merged 2 commits into
mainfrom
fix/celeris-842-eventloop-registration-generation

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Lane D1, PR 2 of 3, stacked on #932 (#862). It targets main so that CI runs, and it carries #862's commit. Review only this PR's two commits: 8195b6b (was 399072a; rounds 0 and 1, reviewed) and 2feac79 (was aeeccf7; review round 2: a comment and a test, no behaviour change). #934 (#881) is stacked on this one.

Rebased onto #932's new head 92f2913 (2026-10-05)

This PR's head is now 2feac79 (commits 8195b6b and 2feac79), rebased with git rebase --onto 92f2913 dce5a3c from aeeccf7 (commits 399072a and aeeccf7). #932 is now on main 8476e8b. The rebase applied with no conflicts, and each commit's git patch-id --stable is unchanged. None of main's 14 commits since df31adc touches driver/internal/eventloop or internal/wakefd, and this PR changes no exported API. The controls below ran at aeeccf7. They were all re-run at 2feac79 (linux/arm64 golang:1.27 container, -race, one process per observation), and every arm's tally matches round 2's: ff-main 10/10/0 (PASS/FAIL/SKIP), ff-parent 10/10/0, fix 30/0/0, nc 10/10/0, mut-NOGEN 20/10/0, mut-MADD 0/30/0, mut-MARM 20/10/0, mut-MDISARM 20/10/0, mut-MSETEV 20/10/0, mut-NOZEROSKIP 20/10/0. All have 0 race reports. Whole suites at 2feac79: the eventloop package 32/0/0, and ./driver/... ./internal/wakefd/ 474/0/0, both with 0 race reports (467 before; the 7 added are main's #859 tests). Scripts: bash evidence/lanes-20261003/D1/fix-r4/scripts/trees.sh 8476e8b 92f2913 2feac79, then bash evidence/lanes-20261003/D1/fix-r4/scripts/controls.sh 842 20261005T0208Z. Logs: evidence/lanes-20261003/D1/fix-r4/logs/842-20261005T0208Z/. The timing A/B under Cost ran at aeeccf7 and was not re-run, because the rebase changed no file this PR touches. #934 has not been rebased yet. It still carries the old commits (399072a, aeeccf7).

Defect

The standalone driver event loop (driver/internal/eventloop) dispatched every event of an epoll_wait batch by the descriptor number the event carried. An event collected for conn A could still be waiting in the batch when A was unregistered and closed, and a new conn B registered A's number on the same worker. The event was then applied to B.

When A's event carried EPOLLRDHUP, EPOLLHUP or EPOLLERR (A's server had hung up), it tore B down: B's onClose fired, B left the map and the epoll set, and Write(B) returned file descriptor not registered. B's socket was healthy. #843 (#784) stopped a reader that is inside A's read loop when A is unregistered. It did not cover an event dispatched after the unregister, which is this case.

Mechanism (line numbers at 56a6c1e; driver/internal/eventloop is byte-identical at 93bd88f, at df31adc, where the controls ran, and at 28383e8, current main)

  • worker.run (loop_linux.go:1088-1121) takes fd := int(ev.Fd) at :1102 and calls handleReadable(fd, ev.Events) at :1113 and handleWritable(fd) at :1116.
  • handleReadable (:1125-1170) looks the conn up by that number at :1127. By then the number can be B's. It reads B, then applies A's flags: errorClose(c, nil) at :1167-1168.
  • No registration identity reaches the kernel: the ADD (:311-314), the two MODs in flushLocked (:438-441, :461-464) and setEvents's MOD (:165-168) all carry only Fd.
  • The pending-flush list is keyed by number too (pending []int, :55; enqueueFlush :474; drainOne looks it up by number at :1194).

Fix

  • Each registration takes a per-worker generation in RegisterConn, under w.mu, never 0, before the conn is published. Every epoll_event of the registration carries it in Pad: the EPOLL_CTL_ADD, both MODs in flushLocked, and setEvents's MOD (the WriteAndPoll* mask and re-arm). All of them go through one helper, epollEvent, so no MOD can drop the generation; a MOD replaces the whole event data. Pad exists on every linux GOARCH in x/sys v0.48.0.
  • dispatch(ev) looks the conn up by number and drops the event unless the conn's generation is the event's. This is the shape epoll: a driver event harvested before UnregisterConn is dispatched by number after it, and closes the conn registered on the reused number (the epoll twin of #707) #771 asks of the epoll engine's driver dispatch.
  • The rest of the worker acts on conns, not numbers. EPOLLOUT goes to the conn the event names (drainOne(c)). The pending-flush list holds conns, so a flush queued for a conn that has gone cannot reach the conn that took its number. That one was benign (it flushed B's own bytes), but it was a number-keyed table.
  • Generations wrap after 2^32-1 registrations on a worker, skipping 0. An event reaches the wrong conn only if the conn now registered on its number has the event's generation: a multiple of 2^32-1 registrations on that worker must come between the event's registration and that conn's, while the event still waits to be dispatched. worker.gen's comment says so (review round 2).
  • A nil-by-default test hook, testHookDroppedEvent, runs on the drop path. It is a witness for the test, and costs nothing on the delivery path.
  • Two existing tests that called handleReadable(fd, …) now go through dispatch with the conn's event (read_critical_section_784_linux_test.go, and read_cost_784_bench_linux_test.go's BenchmarkHandleReadable784).

Tests and controls

Two tests are added in stale_event_842_linux_test.go:

  • TestStaleEventSparesTheConnThatTakesTheNumber842 is deterministic and drives the real worker.
    • It parks the worker in Q's onRecv while P and then A become ready; A's peer shuts its write side, so A's event carries EPOLLRDHUP.
    • It releases Q. The next epoll_wait returns P's event and then A's, and P parks the worker again.
    • With A's event collected and not yet dispatched, it unregisters and closes A, dup3s a new socket onto A's number and registers it as B on the same worker, then releases P.
    • B must not be torn down, and must still read and write. The drop hook must report A's event (A's number, EPOLLRDHUP), so the test cannot pass with the event missing from the batch.
  • TestEventsReachTheConnAfterEveryEpollCtlMod842 drives each MOD and then needs the conn's next event: the EPOLLOUT arm (the rest of a 1 MiB flush is sent only on EPOLLOUT), the disarm (next EPOLLIN), and each WriteAndPoll* mask and re-arm (next EPOLLIN).

Review round 2 adds TestGenerationsWrapPastZero842, in generation_wrap_842_linux_test.go. It starts the worker's count just below the wrap and registers three conns, which must get generations MaxUint32, 1 and 2, and each must be served. It needs the generation, so the failing-first arms do not run it, and the NC moves it out. Mutant NOZEROSKIP (the count wraps to 0) is its second control.

Each arm runs 10 separate processes (-race, linux/arm64, golang:1.27).

arm tree expected stale-event test P/F/S MOD test P/F/S wrap test (r2) P/F/S processes (failed) race reports B torn down stale event dropped generation 0 taken log
ff-main the base (df31adc) + the #842 tests + a drop-hook declaration stale-event test FAILs (MOD test PASSes: main has no generation to lose); wrap test not run (it needs the generation) 0/10/0 10/0/0 0/0/0 10 (10) 0 10 0 0 fix-r2/logs/842-r2/ff-main.log
ff-parent #862 head (this PR's parent) + the #842 tests + a drop-hook declaration stale-event test FAILs; wrap test not run 0/10/0 10/0/0 0/0/0 10 (10) 0 10 0 0 fix-r2/logs/842-r2/ff-parent.log
fix #842 head (round 2) all three PASS 10/0/0 10/0/0 10/0/0 10 (0) 0 0 10 0 fix-r2/logs/842-r2/fix.log
nc NC: the parent's loop_linux.go and the two tests #842 adapts, back in with cp (+ hook declaration), wrap test out stale-event test FAILs 0/10/0 10/0/0 0/0/0 10 (10) 0 10 0 0 fix-r2/logs/842-r2/nc.log
mut-NOGEN 2nd control: dispatch looks the conn up by number only stale-event test FAILs 0/10/0 10/0/0 10/0/0 10 (10) 0 10 0 0 fix-r2/logs/842-r2/mut-NOGEN.log
mut-MADD 2nd control: the EPOLL_CTL_ADD without the generation all three FAIL (every event dropped) 0/10/0 0/10/0 0/10/0 10 (10) 0 0 0 0 fix-r2/logs/842-r2/mut-MADD.log
mut-MARM 2nd control: flushLocked's EPOLLOUT arm MOD without the generation MOD test FAILs 10/0/0 0/10/0 10/0/0 10 (10) 0 0 10 0 fix-r2/logs/842-r2/mut-MARM.log
mut-MDISARM 2nd control: flushLocked's disarm MOD without the generation MOD test FAILs 10/0/0 0/10/0 10/0/0 10 (10) 0 0 10 0 fix-r2/logs/842-r2/mut-MDISARM.log
mut-MSETEV 2nd control: setEvents's MOD (WriteAndPoll* mask, re-arm) without the generation MOD test FAILs 10/0/0 0/10/0 10/0/0 10 (10) 0 0 10 0 fix-r2/logs/842-r2/mut-MSETEV.log
mut-NOZEROSKIP 2nd control (r2): the generation count wraps to 0 wrap test FAILs 10/0/0 10/0/0 0/10/0 10 (10) 0 0 10 10 fix-r2/logs/842-r2/mut-NOZEROSKIP.log
  • parent-pkg: PASS=30 FAIL=1 SKIP=0 race reports=0; FAIL: ['TestStaleEventSparesTheConnThatTakesTheNumber842']; SKIP: [] (fix-r2/logs/842-r2/parent-pkg.log)
  • fix-pkg: PASS=32 FAIL=0 SKIP=0 race reports=0; FAIL: []; SKIP: [] (fix-r2/logs/842-r2/fix-pkg.log)
  • fix-drivers: PASS=467 FAIL=0 SKIP=0 race reports=0; FAIL: []; SKIP: [] (fix-r2/logs/842-r2/fix-drivers.log)

Scripts: bash evidence/lanes-20261003/D1/fix-r2/scripts/trees.sh df31adc dce5a3c aeeccf7 d03bc69 a21d0ff (the export trees), then bash evidence/lanes-20261003/D1/fix-r2/scripts/controls.sh 842 r2. Table: python3 evidence/lanes-20261003/D1/fix-r2/scripts/table.py 842 evidence/lanes-20261003/D1/fix-r2/logs/842-r2.

Main moved during round 2. #935 (#859) merged into main as 28383e8 while this round ran. It changes driver/postgres and adds close-after-teardown tests to the three drivers; none of the commits since the base touches driver/internal/eventloop or internal/wakefd. The stack is behind main, and it merges with it cleanly (git merge-tree --write-tree 28383e8 a21d0ff). The merged tree passes with -race: the eventloop package 39/0/0 (PASS/FAIL/SKIP) with 0 race reports, and ./driver/... ./internal/wakefd/ 481/0/0 with 0 race reports, including #935's 7 #859 tests (evidence/lanes-20261003/D1/fix-r2/logs/merged-28383e8-a21d0ff/; script bash evidence/lanes-20261003/D1/fix-r2/scripts/merged-suite.sh 28383e8 a21d0ff).

Cost

Measured: no cost resolved. Measured 2026-10-05, 01:32Z to 01:57Z, with bash evidence/lanes-20261003/D1/fix-r1/scripts/bench.sh 10 10 df31adc dce5a3c aeeccf7 a21d0ff (the fix-r1 copy of the command, which differs from fix-r2/scripts/bench.sh only in its output directory; dce5a3c, aeeccf7 and a21d0ff are the current heads of #932, #933 and #934). It ran under the laptop TIMING lock, in one linux/arm64 golang:1.27 container (--cpus 4): 10 rounds, one process per arm per round, the arm order rotated each round, -benchtime 1s, and an A/A arm (df31adc's test binary run a second time as its own arm). benchstat: median ± 95% CI, n=10 per arm, ~ = not significant at α=0.05. Host: no game client ran. 12 of the 25 one-minute samples during the run were NOT-QUIET by the script's rule, each because of one desktop process (Orca Helper, 49% to 58% of one of the 8 cores); the other 13 were quiet. The A/A floor: on the micro benchmarks the A/A arm differs from df31adc by 1.2% to 1.4% in 2 of 5 rows at p=0.023 and p=0.029, so a shift under about 1.5% there is not resolved. BenchmarkReadWhileAnotherConnFlushes784's sec/op CIs are ±13% to ±384% per arm, and its gapped rows are bimodal in every arm, the A/A arm included (round trips cluster near 20 µs and near 26 µs), so only large shifts there are resolved. Output: evidence/lanes-20261003/D1/fix-r1/bench/20261005T013145Z/ (the per-arm *.txt, run.log with the test binaries' sha256, quiet-during.log, benchstat/). Analysis: evidence/lanes-20261003/D1/fix-r3/scripts/analyse.sh (benchstat) and fix-r3/scripts/tables.py (these tables).

#933 against df31adc, and against #932, its parent. Each cell is the median ± CI. Each delta in the first three tables is against df31adc.

benchmark (sec/op) df31adc A/A (df31adc again) #932 (parent) #933
HandleReadable784 808.1 ns ±3% 796.9 ns ±3% -1.39% (p=0.023) 802.8 ns ±4% ~ (p=0.393) 801.2 ns ±4% ~ (p=0.225)
WriteAndPoll784/WriteAndPoll 1.986 µs ±2% 1.983 µs ±3% ~ (p=0.698) 2.012 µs ±3% ~ (p=0.382) 1.955 µs ±4% ~ (p=0.325)
WriteAndPoll784/WriteAndPollBusy 1.996 µs ±2% 1.99 µs ±2% ~ (p=0.529) 1.98 µs ±3% ~ (p=0.197) 1.971 µs ±2% -1.25% (p=0.045)
WriteAndPoll784/WriteAndPollMulti 1.984 µs ±2% 1.98 µs ±2% ~ (p=0.363) 1.99 µs ±2% ~ (p=0.781) 1.982 µs ±2% ~ (p=0.446)
WritePending862 370.2 ns ±3% 365.7 ns ±2% -1.23% (p=0.029) 369.8 ns ±1% ~ (p=0.579) 365.8 ns ±3% ~ (p=0.123)
RegisterChurn862/churn=0 19.04 µs ±30% 21.23 µs ±17% ~ (p=0.247) 19.22 µs ±31% ~ (p=0.529) 23.55 µs ±23% ~ (p=0.280)
RegisterChurn862/churn=1 27.67 µs ±13% 27.32 µs ±12% ~ (p=0.912) 24.99 µs ±14% ~ (p=0.481) 26.82 µs ±7% ~ (p=0.739)
RegisterChurn862/churn=4 101.8 µs ±17% 103 µs ±15% ~ (p=0.971) 97.52 µs ±16% ~ (p=0.796) 101.2 µs ±24% ~ (p=0.739)
ReadWhileAnotherConnFlushes784/chunk=0/gap=0s 19.37 µs ±30% 21.73 µs ±16% ~ (p=0.529) 19.26 µs ±31% ~ (p=0.631) 19.37 µs ±30% ~ (p=0.796)
ReadWhileAnotherConnFlushes784/chunk=4096/gap=0s 142.6 µs ±29% 164.3 µs ±44% ~ (p=0.529) 148.4 µs ±28% ~ (p=0.796) 139 µs ±29% ~ (p=0.579)
ReadWhileAnotherConnFlushes784/chunk=65536/gap=0s 5.115 ms ±384% 9.139 ms ±259% ~ (p=0.739) 9.942 ms ±474% ~ (p=0.393) 10.24 ms ±211% ~ (p=0.739)
ReadWhileAnotherConnFlushes784/chunk=524288/gap=0s 1.539 ms ±69% 1.028 ms ±131% ~ (p=0.579) 496.9 µs ±230% ~ (p=0.190) 1.175 ms ±143% ~ (p=0.529)
ReadWhileAnotherConnFlushes784/chunk=262144/gap=1ms 21.83 µs ±22% 21.4 µs ±19% ~ (p=0.725) 19.93 µs ±13% ~ (p=0.393) 22.42 µs ±15% ~ (p=0.796)
ReadWhileAnotherConnFlushes784/chunk=1048576/gap=5ms 19.84 µs ±28% 20.06 µs ±18% ~ (p=0.796) 19.42 µs ±16% ~ (p=0.218) 23.72 µs ±19% ~ (p=0.481)
benchmark (allocs/op) df31adc A/A (df31adc again) #932 (parent) #933
HandleReadable784 0 ±0% 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000)
WriteAndPoll784/WriteAndPoll 0 ±0% 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000)
WriteAndPoll784/WriteAndPollBusy 0 ±0% 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000)
WriteAndPoll784/WriteAndPollMulti 0 ±0% 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000)
WritePending862 0 ±0% 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000) 0 ±0% ~ (p=1.000)
RegisterChurn862/churn=1 9.5 ±16% 9 ±11% ~ (p=0.750) 9 ±11% ~ (p=1.000) 9 ±11% ~ (p=1.000)
RegisterChurn862/churn=4 33.5 ±16% 33 ±15% ~ (p=0.812) 32 ±16% ~ (p=0.838) 33.5 ±22% ~ (p=0.641)
benchmark (pairs/s) df31adc A/A (df31adc again) #932 (parent) #933
RegisterChurn862/churn=1 251.8k ±2% 249.8k ±2% ~ (p=0.684) 249.6k ±3% ~ (p=0.529) 253.8k ±2% ~ (p=0.123)
RegisterChurn862/churn=4 301.9k ±2% 299.9k ±2% ~ (p=0.971) 301.2k ±2% ~ (p=0.796) 305.2k ±3% ~ (p=0.481)
benchmark (sec/op) #932 (parent) #933
ReadWhileAnotherConnFlushes784/chunk=0/gap=0s 19.26 µs ±31% 19.37 µs ±30% ~ (p=0.739)
ReadWhileAnotherConnFlushes784/chunk=4096/gap=0s 148.4 µs ±28% 139 µs ±29% ~ (p=0.529)
ReadWhileAnotherConnFlushes784/chunk=65536/gap=0s 9.942 ms ±474% 10.24 ms ±211% ~ (p=0.529)
ReadWhileAnotherConnFlushes784/chunk=524288/gap=0s 496.9 µs ±230% 1.175 ms ±143% ~ (p=0.353)
ReadWhileAnotherConnFlushes784/chunk=262144/gap=1ms 19.93 µs ±13% 22.42 µs ±15% +12.47% (p=0.029)
ReadWhileAnotherConnFlushes784/chunk=1048576/gap=5ms 19.42 µs ±16% 23.72 µs ±19% +22.14% (p=0.011)
benchmark (sec/op) df31adc A/A #932 #933
ReadWhileAnotherConnFlushes784/chunk=262144/gap=1ms 19.99 µs ±22% 22.96 µs ±13% ~ (p=0.277) 25.91 µs ±23% ~ (p=0.251) 22.96 µs ±13% ~ (p=0.101)
ReadWhileAnotherConnFlushes784/chunk=1048576/gap=5ms 19.86 µs ±28% 22.57 µs ±13% ~ (p=0.478) 23.95 µs ±18% ~ (p=0.108) 22.61 µs ±13% ~ (p=0.289)
benchmark (sec/op) #932 (parent) #933
ReadWhileAnotherConnFlushes784/chunk=262144/gap=1ms 25.91 µs ±23% 22.96 µs ±13% ~ (p=0.815)
ReadWhileAnotherConnFlushes784/chunk=1048576/gap=5ms 23.95 µs ±18% 22.61 µs ±13% ~ (p=0.588)

The first three tables are against df31adc. On the micro rows, which hold the generation store on each ADD/MOD and the generation compare on each dispatch, the one significant row is WriteAndPollBusy, −1.25% (p=0.045). That is inside the A/A floor and is not a saving. The fourth table is against the parent, #932. In the 10-round run, the two gapped rows were +12.5% (p=0.029) and +22.1% (p=0.011) against #932, though not against df31adc (p=0.796 and p=0.481). In that run #933 drew the slow (≈26 µs) mode in 7 of 10 rounds and #932 in 2 of 10. A follow-up re-ran the two gapped rows for 20 more rounds with the same binaries, under the TIMING lock (01:58Z to 02:02Z; 3 of 7 30-second samples NOT-QUIET, the same Orca Helper process): bash evidence/lanes-20261003/D1/fix-r3/scripts/gap-followup.sh 20, output evidence/lanes-20261003/D1/fix-r3/bench/gap-20261005T015850Z/. The last two tables are the follow-up. With n=20, #933 is −11% and −6% against #932 (p=0.815, p=0.588), and the A/A arm is as far from df31adc as #933 is. So the run-1 shift was the bimodality and not this PR.

What changes on the measured paths, without numbers: each ADD and MOD fills one more 4-byte field of a stack EpollEvent, and each dispatched event compares one more uint32. The pending-flush list stores pointers instead of ints and drops one map lookup per entry.

Family audit

These are the fd-number-keyed tables and dispatches in driver/internal and the three drivers:

Site Status
run → handleReadable(fd), by number fixed: dispatch, generation-checked
run → handleWritable(fd) → drainOne(fd), by number fixed: the conn the event names
pending []int → drainOne(fd), by number fixed: a list of conns (it was benign: B's own flush)
WriteAndPoll*'s poll(2) of the number left as is: it reads and changes nothing (documented by #843 in UnregisterConn's doc)
loop_other.go (the non-Linux fallback; it builds on darwin, and the drivers do not build on windows at all: #937) safe: each reader reads its own net.FileConn dup; the map entry is removed only if it is still the reader's conn
driver/redis, driver/memcached no number-keyed table, and they close the fd only in Close, after UnregisterConn
the loop's caller API: Write, WriteAndPoll*, UnregisterConn, by number not covered by this PR: the generation protects epoll events only, and these calls carry none. A caller that still holds a number the loop has released reaches the conn that took it. #929 tracks it for redis (a PubSub control write that races PubSub.Close goes by number to the conn that took the closed descriptor), and #859 tracked it for postgres (next row; fixed by #935)
driver/postgres #859: Close acted on the number after onClose had closed the fd. Fixed on main by #935 (28383e8), which merged during this round (see Tests and controls)
epoll engine's driver dispatch (engine/epoll/loop.go:629) #771, lane E3 after the #443 move. This PR is the shape it mirrors

Review round 1

Both reviews found no defect in this commit. It is unchanged except for its rebase onto dce5a3c (#862's round-1 commit removed w.mu's read lock from RegisterConn, next to the ADD this commit changes; git range-diff shows only those context lines changed). Every control was re-run at 399072a. The cost (MINOR, perf/API/security) is under Cost.

Review round 2

The correctness review approved. The perf/API/security review's MAJOR is #934's measurement, and its MINOR here is this PR's cost (see Cost).

Fixes #842

@FumingPower3925 FumingPower3925 added this to the v1.6.0 milestone Oct 3, 2026
@FumingPower3925 FumingPower3925 added bug Something isn't working platform/linux Linux-specific (io_uring, epoll) area/driver Database/cache driver infrastructure labels Oct 3, 2026
@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (1)
CONTRIBUTING.md — configured

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: goceleris/celeris/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d0afb621-489e-4300-881f-9deacbdd9115
📥 Commits

Reviewing files that changed from the base of the PR and between 5260896 and 08e14ab.

📒 Files selected for processing (5)
  • driver/internal/eventloop/generation_wrap_842_linux_test.go
  • driver/internal/eventloop/loop_linux.go
  • driver/internal/eventloop/read_cost_784_bench_linux_test.go
  • driver/internal/eventloop/read_critical_section_784_linux_test.go
  • driver/internal/eventloop/stale_event_842_linux_test.go

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The Linux event loop now tags connection registrations with nonzero generations and checks those generations when dispatching epoll events. Pending flushes retain connection pointers. Linux tests cover descriptor reuse, generation wrap, and event delivery after epoll updates.

Changes

Linux eventloop registration and dispatch

Layer / File(s) Summary
Registration identity and epoll updates
driver/internal/eventloop/loop_linux.go, driver/internal/eventloop/generation_wrap_842_linux_test.go, driver/internal/eventloop/stale_event_842_linux_test.go
Registrations receive nonzero generations, which are included in epoll ADD and MOD events. Tests check generation wrap and input events after epoll updates.
Generation-checked dispatch and pending flushes
driver/internal/eventloop/loop_linux.go, driver/internal/eventloop/stale_event_842_linux_test.go, driver/internal/eventloop/read_critical_section_784_linux_test.go, driver/internal/eventloop/read_cost_784_bench_linux_test.go
Dispatch drops events whose FD and generation do not match a registered connection. Pending flushes retain connection pointers. Tests cover descriptor reuse; the benchmark and critical-section test dispatch generation-bearing events.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 08e14

The Linux event-loop change is mergeable after normal checks; no unresolved defect in the changed dispatch or pending-flush paths was established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 08e14

The change strengthens isolation between reused connections without adding external access or privileges. Remaining uncertainty concerns extreme counter reuse and deployment conditions outside the inspected paths.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Database peers influence received bytes and close/readiness timing, while local driver code owns registration. A replacement target in this dispatch path must be another connection registered on the same worker. Connections may share that worker; deployment-specific tenant, credential and datastore grouping is unknown.

Trust Boundaries and Controls

  • observed — Generation matching gates event delivery, while connection-owned closed-state locks gate the actual read, write and epoll-update sinks. Unregister marks the original connection closed before removal, so work that already captured its pointer cannot operate on a reused descriptor after teardown returns.

Resilience and Maintainability Implications

  • observed — Failed registration marks the unpublished epoll registration closed and removes only its matching map entry. Error teardown and repeated close suppress duplicate callbacks. Loop.Close joins the worker before shutdown marks remaining connections closed and releases its descriptors; late queued pointers cannot bypass those closed-state I/O checks.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required fix(eventloop): summary form, describes stale-event dispatch, and ends with (celeris#842).
Description check ✅ Passed The description explains the stale epoll-event defect, its fix, and related tests. It is relevant to the changeset.
Linked Issues check ✅ Passed #842 requires stale-event protection in the standalone driver event loop, generation data on ADD and every MOD, generation-checked dispatch, and regression tests. loop_linux.go implements these chan…
Out of Scope Changes check ✅ Passed The generation-wrap and MOD-delivery tests validate #842’s generation mechanism. Updating the existing read test and benchmark to use dispatch keeps them aligned with the changed event path. No unre…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.90566% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
driver/internal/eventloop/loop_linux.go 84.90% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

@FumingPower3925
FumingPower3925 force-pushed the fix/celeris-842-eventloop-registration-generation branch from beede94 to 399072a Compare October 3, 2026 13:43
FumingPower3925 added a commit that referenced this pull request Oct 3, 2026
…mment says what a wrap would take to misdeliver (celeris#842)

Review round 2 of #933.

worker.gen's comment said only that a generation is never 0. It now says
why the 32-bit per-worker count can wrap without misdelivering: an event
reaches the wrong conn only if the conn now registered on its number has
the event's generation, which takes a multiple of 2^32-1 registrations on
the worker between the two registrations while the event still waits to
be dispatched.

TestGenerationsWrapPastZero842 starts the worker's count just below the
wrap and registers three conns: their generations are MaxUint32, 1 and 2,
and each is served. No behaviour changes.
FumingPower3925 added a commit that referenced this pull request Oct 5, 2026
…mment says what a wrap would take to misdeliver (celeris#842)

Review round 2 of #933.

worker.gen's comment said only that a generation is never 0. It now says
why the 32-bit per-worker count can wrap without misdelivering: an event
reaches the wrong conn only if the conn now registered on its number has
the event's generation, which takes a multiple of 2^32-1 registrations on
the worker between the two registrations while the event still waits to
be dispatched.

TestGenerationsWrapPastZero842 starts the worker's count just below the
wrap and registers three conns: their generations are MaxUint32, 1 and 2,
and each is served. No behaviour changes.
@FumingPower3925
FumingPower3925 force-pushed the fix/celeris-842-eventloop-registration-generation branch from aeeccf7 to 2feac79 Compare October 5, 2026 02:07
@FumingPower3925
FumingPower3925 marked this pull request as ready for review October 5, 2026 02:22
…collected for, so a closed conn's stale event never reaches the conn that took its number (celeris#842)

The standalone driver loop dispatched every event of an epoll_wait batch by
the descriptor number it carried. An event collected for conn A, still in
the batch when A was unregistered and closed and a new conn B registered
A's number on the same worker, was applied to B: A's EPOLLRDHUP (A's server
had hung up) tore B down, its onClose fired and its requests failed.

Each registration now takes a per-worker generation (never 0) in
RegisterConn, under w.mu, and every epoll_event of the registration carries
it in Pad: the EPOLL_CTL_ADD, the two EPOLL_CTL_MODs in flushLocked and the
WriteAndPoll* mask and re-arm (setEvents), all built by one helper so no MOD
can drop it. The worker looks the conn up by number and drops the event
unless the conn's generation is the event's. This is the shape #771 asks of
the epoll engine's driver dispatch.

The rest of the worker's number-keyed paths act on conns too: EPOLLOUT goes
to the conn the event names, and the pending-flush list holds conns, not
numbers, so a flush queued for a conn that has gone cannot reach a conn
that took its number.
…mment says what a wrap would take to misdeliver (celeris#842)

Review round 2 of #933.

worker.gen's comment said only that a generation is never 0. It now says
why the 32-bit per-worker count can wrap without misdelivering: an event
reaches the wrong conn only if the conn now registered on its number has
the event's generation, which takes a multiple of 2^32-1 registrations on
the worker between the two registrations while the event still waits to
be dispatched.

TestGenerationsWrapPastZero842 starts the worker's count just below the
wrap and registers three conns: their generations are MaxUint32, 1 and 2,
and each is served. No behaviour changes.
@FumingPower3925
FumingPower3925 force-pushed the fix/celeris-842-eventloop-registration-generation branch from 2feac79 to 08e14ab Compare October 5, 2026 02:25
@FumingPower3925
FumingPower3925 merged commit 72c4b8f into main Oct 5, 2026
41 of 42 checks passed
@FumingPower3925
FumingPower3925 deleted the fix/celeris-842-eventloop-registration-generation branch October 5, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/driver Database/cache driver infrastructure bug Something isn't working platform/linux Linux-specific (io_uring, epoll)

Projects

None yet

1 participant