fix(session): emit the session cookie before the response body is written - #509
Merged
Conversation
ResponseRecorder retained the header slice Context.Blob passed to WriteResponse. That slice aliases the Context's inline header buffer, which keeps being mutated after the write: a SetCookie issued after the body was written (the shape of a post-chain middleware) overwrote the recorded content-type in place, so tests saw a Set-Cookie that never reached the wire and lost one that did. Copy the headers so the recorder is a faithful snapshot of what hit the wire.
…tten The middleware emitted Set-Cookie (or the session-id header in extractor mode) only after c.Next() returned. celeris materialises the response headers on the wire the moment a handler calls c.JSON/String/Blob, so any header added afterwards is silently dropped: a login handler doing sess.Set + sess.Save + c.JSON(200, ...) returned the session id in the body with no Set-Cookie, the client never got a session and every authenticated request 401'd. probatorium's auth_session_ratelimit soak showed this as 96% 4xx, a re-login on every request and one persisted session per login (5 GB heap in 10 minutes). The existing tests passed only because their handlers wrote no body. Emit the cookie at the FIRST of: any mutator (Set, Delete, Clear, SetIdleTimeout), Save, Regenerate (re-emits with the new id, replacing the old one), Destroy (clearing cookie), or, under SaveUnmodified, before the handler runs for a fresh session. The per-request Session carries the middleware's cookie template plus emitted-id/clearing flags (reset on pool return); exactly one session Set-Cookie ever ends up on a response. The post-chain emission stays as the fallback for no-body handlers. Persistence, write-behind and SaveUnmodified semantics are unchanged. A mutation after the body was written cannot set a cookie any more: it no longer panics or queues an undeliverable header, and is counted once per request in session.DroppedCookies() and, under -tags=validation, in the new validation.SessionCookieDrops counter so the soak validator can see it. Package and method docs now state when the cookie is emitted and the mutate-before-write caveat. Tests: recorder-level coverage of every shape (Set+body, Set+Save+body with and without write-behind, SaveUnmodified fresh+body, Destroy+body, Regenerate/Reset+body, header-extractor mode, https Secure, late mutation), an exactly-one-Set-Cookie table over body and no-body shapes, a std-engine wire round trip (login -> cookie -> authenticated GET -> logout) that runs everywhere, and its io_uring/epoll variant on linux mirroring the probatorium refapp.
… on a failed save DroppedCookies over-reported: a loaded session mutated after the body was written counted as a drop although the client already holds that exact cookie and the data is persisted under the same id (only the Max-Age refresh is lost). Record the id the request arrived with (presentedID) and count a drop only when the undelivered header would change what the client holds: a fresh or regenerated id, or the clearing cookie. Early emission also changed the failed-save contract: store.Set / EncodeJSON failures returned through ErrorHandler with a Set-Cookie for an id the store never received (main sent none). Retract that cookie (or the session-id header in extractor mode) before ErrorHandler so the failed response carries no session cookie, matching main; a loaded session's own id stays since it is still valid. Write-behind cannot fail synchronously and keeps the cookie, by design. Documented in doc.go, DroppedCookies, and the validation counter docs; pinned by tests for both findings on the sync, encode, header-extractor, and write-behind paths.
The traceparent response header was injected after c.Next(); celeris materialises response headers on the wire as soon as the handler writes a body, so the header never reached the client (the same defect class as the session cookie in #507). The recorder fix in this branch made the existing TestResponseHeaderPropagation tests observe the wire and fail; injecting before the handler runs makes them pass and the header real.
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.
What
middleware/sessionemitted the sessionSet-Cookie(or the session-id response header in extractor mode) only afterc.Next()returned. celeris writes the response headers to the wire the moment a handler callsc.JSON/String/Blob(Context.Blobflipsc.writtenand materialisesrespHeaders), so a header added after the handler returned was silently dropped.Consequence: a login handler doing
sess.Set("user", ...); sess.Save(); return c.JSON(200, ...)produced a 200 with the session id in the body and noSet-Cookie. The client never got a session and every authenticated request 401'd. Verified on v1.5.9 and v1.5.10, std engine locally and io_uring on the bench cluster. In probatorium'sauth_session_ratelimitsoak this showed as 96% 4xx, a re-login on every request and one persisted session per login (5 GB heap in 10 minutes). The existing tests (e.g.TestFreshSessionExplicitSaveEmitsCookie) passed only because their handlers return without writing a body.Fix
Emit the cookie before the body can be written, keeping the post-chain path as a fallback:
Sessionnow carries the middleware's resolved cookie template, cookie name, extractor mode andemittedID/clearSent/cookieDropflags (installed beforec.Next(), reset inreturnToPool).markModified(), which emits the cookie at the first mutation:Set,Delete,Clear,SetIdleTimeout, a successfulSave, andRegenerate(re-emits with the new id; the earlier cookie is replaced, never duplicated). UnderSaveUnmodifieda fresh session's cookie is emitted before the handler runs.Destroy()emits the clearing cookie (Max-Age=0) immediately, replacing any session cookie emitted earlier in the request; no session cookie is emitted afterwards.c.SetHeader(cookieName, sess.id).Set-Cookieheaders.session.DroppedCookies()and, under-tags=validation, invalidation.SessionCookieDrops(session_cookie_dropsin the snapshot JSON) so probatorium's validator can observe it.Secureis still upgraded under TLS/https (c.IsTLS()), as before.doc.gohas a new "When the cookie is emitted" section with the mutate-before-write caveat;Set,Save,Destroy,Regenerate,Config.SaveUnmodifiedandConfig.WriteBehindcomments updated.Harness fix (first commit)
celeristest.ResponseRecorderretained the header sliceContext.Blobpasses toWriteResponse. That slice aliases the Context's inline header buffer, which keeps being mutated after the write: a post-writeSetCookieoverwrote the recordedcontent-typein place, so recorder-based tests saw aSet-Cookiethat never reached the wire (probe on main:[[content-type text/plain] [content-length 2]]became[[set-cookie late=x] [content-length 2]]). The recorder now snapshots the headers;TestRecorderHeadersAreAWireSnapshotpins it. This is why no unit test could ever have caught the session bug.Fail-first evidence (against unfixed
session.go, with the recorder fix applied)Std-engine wire round trip:
Recorder-level tests (every body-writing shape fails; the three no-body shapes pass on main, as expected):
TestMutationAfterBodyWrittenDropsCookieOnce(test f) does not compile against main (DroppedCookiesundefined); it pins no-panic / no header / exactly one count / unchanged persistence / clean pool reuse.Tests
New:
cookie_emit_test.go(a–e, g, header-extractor mode, https Secure, read-only stays cookieless),cookie_drop_test.go(f),cookie_drop_validation_test.go(validation counter,-tags=validation),engine_roundtrip_test.go(std-engine login → cookie → authenticated GET → logout → 401 over real TCP with a cookie jar, runs on every OS),engine_roundtrip_linux_test.go(h: same round trip on io_uring and epoll, skips when the engine is unavailable),celeristest/recorder_snapshot_test.go.After the fix (macOS, arm64):
The linux native-engine round trip (
TestLoginRoundTripNativeEngines) has not been run locally (darwin); CI's linux job covers it.Issue
No open issue tracks this (searched
Set-Cookieandsession cookie, open and closed, in goceleris/celeris and goceleris/probatorium). Related: #487 (lazy session creation), whose tests pass only for no-body handlers. Please link or open the tracking issue; the PR body can then carryFixes #<n>.Fixes #507.
Follow-up finding fixed in this PR
The recorder fix exposed the same defect in
middleware/otel: thetraceparentresponse header was injected afterc.Next()and never reached the wire when the handler wrote a body (TestResponseHeaderPropagationstarted failing on CI once the recorder observed the wire). Commit 0854272 injects the trace context before the handler runs.