Skip to content

middleware: the ring keeps the queued SSE copy behind a wrapper, negotiate.ParseInto is bounded, h2c SSE probe and docs, static Root docs, websocket start retry, cache Store.Set timeout (default pending) (v1.6.0, Refs #888 #889 #884) - #1131

Merged
FumingPower3925 merged 12 commits into
mainfrom
l16-middleware
Oct 27, 2026

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Lane L16 MIDDLEWARE of v1.6.0. Refs #889, Refs #884, Refs #888.

Draft. Not for merge. Nothing in the three trackers is ticked or edited.

Method: every item was re-read against main 0897add first (the branch is rebased on f4ae3ad, #1124; the probes and the tests that depend on engine behaviour were run again there and gave the same results, logs/probe-889.34-f4ae3ad.log, logs/rebase-check.log) (still open: yes/no below), then measured; claims below have a log or a test run behind them. Item ordinals are the checkbox ordinals counting every checkbox of the issue body, ticked or not (not the numbering of #1099, which counts only the unchecked ones); each row quotes the item's opening words.

Read first: two defects the 889.34 probe found, not built (they need engine files)

  1. An async-route h2c SSE stream is closed by ReadTimeout on epoll and Adaptive. TestSSEStaysOpenPastServerTimeouts855 had no native h2c cells ("h2c is std's only"). With them, on a route registered .Async(): WriteTimeout 1 s keeps the stream open (24 events, 4.6 s); ReadTimeout 1 s ends it with an unexpected EOF (5 events), alone or with WriteTimeout (epoll and Adaptive, sync and async config; std stays open). The cut comes at ReadTimeout after the client's last frame: review r1 timestamped the request and measured 1.004 to 1.025 s request-to-EOF in all 8 cells (the earlier "0.8 s" was measured from the first event). Cause by reading: the engines' sweep applies ReadTimeout and also IdleTimeout to an HTTP/2 conn whatever streams it has open (internal/engine/epoll/loop.go ~4752-4756, internal/engine/iouring/worker.go ~7778-7787, measured from lastActivity, the last read), and an HTTP/2 conn is never detached; so with the defaults (ReadTimeout 60 s, IdleTimeout 600 s) any h2c SSE stream whose client sends no frame for the smaller of the two dies. IdleTimeout is from the code, not measured. This is a new finding, not text of 889.34 or 884.79. io_uring: not measured (Docker Desktop's kernel has no io_uring, tier=none), same code shape by reading only. Fix is engine-side (exempt a running h2 stream from the read sweep, or document it): decision pending. The native h2c cells in the test have the WriteTimeout arm only, with the reason in a comment.
  2. An SSE route on epoll/io_uring/Adaptive that is not .Async() holds its event loop for the life of the stream. See the 889.34 probe below (4 of 12 requests to other connections took 0.5 to 2.7 s). Documented in middleware/sse/doc.go; a code guard (sse refusing, or going async by itself, on an inline h2 stream) is a decision, not built.

Items

item (opening words) premise result commits
889.34 "SSE (any StreamWriter handler) over h2c on epoll, io_uring and adaptive shows no response headers and no body until the handler returns" TRUE for the default configuration (route not async, with or without Config.AsyncHandlers: first byte 3.058 s of a 3 s stream). FALSE for a route registered .Async(): headers and first event at 1 ms. Flush is moot: an inline h2 handler runs on the event loop, which sends the response when the handler returns. probe, no flush code needed, so no CODE-OTHER item added; docs + h2c cells 5
884.79 "The stall bound of PR #1077 ... has four known gaps" (1) h2c: the premise "not covered, no test" is half FALSE: a test now exists and shows the stream ends at 1.4 s (default window) and 1.7 s (1 GiB window) after connect with WriteTimeout 1 s on epoll (mechanism: flow control for the default window is by reading; for the 1 GiB window not confirmed). (2) no tcp_info, (3) io_uring fixed-file: not measured, not touched (engine files, and no io_uring here). (4) no minimum rate: already in the sse doc ("there is no minimum rate"). docs + test for (1) 5
888.24 "middleware/sse (#920 at 043073d): in queued mode a user store that wraps NewRingBuffer ... copies each event twice" TRUE (main: 435 allocs / 100 events, 335 for the plain ring) fixed: 435 -> 336 1, 2
888.30 "middleware/cache (#940 at 714dfcc): a Store.Set that never returns still wedges the leader request" TRUE (cache.go, then runs inside Do, kv.Set(ctx) unbounded; ctx never ends on native H1) field + bound; default pending 7 (last)
888.32 "middleware/cache with internal/sf (#940 at 714dfcc): after a call is replaced on expiry ... can still return after the newer call's Set" already covered: #1007's Config.Store doc names the overlap, and MemoryStore orders the write (setIfNewer). Nothing to do for it. one clause added to that doc (a Set the middleware stopped waiting for lands late), inside commit 7, goes with it 7
888.42 "internal/negotiate ParseInto is unbounded" TRUE (main: 3.8 MB per call for a 64 KiB header, measured here too) fixed: first 64 entries, at most 256 list elements walked 1, 2
888.46 "middleware/static (#1022 review, doc/test minors)" (a) TRUE and bigger than the item says, see Findings; (b) TRUE: no test; (c) TRUE: no row; (d) TRUE doc corrected; tests; benchmark row; rewrap. No behaviour change. 3
888.47 "middleware/sse (#1023 review r1, minor, pre-existing): no test checks that ClientPolicyBlock queues the copy" TRUE test + mutant 4
888.48 "tests (found by PR #1046 review r1, minor): the skip-on-"io_uring" start helper survives in four more places" TRUE for 3 of 4 (websocket_engine_linux_test.go:87, detached_count_window_linux_test.go:178, wsrst_torture_linux_test.go:89); the 4th is in internal/engine/epoll and left to the engine lane fixed in the 3 6

Commits (base f4ae3ad): 1 red tests for 888.24 and 888.42; 2 their fixes; 3 static; 4 sse ClientPolicyBlock test; 5 sse h2c tests and docs; 6 websocket start helpers; 7 cache SetTimeout, pending the maintainer's decision on the default; then the fixes of review r1: 8 red test for a panic after the bound (cache), 9 its fix and the cache godoc (m4, m5, m6), 10 sse doc (m1), 11 websocket probe (m7). Commits 7, 8 and 9 are the cache SetTimeout work and are dropped together (8 and 9 touch only what 7 added); the rest does not depend on them.

889.34: the probe, first (day 1)

probe/zz_probe_h2c_sse_889_34_linux_test.go (kept in the lane's scratch, not in the PR), docker golang:1.27.3, --cpus 2, linux/arm64, main 0897add. A 15-tick, 200 ms stream over prior-knowledge h2c, time to the response headers and to the first event line:

engine route inline (default) Config.AsyncHandlers route .Async()
std 1 ms 1 ms 1 ms
epoll 3.058 s 3.058 s 1 ms
adaptive 3.038 s 3.057 s 1 ms
io_uring not measured (no io_uring in this kernel)

Second probe (probe/zz_probe_wedge_889_34_linux_test.go), epoll, Workers 2, one h2c SSE stream of 3 s open, 12 fresh HTTP/1.1 requests to another route: inline route 4 of 12 took over 500 ms, max 2.738 s (8 of 12, max 2.759 s, on f4ae3ad); .Async() route 0 of 12, max 7 ms (2 ms). So the real defect is not a missing flush: an inline h2 handler occupies its event loop. "Flush on Flush for a detached/async H2 stream" is not needed (the async stream already streams); "document SSE as HTTP/1.1-only" would be wrong. The doc now says: register the route .Async(). That sentence is a consequence of this probe, not a maintainer decision.

Failing on main

  • 888.24: TestWrappedRingKeepsTheQueuedCopy888 fails on main (8 of 8 events: "the ring holds a second copy of Data"); guards TestWrapperThatReplacesDataStillGetsACopy888 / ...EventTypeStillGetsACopy888 pass on main and must keep passing.
  • 888.42: five tests fail on main (TestParseIntoBoundsTheEntryCount888, TestParseIntoKeepsTheFirstEntries888, TestParseIntoEmptyElementsAreBounded888, TestAcceptHeapDoesNotGrowWithTheHeader888: 3 835 525 bytes per call, TestEntriesPastTheBoundAreNotSeen888).
    TestCanonicalAcceptEncodingFollowsTheNegotiatorsEntryCap888 (singleflight, commit 2) is red on main too: "the negotiator tells the two values apart" (differ-past-the-cap; differ-within-the-cap passes). Logs: logs/red-main-singleflight.log, logs/red-main-888-24-42.log.
  • 888.30: cannot be failing-first as one commit (the tests use the new field and do not compile on main). TestUnsetSetTimeoutBoundsTheStoresContext888 uses only API main has, leaves SetTimeout unset, and passes whatever default is chosen: red on main ("the store's Set was given a context without a deadline"; logs/red-main-cache-default.log). Mutants below.
  • 889.34 / 884.79 / 888.46 / 888.47 / 888.48: tests and docs only. Not failing-first by nature (the h2c tests describe behaviour main already has; 888.47 and 888.46 are missing coverage; 888.48 is test hygiene). Mutants where one exists.

Mutants (each applied to the branch, the named tests run, file restored)

item mutant (one change to the branch) red tests (the only ones, besides what the unmutated branch fails in this environment)
888.24 the ring never trusts the drain's context (oc.owns and false: main's behaviour) TestWrappedRingKeepsTheQueuedCopy888
888.24 the ring trusts the drain's context without comparing the strings TestWrapperThatReplacesDataStillGetsACopy888, TestWrapperThatReplacesEventTypeStillGetsACopy888
888.42 the loop's bound removed (true) the five ...888 tests of parse_bound_888_test.go
888.47 M3 of the finding: the block arm queues the uncopied event (orig) whole middleware/sse package: the new TestSlowClientBlockQueuesTheCopy888 fails, and only the two io_uring tests that fail without the mutant in this environment
888.46 resolvesInside accepts everything when the root is "." TestRoot304DotRootDoesNotFollowSymlinkOut888 (the root-index cases)
888.48 an ENOMEM start is classified as skip five rows of TestClassifyStartTable
888.30 the bound off (ok || true) TestSetTimeoutFreesTheLeaderFromAHungSet888 (the package then runs into its timeout, the leader being held)
888.30 the pooled encode buffer always returned to the pool TestSetTimeoutAbandonedSetKeepsItsBytes888
888.30 the key not copied for the Set TestSetTimeoutAbandonedSetKeepsItsKey888
888.30 a panic in Set swallowed TestSetTimeoutSetPanicReachesTheLeader888

Logs: logs/mutant-*.log.

Benchmarks

All: docker golang:1.27.3, --cpus 2, linux/arm64 on a Mac that was also in use, one container at a time, -benchtime 0.5 to 1 s, interleaved rounds of main (0897add), this branch and an A/A twin of main ("main B"), the order rotated each round, 12 to 14 rounds. Medians and minimums; A/A is the median of the twin against the median of main in the same run, which is the noise floor. Raw: bench/*.txt in the lane's scratch. Benchmarks are in the PR (public API only where they must also run on main).

negotiate (888.42), 12 rounds, 500 ms:

benchmark main median / min main B branch median / min allocs main -> branch
AcceptLongHeader888/entries (64 KiB of a,) 1 838 909 ns / 1 781 116 1 837 747 3 862 ns / 3 796 19 -> 3; 3 835 525 -> 3 072 B
AcceptLongHeader888/weighted (64 KiB of a;q=0.5,) 489 940 / 473 048 496 567 3 992 / 3 808 13 -> 3; 714 368 -> 3 072 B
AcceptLongHeader888/commas (64 KiB of ,) 517 046 / 502 573 513 675 2 388 / 2 336 0
AcceptTypical888/browser 184.2 / 181.3 186.2 183.0 / 179.3 0
AcceptTypical888/weighted 156.4 / 152.7 157.4 156.8 / 151.1 0
AcceptTypical888/star-first 70.2 / 67.6 71.8 73.2 / 69.1 (+4.3%; A/A +2.2%) 0
AcceptTypical888/star-q0, refused, one, AcceptMedia888 within +1.3% of main, A/A +1 to +2.5% 0

cache (888.30) miss path, 12 rounds, 1 s (the final code; bench/cache-miss-v2-typeswitch.table.txt):

benchmark main median / min main B branch median / min B/op, allocs main -> branch
CacheMissSingleflight (MemoryStore) 794.5 / 738.6 778.2 / 709.6 853.9 / 751.2 346 -> 350, 9 -> 9
CacheMissNoSingleflight (MemoryStore) 691.3 / 678.4 699.9 / 672.8 734.1 / 684.0 322 -> 324, 8 -> 8
CacheMissCacheControl (MemoryStore) 990.6 / 921.5 996.0 / 923.3 1053.0 / 966.6 524 -> 543, 10 -> 10
CacheHit 309.0 / 291.4 302.3 / 287.9 306.9 / 293.3 4 -> 4
CacheMissMemoryKV888 (bare *store.MemoryKV, exempt) 865.1 / 817.9 917.8 / 801.2 882.2 / 807.9 9 -> 9
CacheMissOtherStore888 (a store the bound applies to) 784.1 / 739.4 787.6 / 737.9 2156.5 / 1921.0 297 -> 1103 B, 9 -> 20
CacheMissNoSingleflightOtherStore888 804.2 / 725.9 791.7 / 728.8 2245.5 / 1993.0 289 -> 1084 B, 8 -> 19

The MemoryStore miss rows are about 4% slower. Review r1, pairing the rounds (14 rounds, CacheMissSingleflight +4.1%, CacheMissNoSingleflight +4.5%, CacheMissCacheControl +0.7%, against an A/A twin of -1.8%, -1.9%, -0.3%): only CacheMissNoSingleflight separates cleanly from noise, CacheMissSingleflight points the same way with overlapping ranges; allocations are unchanged. The code difference on this path is one extra function call (fill, not inlined) and two type-word compares, a few ns, so most of the roughly 30 ns is probably code placement. A second layout (direct assertions) measured the same. The grouped timing run is still owed. The bound itself costs about 1.4 µs and 11 allocations (+800 B) per fill on a store whose Set takes nothing, which is why the two in-process stores are exempt (exact types); a wrapper of one pays it (next paragraph); a remote store's Set is a network round trip (100 µs and up), so there it is about 1%. The first version (no exemption) put MemoryKV at +180%.

Wrappers pay the bound (review r1, m4): any store that wraps an in-process one, including the repo's own store.Prefixed (middleware/store/prefix.go), is not the exact type and is bounded: CacheMissOtherStore888 (a struct embedding *store.MemoryKV) is +175% paired (A/A -3.9%), 789 to 2204 ns, 9 to 20 allocs, 297 to 1098 B. Stated in the field's godoc. Abandoned Sets accumulate (m6): with a store that ignores its context and never returns, each abandoned Set keeps a goroutine and holds the response body until it returns; review r1 measured 2000 misses at SetTimeout 1 ms: 12 to 2012 goroutines and +37 MiB live heap (about 19 KiB per Set for a 16 KiB body; up to MaxBodyBytes, 1 MiB by default, per Set), with or without singleflight. celeris's own Redis and Memcached drivers honour the deadline (driver/redis/conn.go, driver/memcached/conn.go), so only a third-party store that ignores its context is affected; on main the same store held every request for ever. Stated in the godoc. A panic in an abandoned Set (m5) is logged at Error level through slog.Default with the store's type, the value and the stack (the cache package has no error hook; Config has no logger for middleware); TestSetTimeoutPanicAfterTheBoundIsReported888, red before commit 9.

sse (888.24): (final code, 14 rounds, bench/sse-rot.table.txt; the Queued rows from the previous 14-round run of the same code paths, bench/sse-rot-v3-assert-in-ring.table.txt)

benchmark (100 events per op) main median / min main B branch median / min B/op, allocs main -> branch
SendBurstQueuedWrapRing888 (a decorator around the ring, queued) 27 872 ns / 23 416 25 254 / 23 003 22 862 / 20 762 50 380 -> 42 426 B, 435 -> 336
SendBurstRing732 (blocking, bare ring: the only changed path that every ring user pays) 16 522 / 14 011 15 489 / 13 831 15 054 / 13 865 331 -> 331
BurstEvents (blocking, no store) 9 483 / 7 865 8 611 / 7 932 8 460 / 8 078 131 -> 131
SendBurstQueued732 (queued, no store) 17 959 / 16 111 17 968 / 16 025 18 562 / 16 303 235 -> 235
SendBurstQueuedRing732 (queued, bare ring) 22 681 / 18 517 20 454 / 18 460 21 855 / 18 604 335 -> 335

The medians of this machine moved by up to 12% between main and its own twin in one run; the minimums are flat (within 1.5%) on every row but the wrapped ring, which is 2 to 3 allocations per event lighter. Two earlier layouts of the check are in bench/: marking Event itself (unexported field) made BurstEvents +20% in two separate runs (the 16 bytes push Send's by-value Event out of the registers), and asserting the context inside a helper made SendBurstRing732 +9% to +15% in medians and +9% in minimums in two runs; both were replaced. Not claimed: "no regression" on the medians; this run does not show one on the minimums.

static (888.46), 8 counts, 1 s, no main/branch pair (no code change; the row is new): 304 for a file below a symlinked directory inside the Root 6 251 ns, 54 allocs, 5 057 B, against 2 193 ns, 15 allocs, 1 496 B for the same file below a plain directory, and 2 604 ns, 17 allocs for the existing 4-element-deep row (bench/static-symlinked-dir.txt): 2.9 times a plain 304 for two EvalSymlinks.

Findings, not fixed here

  1. static with a Root of ., ./ or / serves nothing below the root (found writing the 888.46 tests; user-visible, bigger than the doc minor that item asked for). serveOS compares the joined path with cleanRoot + "/", which no path below "." or "/" starts with, so every such request is passed to the next handler (a 404 behind it), unconditional or conditional. For "." and "./" the root directory's own request is the one that gets through: 400 from FileFromDir (it requires the "./" prefix a Root of "." lacks), 304 when a validator matches (the static middleware's own resolvesInside accepts it). Root: "./public" and any named directory are unaffected. Not fixed. The doc now states it as the current limitation, and TestRootDotAndSlashServeOnlyTheRootIndex888 pins the current behaviour until the decision below; if the behaviour changes, the test and the doc change with it.
  2. The two defects at the top (h2c SSE and ReadTimeout; inline h2 route holds its loop).
  3. std, h2c, a client that reads nothing with a 1 GiB window: OnDisconnect did not run in 26 s with WriteTimeout 1 s (logs/nonread-h2c-1.log; default window: 1.4 s). std is not this lane's engine; the test has no such cell.
  4. 884.79 gaps (2) and (3) stay as listed (no tcp_info socket; io_uring fixed-file mode): engine files, and nothing here runs io_uring. No io_uring cell of any new test has run anywhere yet: Docker Desktop's kernel reports tier=none. They run first in this PR's CI; every io_uring statement in this PR and in the docs is by reading the code.
  5. Cache: Get is not bounded (SetTimeout covers Set only); a hung Get still holds the request. On epoll and io_uring a sync leader whose store hangs is held for SetTimeout (default pending) on the loop; a short value matters.
  6. negotiate: one list element with many parameters (a;q=1;q=1;...) is not bounded: no heap, CPU linear in the header, 185 µs per call for 64 KiB on main and here (BenchmarkAcceptLongHeader888/params), so about 48 ms for the 16 MiB std accepts by default.
  7. The two findings already drafted for refactor!: remove Context.Stream and the bool form of Route/RouteGroup.Async, make recovery ErrorHandler take an error; add ErrStreamRefused, DisableAutoHEAD/OPTIONS; join Cookie lines (Refs #889 #884 #891) #1109 (m1-issue-draft.md: the singleflight/FlushResponse empty-Content-Type body drop; the cache HIT Content-Type) are untouched. 888.30 changes fill/boundedSet in cache.go, not replay or the stored headers; the new cache tests assert body and x-cache only.
  8. 888.48's fourth site, internal/engine/epoll/detach_inline_regression_linux_test.go:131, is in the engine lane's directory and was not touched.

Decisions pending (for the maintainer; none is taken here)

D1. 888.30, SetTimeout: the default and the shape. The default is one constant, defaultSetTimeout in middleware/cache/config.go, plus the sentence "Zero selects the default, 5 seconds," in the field's godoc (a test fails if they disagree); the value in the tree is a placeholder, and the cache work is separate commits (7, 8, 9). Review r1 measured (epoll, Workers 2, a store whose Set ignores its context, a sync route) that a hung Set holds its event loop for the whole bound: with SetTimeout 1 s the miss is answered after 1.002 s and a fresh request that lands on that loop waits 951 ms; with 3 s, 3.001 s and 2.95 s. Every miss on a sync route holds its loop for the full bound, so a longer value is a longer dead loop per hung fill. The reviewer recommends 1 s (Redis and Memcached Sets are sub-millisecond on a healthy link and the drivers honour the deadline). Still open: whether a wrapper such as store.Prefixed of an in-process store stays bounded (+175%, 9 to 20 allocs per fill), whether Get gets a bound (a hung connection hangs Get before Set is reached), whether to cap the number of abandoned Sets (m6: past a cap, skip the Set and count the skips), and whether the godoc keeps the number.

D2. static Root ., ./, /. Serves nothing below the root; GET / is a 400 without a validator and a 304 with one. The guard (static.go, strings.HasPrefix(fullPath, cleanRoot+"/")) dates from 4b931fe (v1.3.3), so it is long-standing, not a v1.6.0 regression (the new test file passes unmodified on main). The reviewer recommends fixing it, about 3 lines (compare with cleanRoot as it is for "/" or a root ending in a separator, make "." absolute once with filepath.Abs, and relax FileFromDir's "./" requirement for "."), over documenting it; this PR documents it and pins it with TestRootDotAndSlashServeOnlyTheRootIndex888.

D3. The two h2c SSE engine defects. (i) ReadTimeout and IdleTimeout close an h2 connection whose client sends no frame for that long, whatever streams it has open: every h2c SSE stream through a quiet client dies at 60 s by default on epoll and Adaptive (reproduced, 8 of 8 cells at ReadTimeout 1 s; io_uring by reading only, where the WriteTimeout arm is also measured from the last read when a send is in flight). The reviewer sketched a fix: skip the Idle and Read arms for an h2 conn with CountActiveStreams() > 0 in both sweeps (the count includes half-closed streams, so SSE is covered), reading cs.h2State inside the snapshot that holds detachMu; not implemented. The native h2c cells of TestSSEStaysOpenPastServerTimeouts855 would then take the read and both arms. (ii) An inline h2 route holds its event loop for the life of the stream: documented here; a guard in sse (refuse, or run the stream async) is a choice.

D4. The negotiate cap and a q=0 past the 64th entry. A client that sends 64 entries and then gzip;q=0 is served gzip: self-harm only, documented, and no browser does it, but RFC 9110 12.5.3 makes a q=0 refusal binding. If the deviation is unwanted, the reviewer's safe option is for encodings only: when ParseInto stops at the cap with header left, AcceptsEncodings returns "" (compress then sends identity); not for Negotiate or AcceptsLanguages, where "" means something else; and with a truncation marker in middleware/singleflight/canonical.go's canonicalAppend, otherwise a 64-entry header and the same plus a 65th would share a key and get different answers. Raising the cap only moves the edge. Keeping the current behaviour with its doc is also viable for v1.6.0.

Verification (local)

Docker golang:1.27.3, --cpus 2, one container at a time, linux/arm64. Docker Desktop's kernel has no io_uring (tier=none): every io_uring cell skipped or failed to start here, as on main (logs/env-failures-on-main.log: TestSSEHeadGetsHeadersOnly421, TestOnConnectRejectionReachesClient835 and the three cache ...913 / ...921 parents fail the same way on main).

  • go vet ./... clean; golangci-lint v2.14 with the repo config on the root module: 0 issues; apidump (what mage apiCheck runs): api/ matches the exported API of 53 packages (only api/middleware/cache.txt changed, SetTimeout appended at the end of Config, so no positional literal changes).
  • go test -race -count=1 ./middleware/... ./internal/negotiate/... (one run): everything ok, including middleware/websocket (580 s), except the io_uring-dependent tests above. test/server -run TestSSEStaysOpenPastServerTimeouts855 -race: all epoll, adaptive and std cells pass, io_uring cells fail to start (environment).
  • -count=10 of the new timing-sensitive tests (TestSSEOverH2C*, TestNonReadingH2CClientEndsStream884, TestSlowClientBlockQueuesTheCopy888, the wrap tests, TestSetTimeout*, negotiate, singleflight): all green. TestAcceptHeapDoesNotGrowWithTheHeader888 also under -race and -cover (it reads process-wide MemStats). The alloc-guard tests the CI runs without -race (TestRingBufferAppendAllocs, TestSlowClientSendNonFullQueueAllocs, TestSlowClientDropPathAllocs888, TestBrokerFanOutFormatsOnce, cache TestAllocBudgets with TESTING_STRICT_ALLOC_BUDGETS=1) pass.
  • Round 2 (commits 8 to 11): go vet ./..., golangci-lint 0 issues, apidump matches (53 packages, api/ unchanged by round 2); -race -count=5 of TestSetTimeout* and TestDefaultSetTimeoutIsInTheDoc888 (cache), TestClassifyStartTable and TestWebSocketUpgradeOnNativeEngines (websocket), TestSSEOverH2C* and the h2c tests (sse): all green. The m5 test is red without commit 9 and with the report removed (mutant).
  • middleware/metrics (a nested module, files untouched): go vet and go test ok.
  • CI: no by-name or tally step of ci.yml names a test I added or renamed; the root -race step's skip allow-list: the new tests do not skip (an engine that does not exist on a runner skips through the existing start helpers, as before). ci.yml untouched; the 11 required check names are unchanged.
  • CI at head 8d4228f (round 1; round 2 is below if present): 30 checks pass, 2 skipping (the macro benchmark runner and CodeRabbit). Conformance failed once (expected no SKIP line, TestConformanceMatrix/iouring/http1/BasicMethods) and passed on the re-run of that job. It is not this PR's and not the runner's memlock (corrected after review r1): an io_uring server and an epoll server bound the same port (45049) in test/conformance (freePort closes its listener and returns the port; the engines rebind it with SO_REUSEPORT), so the HTTP/1 readiness probe of one cell was answered with an HTTP/2 SETTINGS frame by the other server. A flake of test/conformance, which this PR does not touch; found by review r1; no tracker found.
  • CI at head 83ed14e (round 2): 29 checks pass, 2 skipping (the macro benchmark runner and CodeRabbit), none failed.

Release notes

Changed (cache): a new cache.Config.SetTimeout bounds each Store.Set ... (pending the default; leave out of the notes until it is decided)
Changed (sse): over HTTP/2 an SSE route must be registered .Async() to stream on epoll, io_uring and Adaptive (documented). A ReplayStore that wraps NewRingBuffer no longer makes a second copy of each queued event.
Changed (negotiate): Context.AcceptsEncodings, AcceptsLanguages and Negotiate read the first 64 entries of the header; an entry after the 64th is not seen.

For the reviewer to attack

  • ownedCtx trust model (middleware/sse/event.go). The ring keeps a queued event's strings only if the Append context is the drain's *ownedCtx and Event and Data are, by address and length, the strings the drain named just before. A decorator that derives its own context loses the saving and is copied (TestWrapperThatDerivesItsContextGetsACopy888); one that changes a string is copied (the guards). The slot is written by the drain and read by the ring on any goroutine, with four atomics; a torn read can only name bytes of an owned, immutable copy. Try to get the ring to keep a view of a request string. A first version marked Event itself (an unexported field): 16 bytes more pushed Send's by-value argument out of the registers (BenchmarkBurstEvents +20% in two runs, logs: bench/sse-queued.table.txt, bench/sse-rot-OWNFIELD-superseded.table.txt), so it was dropped.
  • boundedSet: the goroutine and the buffer ownership on abandon (the pool buffer is not returned; the key is cloned; enc of a coalesced fill is read-only for the followers), a panic raised again on the caller (the store's stack is lost), cancel after the send on done, a request context that ends together with the Set (the return wins).
  • defaultSetTimeout in public godoc, the exact-type exemption of the two in-process stores (a type embedding one is bounded).
  • negotiate cap: a header with exactly 64 entries and the 65th being the refusal; maxSegments (256) for empty elements.
  • Timing thresholds in the new tests: TestSSEOverH2C* (700 ms / 500 ms), TestNonReadingH2CClientEndsStream884 (WriteTimeout 1 s, 5 s slack, lower bound WriteTimeout for the native cells), TestSetTimeout* (150 ms bound). -count=10 below; the io_uring cells are untested.
  • The doc claims in middleware/sse/doc.go (HTTP/2 paragraphs) against the probes; the statements about ReadTimeout are from the epoll sweep code and a 1 s measurement on epoll and Adaptive.

@FumingPower3925 FumingPower3925 added this to the v1.6.0 milestone Oct 27, 2026
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

… is unbounded (red on main; Refs #888 items 24, 42)

Failing tests, one per finding, no fix yet:
- TestWrappedRingKeepsTheQueuedCopy888: a ReplayStore that wraps NewRingBuffer
  gets the queued copy copied a second time (the ring cannot tell the event
  is owned). Two guards pass on main and must keep passing: a wrapper that
  puts its own string into the event still gets a copy.
- internal/negotiate parse_bound_888_test.go: ParseInto returns one entry per
  list element, so a 64 KiB header of 'a,a,a,...' costs 3.8 MB per Accept.
…eInto reads at most 64 entries (Refs #888 items 24, 42)

sse: for a ReplayStore other than the bare ring, the drain gives the store a
context (ownedCtx) that names the strings of the event it is appending, which
sendQueued copied before it queued the event. The ring keeps an event whose
Event and Data are those very strings (address and length) without a second
copy, so a decorator that wraps NewRingBuffer and forwards the event and its
context no longer doubles the copies: 435 -> 336 allocs per 100 events. An
event the decorator changed, or one appended with another context, does not
match and is copied. No field is added to Event: a first version marked the
Event itself, and the 16 bytes pushed Send's by-value argument out of the
registers (BenchmarkBurstEvents +20%).

negotiate: ParseInto keeps the first 64 entries and walks at most 256 list
elements. A 64 KiB Accept-Encoding of 'a,a,a,...' allocates a few KB, not
3.8 MB. Entries past the 64th are not seen (documented, tested).
… the doc for those roots, add the symlinked-directory 304 row (Refs #888 item 46)

Tests and documentation only (no behaviour change): the escape cases of the 304
tests now run with a Root of '.' and './'; a Root of '.', './' or '/' serves
nothing below the root (every such request is passed on) and its root index
gets a 400 without a validator and a 304 with a matching one, which the doc
now states and TestRootDotAndSlashServeOnlyTheRootIndex888 pins.
BenchmarkStaticServeFromRootNotModifiedSymlinkedDir is the cost row for a 304
under a symlinked directory inside the Root. One Config.Compress doc line is
rewrapped.
…ent (Refs #888 item 47)

Test only. The OnSlowClient callback changes the caller's bytes and then opens
the gate itself, so the blocked Send is released only after the change; the
wire must carry the original strings. Mutant M3 of the finding (the block arm
queues the uncopied event) passes the whole package without it and fails this
test alone.
…d the doc for both (Refs #889 item 34, #884 item 79)

Tests and documentation only; the probe (below) found no flush to add.

889.34: over h2c on epoll and Adaptive the headers and the first event reach the
client after 1 ms on a route registered .Async(), and after 3.06 s (the handler's
return) on one that is not, with or without Config.AsyncHandlers; std streams
either way. An inline HTTP/2 handler runs on the event loop, so Flush cannot
send anything, and the stream holds the loop (4 of 12 requests to other
connections took 0.5 to 2.7 s). The sse doc now says to register the route
.Async(); TestSSEOverH2CStreamsOnAnAsyncRoute889 and
TestSSEOverH2CAsyncRouteDoesNotHoldTheLoop889 pin the async route, and the
native engines get h2c cells (WriteTimeout arm) in TestSSEStaysOpenPastServerTimeouts855.
The ReadTimeout arm is cut at ReadTimeout on epoll and Adaptive (found by the
cell, not fixed here).

884.79 gap 1: TestNonReadingH2CClientEndsStream884 drives an h2c client that
reads nothing after the headers: the stream ends 1.4 s (default window) and
1.7 s (1 GiB window) after connect with WriteTimeout 1 s on epoll.
…t fails with ENOMEM instead of skipping (Refs #888 item 48)

Three of the four remaining places named by the item (the fourth is in
internal/engine/epoll and is left to the engine lane): TestWebSocketUpgradeOnNativeEngines,
wsrstTorture and detachedCountWindow built the server once and skipped when the
Start error named io_uring. startWS888 builds the server and the listener per try and
retries for 10 s when io_uring exists on the host (ENOMEM from RLIMIT_MEMLOCK
charged to another test binary), fails after the window, and skips only where
io_uring does not exist, as startSSEServer and startLoginServer do.
TestClassifyStartTable pins the decision. Test files only.
…r's decision on the default (Refs #888 items 30, 32)

Separate last commit: drop it to leave the default undecided. The field, the
bound and the tests are here; the default value is one constant,
defaultSetTimeout in middleware/cache/config.go, with the sentence in the
field's doc that names it (TestDefaultSetTimeoutIsInTheDoc888 fails if they
disagree).

A Store.Set that never returns held the leader request for ever (the request
context of a native HTTP/1 request never ends), and on epoll and io_uring the
event loop a sync leader runs on. The Set now runs on its own goroutine for
every store but *MemoryStore and *store.MemoryKV (in-process, a lock only), and
the wait for it is bounded, whether or not the store honours the context, which
carries the same deadline. Zero selects the default, a negative value is no
bound (the previous behaviour). The bound costs 1.4 us and 11 allocations per
fill on a store whose Set takes nothing (measured; the two in-process stores
pay nothing).

An abandoned Set keeps running if the store ignores its context: its bytes are
not returned to the encode-buffer pool, its key is copied, a panic in it is
raised again on the request, and the late write it may make is the overlap
Config.Store already documents (item 32: now also named there).

Failing-first is not possible for the field's own tests: they use the new field
and do not compile on main. TestUnsetSetTimeoutBoundsTheStoresContext888 uses
only API main has, and is red there. The mutants (bound off, buffer recycled,
key not copied, panic swallowed) each turn a named test red.
@codecov

codecov Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…eported (red; Refs #888 item 30)

Review r1 of #1131 (m5). On main that panic reached the request; with SetTimeout
it is recovered on the Set's goroutine and dropped. The test expects an Error
record with the store's type and the panic value, and the request unaffected.
A second test pins that a panic received within the bound is raised, not logged.
…cument the cost of wrappers and of abandoned Sets (Refs #888 item 30)

Review r1 of #1131 (m4, m5, m6, n1). Part of the SetTimeout commit's subject:
drop it with that commit (it only touches what that commit added).

- m5: the Set's goroutine and the waiter agree on who owns the result with one
  atomic state. A panic that arrives while the request waits is raised on it, as
  before; one that arrives after the request gave up is logged at Error level
  through slog.Default with the store's type, the value and the stack.
  TestSetTimeoutPanicAfterTheBoundIsReported888 is red without this.
- m4: the field's godoc says that a wrapper of an in-process store, including
  store.Prefixed, is bounded and pays about 1.4 us and 11 allocations per fill.
- m6: the godoc says that an abandoned Set of a store that ignores its context
  keeps a goroutine and the response body until it returns, with no cap
  (2000 misses: 2000 goroutines, about 37 MiB), and that celeris's drivers honour
  the deadline.
- n1: the godoc no longer names the unexported constant.
…nt's last frame (Refs #889 item 34)

Review r1 of #1131 (m1). The 0.8 s of the earlier wording was measured from the
first event; from the request the stream closes at 1.0 s with ReadTimeout 1 s.
Names IdleTimeout (from the code, not measured), the cause (the engines' sweep
applies both to an h2 conn whatever streams it has open), that std is not
affected and that it is an engine defect.
…try (Refs #888 item 48)

Review r1 of #1131 (m7). probeIOUring retries for 10 s under
CELERIS_REQUIRE_IOURING_WORKERS=1 as c714ProbeIOUring does (that helper is in
package websocket and not visible from websocket_test).
@FumingPower3925
FumingPower3925 marked this pull request as ready for review October 27, 2026 07:33
@FumingPower3925
FumingPower3925 merged commit c65680f into main Oct 27, 2026
31 checks passed
@FumingPower3925
FumingPower3925 deleted the l16-middleware branch October 27, 2026 07:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant