Repository navigation
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
Merged
Conversation
|
Caution Review failedThe pull request is closed.
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. Comment |
… 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.
FumingPower3925
force-pushed
the
l16-middleware
branch
from
October 27, 2026 03:51
3da66ab to
8d4228f
Compare
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.
FumingPower3925
marked this pull request as ready for review
October 27, 2026 07:33
This was referenced Oct 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
TestSSEStaysOpenPastServerTimeouts855had 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 fromlastActivity, 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..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 inmiddleware/sse/doc.go; a code guard (sse refusing, or going async by itself, on an inline h2 stream) is a decision, not built.Items
StreamWriterhandler) over h2c on epoll, io_uring and adaptive shows no response headers and no body until the handler returns"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.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").NewRingBuffer... copies each event twice"Store.Setthat never returns still wedges the leader request"cache.go,thenruns insideDo,kv.Set(ctx)unbounded; ctx never ends on native H1)Set"Config.Storedoc names the overlap, andMemoryStoreorders the write (setIfNewer). Nothing to do for it.ParseIntois unbounded"ClientPolicyBlockqueues the copy""io_uring"start helper survives in four more places"websocket_engine_linux_test.go:87,detached_count_window_linux_test.go:178,wsrst_torture_linux_test.go:89); the 4th is ininternal/engine/epolland left to the engine laneCommits (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
SetTimeoutwork 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), dockergolang: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:Config.AsyncHandlers.Async()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 onFlushfor 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
TestWrappedRingKeepsTheQueuedCopy888fails on main (8 of 8 events: "the ring holds a second copy of Data"); guardsTestWrapperThatReplacesDataStillGetsACopy888/...EventTypeStillGetsACopy888pass on main and must keep passing.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-cappasses). Logs:logs/red-main-singleflight.log,logs/red-main-888-24-42.log.TestUnsetSetTimeoutBoundsTheStoresContext888uses only API main has, leavesSetTimeoutunset, 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.Mutants (each applied to the branch, the named tests run, file restored)
oc.ownsand false: main's behaviour)TestWrappedRingKeepsTheQueuedCopy888TestWrapperThatReplacesDataStillGetsACopy888,TestWrapperThatReplacesEventTypeStillGetsACopy888true)...888tests ofparse_bound_888_test.goorig)middleware/ssepackage: the newTestSlowClientBlockQueuesTheCopy888fails, and only the two io_uring tests that fail without the mutant in this environmentresolvesInsideaccepts everything when the root is "."TestRoot304DotRootDoesNotFollowSymlinkOut888(the root-index cases)TestClassifyStartTableok || true)TestSetTimeoutFreesTheLeaderFromAHungSet888(the package then runs into its timeout, the leader being held)TestSetTimeoutAbandonedSetKeepsItsBytes888TestSetTimeoutAbandonedSetKeepsItsKey888TestSetTimeoutSetPanicReachesTheLeader888Logs:
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,-benchtime0.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/*.txtin 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:
AcceptLongHeader888/entries(64 KiB ofa,)AcceptLongHeader888/weighted(64 KiB ofa;q=0.5,)AcceptLongHeader888/commas(64 KiB of,)AcceptTypical888/browserAcceptTypical888/weightedAcceptTypical888/star-firstAcceptTypical888/star-q0,refused,one,AcceptMedia888cache (888.30) miss path, 12 rounds, 1 s (the final code;
bench/cache-miss-v2-typeswitch.table.txt):CacheMissSingleflight(MemoryStore)CacheMissNoSingleflight(MemoryStore)CacheMissCacheControl(MemoryStore)CacheHitCacheMissMemoryKV888(bare*store.MemoryKV, exempt)CacheMissOtherStore888(a store the bound applies to)CacheMissNoSingleflightOtherStore888The 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%): onlyCacheMissNoSingleflightseparates cleanly from noise,CacheMissSingleflightpoints 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) putMemoryKVat +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 atSetTimeout1 ms: 12 to 2012 goroutines and +37 MiB live heap (about 19 KiB per Set for a 16 KiB body; up toMaxBodyBytes, 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 throughslog.Defaultwith the store's type, the value and the stack (the cache package has no error hook;Confighas 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)SendBurstQueuedWrapRing888(a decorator around the ring, queued)SendBurstRing732(blocking, bare ring: the only changed path that every ring user pays)BurstEvents(blocking, no store)SendBurstQueued732(queued, no store)SendBurstQueuedRing732(queued, bare ring)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/: markingEventitself (unexported field) madeBurstEvents+20% in two separate runs (the 16 bytes pushSend's by-valueEventout of the registers), and asserting the context inside a helper madeSendBurstRing732+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 twoEvalSymlinks.Findings, not fixed here
staticwith 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).serveOScompares the joined path withcleanRoot + "/", 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 fromFileFromDir(it requires the "./" prefix a Root of "." lacks), 304 when a validator matches (the static middleware's ownresolvesInsideaccepts it).Root: "./public"and any named directory are unaffected. Not fixed. The doc now states it as the current limitation, andTestRootDotAndSlashServeOnlyTheRootIndex888pins the current behaviour until the decision below; if the behaviour changes, the test and the doc change with it.OnDisconnectdid 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.tcp_infosocket; 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.Getis not bounded (SetTimeoutcoversSetonly); a hungGetstill holds the request. On epoll and io_uring a sync leader whose store hangs is held forSetTimeout(default pending) on the loop; a short value matters.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.m1-issue-draft.md: the singleflight/FlushResponseempty-Content-Type body drop; the cache HIT Content-Type) are untouched. 888.30 changesfill/boundedSetincache.go, notreplayor the stored headers; the new cache tests assert body andx-cacheonly.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,defaultSetTimeoutinmiddleware/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: withSetTimeout1 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 asstore.Prefixedof an in-process store stays bounded (+175%, 9 to 20 allocs per fill), whetherGetgets a bound (a hung connection hangsGetbeforeSetis 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.
staticRoot.,./,/. 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 withcleanRootas it is for "/" or a root ending in a separator, make "." absolute once withfilepath.Abs, and relaxFileFromDir's "./" requirement for "."), over documenting it; this PR documents it and pins it withTestRootDotAndSlashServeOnlyTheRootIndex888.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() > 0in both sweeps (the count includes half-closed streams, so SSE is covered), readingcs.h2Stateinside the snapshot that holdsdetachMu; not implemented. The native h2c cells ofTestSSEStaysOpenPastServerTimeouts855would 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=0is 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: whenParseIntostops at the cap with header left,AcceptsEncodingsreturns "" (compress then sends identity); not forNegotiateorAcceptsLanguages, where "" means something else; and with a truncation marker inmiddleware/singleflight/canonical.go'scanonicalAppend, 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,TestOnConnectRejectionReachesClient835and the three cache...913/...921parents fail the same way on main).go vet ./...clean;golangci-lintv2.14 with the repo config on the root module: 0 issues;apidump(whatmage apiCheckruns):api/matches the exported API of 53 packages (onlyapi/middleware/cache.txtchanged,SetTimeoutappended at the end ofConfig, so no positional literal changes).go test -race -count=1 ./middleware/... ./internal/negotiate/...(one run): everythingok, includingmiddleware/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=10of the new timing-sensitive tests (TestSSEOverH2C*,TestNonReadingH2CClientEndsStream884,TestSlowClientBlockQueuesTheCopy888, the wrap tests,TestSetTimeout*, negotiate, singleflight): all green.TestAcceptHeapDoesNotGrowWithTheHeader888also under-raceand-cover(it reads process-wide MemStats). The alloc-guard tests the CI runs without-race(TestRingBufferAppendAllocs,TestSlowClientSendNonFullQueueAllocs,TestSlowClientDropPathAllocs888,TestBrokerFanOutFormatsOnce, cacheTestAllocBudgetswithTESTING_STRICT_ALLOC_BUDGETS=1) pass.go vet ./..., golangci-lint 0 issues,apidumpmatches (53 packages,api/unchanged by round 2);-race -count=5ofTestSetTimeout*andTestDefaultSetTimeoutIsInTheDoc888(cache),TestClassifyStartTableandTestWebSocketUpgradeOnNativeEngines(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 vetandgo testok.ci.ymlnames a test I added or renamed; the root-racestep'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.ymluntouched; the 11 required check names are unchanged.Conformancefailed 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) intest/conformance(freePortcloses 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 oftest/conformance, which this PR does not touch; found by review r1; no tracker found.Release notes
For the reviewer to attack
ownedCtxtrust model (middleware/sse/event.go). The ring keeps a queued event's strings only if theAppendcontext is the drain's*ownedCtxandEventandDataare, 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 markedEventitself (an unexported field): 16 bytes more pushedSend'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;encof a coalesced fill is read-only for the followers), a panic raised again on the caller (the store's stack is lost),cancelafter the send ondone, a request context that ends together with the Set (the return wins).defaultSetTimeoutin public godoc, the exact-type exemption of the two in-process stores (a type embedding one is bounded).maxSegments(256) for empty elements.TestSSEOverH2C*(700 ms / 500 ms),TestNonReadingH2CClientEndsStream884(WriteTimeout 1 s, 5 s slack, lower bound WriteTimeout for the native cells),TestSetTimeout*(150 ms bound).-count=10below; the io_uring cells are untested.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.