fix: never reuse Request identities, pool only buffers (#195)#206
Conversation
Jaws.ServeHTTP could recycle a Request while its initial HTTP renderer still held the pointer: an early or malformed GET /jaws/<key> claimed a still-rendering Request, failed the WebSocket upgrade, and the deferred stopServe cleared the Request and returned it to a sync.Pool. Once reused from the pool, continued rendering mutated an unrelated client's Request. Stop reusing Request identities entirely. NewRequest now allocates a fresh Request with a stable identity; only the reusable buffers (todoDirt, elems, tagMap, wsQueue) are pooled, via Jaws.requestBufferPool. On completion, releaseBuffersLocked cancels the context, unregisters the Request and returns its buffers to the pool while preserving JawsKey, so a pointer the initial renderer still holds stays valid and inert and is never handed to another connection. Identity-targeted operations (destKey, wantMessage, appendDirtyTags) gate on the new registered flag instead of the former key-zeroing trick, and TagExpanded guards against the detached (nil) tagMap so a render continuing after a racy teardown degrades to a no-op rather than panicking. The large per-page buffers remain pooled, so the change adds only one small Request allocation per request: negligible for real pages and vanishing at 1000 elements (BenchmarkRequestLifecyclePooling, impl=pooled). Fixes #195.
…stone Follow-up to the identity-stable change, addressing four review findings. Teardown no longer mutates render-visible state: releaseBuffersLocked stops clearing Element.ui/handlers and resetting lastJid, which raced an initial renderer still inside JawsRender (those fields are read lock-free) and could duplicate an already-streamed Jid. A stale Element needs no inerting now that a Request is never reused. releaseBuffersLocked holds muQueue across the whole wsQueue clear/transfer/detach, so a concurrent Request.queue can no longer land a message in a buffer already returned to the pool, where it would surface in the next Request. UseRequest refuses to claim a Request while its initial render is still in progress (the initial request's context is live), returning 404 WITHOUT consuming the key. An early or malformed /jaws/<key> callback mid-render can no longer claim and tear down the Request; the real WebSocket, which connects only after the page loads, claims it normally. This is a synchronous check on the initial request's context, so there is no watcher goroutine and no watcher-vs-WebSocket scheduling race. The recycle path reserves the finished key with a nil tombstone and a runtime cleanup instead of deleting it, so a stable key can never be reassigned to a different Request while a page might still emit it, mirroring the retirement path. Docs on Request, Element and the README drop the removed pool-and-reuse model. Tests decouple the initial-render request from the WebSocket request (distinct in production) and cover the render gate, the forced-key tombstone, and a live JawsRender racing teardown.
|
Thanks for the review — all four are real. Pushed b9d93db addressing them. [P1] Outbound messages crossing Requests ( [P1] Teardown racing the initial renderer. Two changes:
[P2] Finished keys reassigned while reachable. The recycle path now reserves the key with a nil tombstone + [P3] Contradictory docs. The New/adjusted tests: the render gate (early callback 404 + key preserved, then claim after render), the forced-key tombstone, and a live Cost. The tombstone's per-recycle Separately: your |
Second review round. Remove the initial-request-context render gate. The request context is not a reliable render-completion barrier: it is canceled when the client connection closes (before the handler returns) and a detached/background context stays live after it returns, so the gate could admit an early callback mid-render or reject the real WebSocket indefinitely. It also 404'd the streamed <noscript> probe, which the browser never retries, so ErrJavascriptDisabled was never recorded and cleanup waited for timeout. Teardown is already race-safe (it no longer mutates render-visible Element state and reserves the key with a tombstone), so the gate is unnecessary; UseRequest claims as before and the fixtures that decoupled the initial-render request from the WebSocket request revert with it. Clear reusable buffers through capacity on completion, not just length. getSendMsgs now zeroes the drained wsQueue before reslicing to zero so HTML payloads are released promptly, and releaseBuffersLocked clears todoDirt/elems/wsQueue through cap so a drained-to-zero queue cannot pin payloads in the pooled backing array. Correct the lifecycle docs: a finished Request keeps its Element fields, so a retained Element is not a no-op — it is unregistered (no further updates) and its identity is never reused, but its methods still operate on the finished Request. The README maintainer checklist now describes identity-stable buffer pooling and key tombstoning rather than whole-Request pooling and pointer reuse. Tests: a /noscript probe during a live-context render still returns 204 and records ErrJavascriptDisabled; TestReleaseBuffersLockedZeroesWsQueue drains to zero length before releasing to prove the backing array is cleared through capacity.
|
Round 2 addressed in 4ab6eaf — all four are correct. [P1] Request context isn't a reliable render barrier. Agreed, and the decisive point is that teardown is now race-safe, so the gate isn't buying correctness. Removed the render gate entirely ( [P2] Gate broke streamed [P2] Drained queues retained payloads in pooled capacity. Right — [P3] "inert" was wrong. Corrected Full gate green: |
Third review round. releaseBuffersLocked clears only the live length of the reusable buffers, not their full capacity. Every path that shrinks a buffer already zeroes the vacated entries (getSendMsgs and drainTailScript for wsQueue, makeUpdateList for todoDirt, slices.DeleteFunc for elems), so the retained capacity holds no live references. Clearing through capacity rescanned a pooled buffer's high-water mark on every recycle, including later empty requests, under rq.mu/muQueue. BenchmarkRequestRecycleAfterHighWater shows empty recycling now stays flat across the high-water axis. Add exact regression coverage for the two teardown races the earlier test missed: TestFinishDoesNotResetJidCounter asserts a monotonic Jid across teardown (a restored lastJid = 0 fails it), and TestRecycleQueueRaceDoesNotLeak races a queue against recycle under -race (moving the wsQueue transfer outside muQueue trips the detector). TestGetSendMsgsZeroesDrainedQueue covers the drain zeroing directly. Finish the lifecycle docs: the Request and Element docs no longer call a finished Request or a retained Element "inert" — their methods still run; they are just unregistered and never reused. The README drops the removed render gate and, in the maintainer checklist, distinguishes completion (releases buffers) from non-running retirement (which preserves them for the initial HTTP handler).
|
Round 3 addressed in 1e43966 — all three correct. [P2] Capacity clear on every recycle. Reverted to clearing only the live length. You're right that it's redundant now: every shrink path zeroes vacated entries ( [P2] Exact regression coverage. Added:
[P3] Docs. Full gate green: |
Documentation-only. The Request type doc no longer claims lifecycle completion always releases buffers and leaves empty, useless collections: it distinguishes completion after WebSocket serving (which releases buffers to the pool) from retirement of an unclaimed Request (which preserves Elements and buffers for an initial HTTP handler that still holds the pointer), and states only that a finished Request is unregistered, never reused, and receives no further broadcasts or updates — its methods still run but affect no live connection. releaseBuffersLocked's contract no longer says "no other goroutine is still processing rq"; it now states that the WebSocket loop must have exited but an initial renderer may still run concurrently, which is exactly why teardown leaves render-visible Element/Jid state untouched and transfers wsQueue under muQueue. Refresh stale test comments that referenced clearLocked, key zeroing on recycle, or the pool reusing a *Request: recycle now tombstones the key and only buffers are pooled, so those tests exercise finished/unregistered requests and distinct later identities rather than reused pointers.
|
Round 4 (docs) addressed in 0f9204e. [P3] Request lifecycle contract. The
Stale test comments. Fixed the two you cited ( Docs-only round; full gate still green ( |
…Request guarantee
Documentation-only, refining the lifecycle contract.
The Request type doc no longer equates the end of the initial HTTP render with the
Request finishing: the render handler returning normally leaves the Request pending
until UseRequest claims it for the WebSocket. The Request finishes when its WebSocket
handling ends, or when a non-running Request is retired ("non-running", not
"unclaimed", also in the README lifecycle section).
The finished-Request guarantee is scoped to identity-targeted operations: because an
identity is never reused, updates and broadcasts aimed at a finished Request reach
nothing, but this is not a general no-op — Request.Dirty delegates to instance-wide
dirtying and can still update matching Elements on other live Requests. Using a
Request past its borrowed lifecycle is documented as unsupported.
Finish removing the whole-Request pooling/key-zeroing model from comments: the
requestBuffers doc, the buffers field, and appendDirtyTags no longer imply every
finished Request returns its buffers (retirement preserves them); the wantMessage and
ProducersSkip test comments no longer say recycle zeroes JawsKey (the key is
preserved — only registered flips and destKey returns zero).
|
Round 5 (docs) in 986ca7a. [P3] Pointer ownership vs completion. The [P3] Scope of the finished-Request guarantee. Limited to identity-targeted operations: since an identity is never reused, updates/broadcasts aimed at a finished Request reach nothing — but it's explicitly not a general no-op, because [P3] Remaining pooling/key-model wording. The (Left Docs-only; full gate green (16/16 packages under |
…op rebind fixture Documentation and test-fixture accuracy. Early teardown clears the reusable collections (element list, tag map, dirt list, message queue) while leaving the individual Element fields and the Jid counter intact. Describe it as race-safe rather than non-destructive: releaseBuffersLocked's contract and the README now say a concurrent render may degrade (later GetElements and getElementByJid find nothing) but stays race-safe — no data race, no reused identity, no duplicated id. TestRequestFinishDoesNotPanicOnContinuedRender's comment no longer calls continued registration a no-op: NewElement still creates an Element and advances the Jid; only Tag is dropped (via the nil-map guard). Remove the impossible Request-rebinding fixture in TestSession_ProducersSkipRecycled. It modeled a pooled *Request being cleared and acquiring an unrelated identity, which identity stability makes impossible. It now uses a single entry modeling a finished, Session-detached Request (its own nonzero key, session == nil), which is what the producers' skip branch actually guards; the real snapshot-vs-finish race is covered by TestSessionCloseDoesNotReachLaterRequest.
|
Round 6 (docs + one test fixture) in df868e1. [P3] Early teardown is race-safe, not fully preserved. Correct — teardown clears [P3] Impossible rebind fixture removed. Full gate green: 16/16 packages under |
Documentation and test-name accuracy. Qualify the early-teardown contract: clearing the collections forgets the already-rendered elements and tags (a later GetElements finds nothing), but a subsequent NewElement repopulates rq.elems and is again findable by Jid. Correct the locking claim — the lock-free Element fields (ui, handlers) are what stay intact; lastJid is advanced only under rq.mu (not read lock-free) so Jids stay monotonic, and wsQueue transfers under muQueue. Mirror this in the README. Finish the stale lifecycle terminology: the #195 reproduction is renamed TestEarlyCallbackPreservesInitialRenderIdentity, and its comment and failure message now say the callback tears the Request down but must not destroy its stable identity (the test asserts only that the key survives). deadSession's "recycled or rebound to another Session" wording becomes "finished, or detached from this Session". The TestSession_ProducersSkipRecycled fixture comment now says the snapshot is taken under sess.mu and processed after releasing it, not taken after unlocking.
|
Round 7 (docs) in f57d5b7. [P3] Post-teardown lookup and locking. Corrected both:
[P3] Stale lifecycle terminology.
Full gate green: 16/16 packages under |
…nt to reachability TestEarlyCallbackPreservesInitialRenderIdentity now captures the key before the early callback and asserts it is unchanged afterward, instead of merely checking it is nonempty; the comment says the key stays stable and nonzero (the finished Request is unregistered), not "valid". Scope the key non-reassignment claims to the tombstone lifetime. destKey's doc and the README maintainer checklist now say a finished Request's key is reserved (not reassigned) only while the Request remains reachable; once it is collected the tombstone is cleaned up and the key becomes eligible for random reuse, by which point no stale destination referencing it can remain. A wantMessage test comment is likewise qualified.
|
Round 8 (docs/test) in 926e116. [P3] Identity test now asserts stability. [P3] Key non-reassignment scoped to the tombstone lifetime. Full gate green: 16/16 packages under |
A key VALUE (a copied key.Key, a queued wire.Message.Dest, or a browser /jaws/<key> URL) can outlive the *Request. While the finished Request stays reachable its key is held reserved by a nil tombstone, so a stale destination matches nothing; but after the Request is collected the tombstone is removed and the CSPRNG may eventually reissue that key value to a new Request, which such a stale destination could then match. Only the *Request pointer is never reused, so pointer-derived operations are always safe. destKey's doc no longer claims "no such stale destination can remain" once the Request is collected; it now scopes the guarantee to the reachable window and acknowledges post-collection CSPRNG reuse, separating pointer-derived operations (always safe) from key-value-derived ones. The wantMessage regression test's summary comment is corrected the same way (its inline comment was already scoped). Retaining tombstones for the whole Jaws lifetime would make the absolute guarantee hold, but at the cost of an unbounded jw.requests leak (one dead entry per request ever created), so the cleanup-based, correctly-scoped guarantee is kept.
|
Round 9 in 0b338d5. [P3] Stale destinations don't disappear on collection. Correct — a key value (copied I kept the code (docs-fix, not permanent tombstones): I ran a small audit to be sure. Verification confirmed the only path by which a captured key value reaches a different Request is a post-cleanup CSPRNG collision (2⁻⁶⁴, further gated by the IP check on the reachable Full gate green: 16/16 packages under |
…ing + destKey resolution The previous wording claimed "pointer-derived operations never reach a different connection", which is false: Request.Dirty dirties matching Elements on other live Requests by design, and an Alert/Redirect message queued before completion carries a key.Key destination that can outlive the *Request and its tombstone. State only the two guarantees that always hold — the *Request pointer is never reused so it never aliases another connection, and destKey returns zero once the Request has finished so it cannot hand back the finished destination — and explicitly note it is not a blanket pointer-derived-safety claim.
|
Round 10 in the latest push. [P3] Narrowed the destKey guarantee. You're right that "pointer-derived operations never reach a different connection" is false —
and it explicitly flags that this is not a blanket "pointer-derived operations are safe" claim, naming Full gate green: 16/16 under |
…key guarantee The Request godoc no longer claims all identity-targeted operations are never retargeted. It defines the never-reused identity as the *Request pointer (GC'd, never handed out again, so a retained pointer never aliases another connection), and scopes the operational guarantee to destinations resolved from the Request after completion (Alert/Redirect see an empty destKey and reach nothing). It reiterates this is not a blanket no-op: Request.Dirty dirties other Requests by design, and an Alert/Redirect queued before completion carries a key value that can outlive the Request and match a different one after key reuse. The README key-reservation invariant is qualified from "in every case" to registered Requests: a Request created by NewRequest after Jaws.Close is unregistered and installs no tombstone, so two post-close calls could receive the same key — harmless, since neither is claimable.
|
Round 11 in the latest push. [P3] Exported Request guarantee narrowed. The godoc now defines the never-reused identity as the [P3] Post-Close exception in the README. Qualified "in every case" → registered Requests. A Request created by Full gate green: 16/16 under |
Problem
Jaws.ServeHTTPcould recycle aRequestwhile its initial HTTP renderer still owned the pointer (issue #195, severity: high). An early or malformedGET /jaws/<key>claimed a still-rendering Request, failed the WebSocket upgrade, and the deferred teardown cleared the Request and returned it to async.Pool; once reused for another connection, continued rendering corrupted an unrelated client's Request.Fix
Stop reusing
*Requestidentities — the structural root cause. EachNewRequestallocates a fresh Request with a stable identity that is never handed to another connection; only the internal buffers (todoDirt,elems,tagMap,wsQueue) are pooled viaJaws.requestBufferPool.releaseBuffersLocked) cancels the context, unregisters the identity, detaches the session, and returns the buffers to the pool. The*Requestis never re-pooled, so a stale pointer can never alias a different connection.Element.ui/handlers, theJidcounter, andJawsKeyintact. Those are read lock-free by an initialJawsRenderthat may still be running, and resetting the Jid counter would duplicate an already-streamed id. A stale Element needs no inerting since the identity is never reused.muQueue: thewsQueueclear/transfer/detach is done entirely undermuQueue, so a concurrentRequest.queuecannot land a message in a buffer already returned to the pool.runtime.AddCleanup(mirroring retirement) instead of deleting it, so a stable key is never reassigned to a different Request while a page might still emit it.getSendMsgs) zeroes drainedwsQueueentries, and completion clears only the live length (the shrink paths already zero vacated entries), so empty recycling stays O(1) rather than rescanning a pooled buffer's high-water capacity.An early callback that claims and tears down a still-rendering Request is now harmless: no corruption, no race, no key reassignment — at worst a degraded render on a page that was already being probed. (An earlier revision gated claims on the initial request's context to make render/WebSocket lifetimes sequential, but that signal is unreliable — client disconnect cancels it early, a detached context stays live — and it broke streamed
/noscriptprobes, so it was removed once teardown was made race-safe.)Tests
request_identity_test.goadds the #195 reproduction, the identity/late-cancel guarantee, non-destructive-teardown coverage (liveJawsRenderracing teardown; monotonic Jid across teardown; queue-vs-recycle under-race), the key tombstone (forced K,K,K2), and a/noscript-during-live-render guard.TestGetSendMsgsZeroesDrainedQueueandTestReleaseBuffersLockedZeroesWsQueuecover payload release. Existing pointer-reuse assertions were reworked to the stable-identity invariant.Verification
go vet ./...,staticcheck(clean),golangci-lint(0 issues),go test -race ./...andgo test -tags debug -race ./...all pass. 100% coverage on the changed functions.Performance
BenchmarkRequestLifecyclePooling/impl=pooled(production path, main whole-Request pool → this buffer-pool + tombstone) andBenchmarkRequestRecycleAfterHighWater:*Requeststruct plus the key-reservationruntime.AddCleanup— +3 allocs/op on the empty-request path.A follow-up will consolidate
registered/running/claimed/tailsentinto a singleint32lifecycle state (kept out of this PR to keep the security fix reviewable).Fixes #195.