fix(server): a Start that never serves releases the caller's listener, the CPU monitor and the settle re-opener (#737) - #747
Conversation
…, the CPU monitor and the settle re-opener (celeris#737) A Start that failed in doPrepare returned with the listener the caller had handed over still bound and listening, and, when createEngine failed, with the CPU monitor's /proc/stat descriptor open: only Shutdown closed it, and such a caller has no reason to call Shutdown. A Start that published its engine but ended with no Shutdown to come, because Listen failed or Shutdown had been called before Start, likewise left the monitor open and the settle re-opener (celeris#592) running. prepareWithListener now closes the supplied listener on any start failure except ErrAlreadyStarted, doPrepare closes the monitor when createEngine fails, and the function listenContext returns, which every Start* entry point defers until Listen has returned, also stops the re-opener and closes the monitor. All three releases are idempotent.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: goceleris/celeris/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughStartup failure paths now release supplied listeners and the CPU monitor. The cleanup returned by ChangesStartup resource cleanup
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to The reviewed cleanup does not show an outstanding merge-blocking risk; merge after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Failed starts now release listeners that would otherwise remain reachable, and starts that end without Shutdown release server resources. The reviewed paths do not add a network entry point, but this is a public lifecycle change and security coverage is incomplete. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will improve performance by 13.01%
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
|
Round 2: no code change. The review had no blocking finding, and the head stays |
…ained the channel (celeris#705); fail the start helpers fast with Start's error (celeris#706) (#730) Bug (#705): a chunk that spilled after the handler drained the channel stayed in the spill while Read parked on the empty channel and the engine stayed paused, for the rest of the connection. Bug (#706): the websocket and static start helpers never read Start's error channel, so a failed Start cost the full 30 s timeout and reported "server not ready within timeout". Change: spillChunk retries the channel under spillMu; Read's new next() reads the close flag first, then promotes the spill under spillMu before it parks or reports a close (so a close cannot overtake earlier chunks); queueBehind pauses only when the chunk spilled or at highWater. waitForReady takes Start's channel and fails fast with its error; failed-start helpers release the listener and CPU monitor; the closeprobe readerPaused collision is renamed and vetted in CI. Verification: new tests fail first (the close-overtake test 30/30 FAIL before the fix, 30/30 PASS after; the start helper took 30.015 s on main); 12 of 13 mutants killed (F2 has no deterministic window); websocket suite 230/0/2 -> 242/0/2, static 70/0/0 -> 71/0/0, 0 PASS->FAIL; GitHub stress 1000 iterations per test per arch, 0 FAIL; ^TestBackpressure pair main vs head shows no increase (0 of 14 rows p<0.05). CI on 806ead3 (merged with main 0e239b1): 17/17 checks green, websocket interlock 14/14 and 15/15. Follow-ups: #782 (a sleep used as sync in read-waiting-in-window; start-helper comments stale after #747). Closes #705. Refs #706 (the websocket/static helpers only; the rest stays open). Refs #716 (item 1 only).
Fixes #737
What was wrong
StartWithListenerandStartWithListenerAndContexttell the caller not to close the listener it hands over. Two kinds ofStartthen left things open that nothing would ever close:doPrepare: config validation, a badTrustedProxiesentry,createEngine). It returned the error with the caller's listener still bound and listening, so the kernel kept completing handshakes into a backlog nothing accepts (the middleware/websocket tests: the native-engine start helper never reads Start's error, so a failed start is reported as a 30 s "server not ready within timeout" #706 symptom). WhencreateEnginefailed, the CPU monitor's/proc/statdescriptor, opened just before, stayed open too: onlyShutdownclosed it, and a caller whoseStartfailed has no reason to callShutdown.Listen, but ends with noShutdownto come. Found while measuring (1), same class:Listenfails (the address is taken), orShutdownwas called beforeStart, soListenran on a context that was already cancelled and returned at once (Server.Start / StartWithListener never return after Shutdown on io_uring and epoll: Listen blocks on a Background context and Engine.Shutdown is a no-op #595).doPreparehad opened the CPU monitor and started the settle re-opener (adaptive dispatch never re-times a settled route: a store-backed handler that turns slow runs inline on the engine worker forever (#493 item 4, measured) #592), and again onlyShutdownreleased them: one descriptor and one goroutine per suchStart, for the life of the process. The fix(server): shut down on every context cancel, and return only after it (celeris#673) #692 design makes the second case explicit: aShutdownbeforeStartsetsdirectShutdown, so theStart*Contextwatcher leaves the shutdown to it, and thatShutdownfound no engine to shut down.Measured on main
dccb839(linux/arm64 Docker, CI shape), 20Startcalls per row, GC off so no finalizer closes a leaked descriptor,/proc/self/fdcounted before and after;route_adaptive=true(so a published engine starts the re-opener):createEnginefails,StartWithListenercreateEnginefails,StartWithListenerAndContextcreateEnginefails,StartWithContextStartWithListenerTrustedProxiesfails,StartWithListenerListenfails (address taken), std,StartListenfails (address taken), epoll,StartWithContextShutdownbeforeStart, std,StartWithContextShutdownbeforeStart, epoll,StartWithListener(
probe-dccb839.log, from737/probe/run-probe.sh; the probe test is evidence only, not committed.)The fix (server.go only)
prepareWithListenercloses the supplied listener when the start fails, except onErrAlreadyStarted: then a server is already running, perhaps on that very listener (std serves on it directly), so closing it would stop that server. The listener stays the caller's, and theStartWithListenerdoc now says both.doPreparecloses the CPU monitor whencreateEnginefails.listenContextreturns, which everyStart*entry point defers right after it (so it runs onceListenhas returned, and the server never serves again:Startcannot be retried), now also stops the settle re-opener and closes the CPU monitor. Both are idempotent under their own locks, so aShutdownbefore or after it changes nothing. Putting the release there rather than at eachListencall site keeps it in one place, and keeps this PR out of the lines fix(server): Shutdown runs the OnShutdown hooks, and returns, only after the drain on every engine (#703) #746 (Server.Shutdown runs OnShutdown hooks and returns before epoll/io_uring drain in-flight requests, breaking its documented order #703) changes: the two branches merge cleanly, and the suites below also ran the merge.Not on the request path: all three run once per
Start.Tests
TestFailedStartClosesTheSuppliedListener(all OSes): eachdoPreparefailure x both listener entry points, called twice on the same server (the second call returns the cached error with a new listener). Checks the listener is closed (a secondClosereportsnet.ErrClosed) and a dial to its address is refused.TestErrAlreadyStartedLeavesTheListenerToTheCaller: the boundary.TestFailedStartClosesTheCPUMonitor:createEnginefailure viaStart,StartWithContext,StartWithListener.TestStartWithoutShutdownReleasesWhatItOpened(all OSes, std):Listenfails (3 entry points) andShutdownfirst (3 entry points); afterStartreturns, the re-opener is stopped and the monitor closed.TestFailedStartsLeaveNoProcStatDescriptorandTestStartsWithoutShutdownLeaveNoProcStatDescriptor(Linux): the issue's measurement, 20 starts, GC off, counting only descriptors whose link is/proc/statso other tests' descriptors cannot move it.Failing-first and controls (linux/arm64 Docker,
--cpus 4, CI shape: 8 MiB memlock;-race -v; counts are--- PASS/FAIL/SKIPlines only)On main
dccb839, the six new tests (overlaid,scripts/ff-main.sh,ff-main/final2/): 5 FAIL (23 of 25 subtests), in both shapes (CI shape and unconstrained memlock); only theErrAlreadyStartedboundary passes, as it should. No SKIP line.Controls, one container (
737/controls.sh), headfb52752, each arm a copy of the head's tree withserver.goreplaced (nevergit checkout); the run covers the six new tests plusTestStartWithListenerDoubleStart,TestServerDoubleStartand #592'sTestRouteAdaptive_NoReopenerWhenEngineCreationFails:-count=3server.go(the negative control)ErrAlreadyStartedboundary passes, as it does on mainTestFailedStartClosesTheSuppliedListener(all 6 cases)createEnginefailureTestFailedStartClosesTheCPUMonitor(3) andTestFailedStartsLeaveNoProcStatDescriptor(4, +20/proc/statdescriptors each)listenContextreturns the bare cancelTestStartWithoutShutdownReleasesWhatItOpened(6) andTestStartsWithoutShutdownLeaveNoProcStatDescriptor(4, +20 each)ErrAlreadyStartedtoo (over-closing)Each partial mutant is caught by exactly the tests written for its part, and no SKIP line appears in any arm.
Suites (
scripts/suites.sh,-race -count=1 -v, one container per shape, onego testprocess per arm)Root package, arms: base main
dccb839, #746's head76c205c, this headfb52752, and a local merge of the two (they merge cleanly):Apart from the one failure below, no test goes from PASS to anything else, and every test new in an arm passes (
scripts/suite-compare.py).The failure is
TestAdaptiveSettledRouteRetime592/epoll/settledin the CI shape, verdictNOT_FIXED. The failing run's own log is what attributes it. Its reference window, taken while/kvwas settled, saw/pingfast (284 samples, median 0.107 ms), so the rig's premise (a pinned worker to compare against) did not hold in that run. The rig counts on every worker holding a/kvconnection, and its own comment puts the miss at about 2^-7 per run. The same code passed in the merged arm of the same container and in the unconstrained shape. The release this PR adds runs only afterListenhas returned, never during the run.A re-run (
scripts/rerun.sh,05-reruns/retime592.log, one container, CI shape,-count=5) gave main 5/5 PASS and this head 5/5 PASS, every settled armFIXEDwith a stalled reference window (2-7 samples, medians 0.59-2.10 s). That is consistent with the attribution, but at a 2^-7 miss rate five runs a side cannot tell the two sides apart, so the re-run is not the attribution. The skips are the same in every arm:TestRouteAdaptive_SettleReopenCost(env-gated) and, at 8 MiB only, the three io_uring subtests ofTestAdaptiveSettledRouteRetime592(#709)../middleware/...ran for #746 (below the root package, it is where servers are started and shut down most); for this PR, CI's Unit job ran it.Round 2 (this head unchanged,
fb52752). #746 changed in its round 2 (3d2ab72: aStart*call stopped by a directShutdownnow waits for it). A local merge of3d2ab72, this head and main9b670b8merges cleanly and passes the root package in both shapes, 0 FAIL (round2/scripts/suites.sh,-race -count=1 -v):9b670b83d2ab72+ this PR + main)No test goes from PASS to anything else (
round2/scripts/suite-compare2.py), and the six #737 tests pass in both shapes. In the merge, this PR's release (inlistenContext's returned function, deferred inlisten) runs beforelistencloseslistenDoneand before it waits for a directShutdown. So aShutdownthat waits forListenfinds the monitor and the re-opener already released, and its own releases are no-ops.Also:
golangci-lintv2.13 (the repo's config) 0 issues forGOOS=linux;GOOS=linux GOARCH=amd64/arm64 go vetand test-binary builds clean (lint/); onGOOS=darwinthe linter reports the same twointernal/deferlingerunused fields as on main. CI onfb52752: run 36341288144, every job green on the first attempt. amd64 on this laptop is emulated (--platform linux/amd64,04-amd64/):go vetclean and the six new tests pass (6/6, 25 subtests).Behaviour change
breakinglabel (added in round 2, as on #692 and #746: behaviour, not API). A failedStartWithListener/StartWithListenerAndContextnow closes the listener, asnet/http'sServer.Servedoes ("Serve always returns a non-nil error and closes l"). The one pattern this changes: a caller that, after a failed start, hands the same listener to a secondServer, for example falling back fromIOUring(whose constructor fails with "io_uring not available on this system") toEpoll. That second start now fails too. Such a caller should let the default adaptive engine choose, or keep a duplicate of the listener ((*net.TCPListener).File) to retry with. No caller in this repository does this: of the call sitesgit grep -n StartWithListener -- '*.go'lists outsideserver.go, every one binds its listener just before the call, and on a start error the test skips, fails or returns.Follow-ups
The review's minor and nit findings not fixed here are in #778: the
/proc/stattest comments and the!=vs>check, a dial assertion that cannot fail, and the uncommitted fd-delta probe.Evidence
Scripts and logs:
evidence/lanes-20260927/LIFECYCLE/in the maintainer's probatorium evidence root (737/,ff-main/,03-suites/,04-amd64/,lint/,scripts/). Every number above is printed by a script there.