Skip to content

refactor: consolidate Request lifecycle flags into an atomic int32 state#207

Merged
linkdata merged 4 commits into
mainfrom
fix/request-lifecycle-state
Jul 21, 2026
Merged

refactor: consolidate Request lifecycle flags into an atomic int32 state#207
linkdata merged 4 commits into
mainfrom
fix/request-lifecycle-state

Conversation

@linkdata

Copy link
Copy Markdown
Owner

Summary

Follow-up to #206 (stable Request identities). Replaces the three separate lifecycle flags on Requestregistered bool, running atomic.Bool, claimed atomic.Bool — with a single reqState int32 held in Request.state, so the lifecycle transitions are explicit and race-safe.

reqUnclaimable                                   (terminal; created after Jaws.Close)
reqPending -> reqClaimed -> reqRunning -> reqFinished
reqPending/reqClaimed ------------------> reqFinished   (never served)
  • Claim and start-serve are now single checked CAS transitions: casState(reqPending, reqClaimed) and casState(reqClaimed, reqRunning). A claim/serve can only succeed from the exact expected state, so a concurrent Close/retirement or a duplicate claim can never resurrect a Request.
  • finishLocked centralizes the finish transition. It captures whether the Request had been claimed before detaching the session — so Session.delRequest still grants the claimed-WebSocket grace window — then stores reqFinished. Session.delRequest now takes wasClaimed explicitly rather than reading a flag, so the grace decision no longer depends on the order of the finish transition vs. the session detach.
  • Lock-free reads (maintenance, Close, RequestCounts) use loadState(); identity-gated paths (destKey, wantMessage, appendDirtyTags) use loadState().registered() under rq.mu, exactly as they read registered before.

TestServe contract change (intentional, not a pure refactor)

TestServe now performs the same checked startServe transition synchronously, before creating its per-run channels, and panics (after releasing the broadcast subscription) if the Request is not servable — instead of the old fire-and-forget running.Store(true) inside the serve goroutine. This tightens the contract: the Request must already be claimed via UseRequest, and it may be served only once and only before Close.

This is a deliberate test-API tightening, not behavior-preserving. An audit of all callers (in-tree and github.com/linkdata/jaws/jawstest) confirmed none rely on the old loose behavior; the harness always claims via UseRequest first. The upside: a concurrent Close/retirement can no longer race a TestServe into a half-started state (covered by TestServe_CloseRaceIsSafe, 50 iterations under -race).

Performance

Consolidating three fields into one shrinks each Request by 16 bytes with no change in allocation count. benchstat (Apple M5 Max, -count=10, -benchtime=200ms), old = separate flags (e23d53f), new = int32 state:

                                                       │   old sec/op   │   new sec/op  vs base           │
RequestLifecyclePooling/elems=0/impl=pooled/serial       580.8n ± 3%      565.2n ± 2%   -2.68% (p=0.011)
RequestLifecyclePooling/elems=0/impl=pooled/parallel     613.8n ± 1%      601.0n ± 1%   -2.09% (p=0.000)
RequestLifecyclePooling/elems=0/impl=unpooled/serial     226.4n ± 2%      218.7n ± 1%   -3.40% (p=0.000)
RequestLifecyclePooling/elems=0/impl=unpooled/parallel   420.4n ± 2%      416.8n ± 3%        ~   (p=0.928)
RequestRecycleAfterHighWater/highwater=0                 574.5n ± 1%      575.5n ± 1%        ~   (p=0.617)
RequestRecycleAfterHighWater/highwater=1000              578.4n ± 1%      600.1n ± 5%   +3.75% (p=0.034)
RequestRecycleAfterHighWater/highwater=100000            636.8n ± 8%      577.5n ± 1%   -9.32% (p=0.000)
RequestClaimStartFinish                                  972.8n ± 1%      922.6n ± 3%   -5.16% (p=0.000)
geomean                                                  537.7n           524.2n        -2.51%

                                                       │    old B/op    │    new B/op   vs base           │
(every benchmark)                                        401/416/432 B    385/400/416 B  ~ -3.9%  (16 B)
geomean                                                  450.3            433.8         -3.65%

allocs/op: unchanged across all benchmarks (geomean +0.00%)

The Request* benchmarks are added by the first commit (e23d53f) and stay in the tree as a regression guard.

Tests

  • New request_state_test.go: reqState.String; full-transition table (pending->claimed->running->finished, never-claimed recycle, claimed-not-running retire, double claim/startServe rejected, terminal-stays-finished); post-Close claim returns the cancellation cause (not ErrRequestAlreadyClaimed); the debug-only finishLocked terminal-state assertion; duplicate TestServe panics without disturbing the first serve; TestServe/Close race (50×).
  • Existing lifecycle/session/broadcast tests migrated 1:1 to the state helpers; none weakened.

Gate

gofmt clean · go vet clean · staticcheck clean · golangci-lint 0 issues · go test -race ./... and go test -tags debug -race ./... green · coverage 99.7% (only the pre-existing errunusableui path below 100%; finishLocked at 100%).

linkdata added 4 commits July 21, 2026 21:06
Baseline benchmark (no behavior change) covering NewRequest -> UseRequest ->
startServe -> recycle, which the existing create/recycle and high-water benchmarks
do not exercise. Landed before the lifecycle-state consolidation so benchstat has
before/after samples for the claim/start path.
Replace the three separate lifecycle flags on Request (registered bool,
running atomic.Bool, claimed atomic.Bool) with a single reqState int32 held
in Request.state, making the transitions explicit and race-safe:

    reqUnclaimable -> (terminal; created after Jaws.Close)
    reqPending -> reqClaimed -> reqRunning -> reqFinished
    reqPending/reqClaimed -> reqFinished (never served)

Claiming and starting to serve are now single checked CAS transitions
(casState(reqPending, reqClaimed) and casState(reqClaimed, reqRunning)),
and finishLocked centralizes the finish transition: it captures whether the
Request had been claimed before detaching the session (so Session.delRequest
still grants the claimed-WebSocket grace window) and then stores reqFinished.
Session.delRequest takes wasClaimed explicitly instead of reading a flag, so
the grace decision no longer depends on the order of the finish transition
and the session detach.

TestServe now performs the same checked startServe transition synchronously
before creating its per-run channels, and panics (releasing the broadcast
subscription) if the Request is not servable. This tightens its contract: the
Request must already be claimed via UseRequest. A concurrent Jaws.Close or
retirement can no longer resurrect a Request into reqRunning.

Consolidating three fields into one shrinks each Request by 16 bytes with no
change in allocation count. benchstat (Apple M5 Max, -count=10, three
lifecycle benchmarks), old = separate flags, new = int32 state:

    B/op        geomean  -3.65%  (e.g. 401 -> 385, 416 -> 400)
    sec/op      geomean  -2.51%
    allocs/op   geomean   0.00%  (unchanged)
…se-race test

- finishLocked doc: state that both rq.mu and jw.mu are required, and why. claim
  runs its reqPending->reqClaimed CAS under rq.mu while startServe runs its
  reqClaimed->reqRunning CAS under jw.mu (not rq.mu), so both locks are needed to
  keep finishLocked's read-then-store atomic against the other transitions. The old
  wording ("rq.mu prevents state changes"; "jw.mu only for map bookkeeping") was
  wrong.

- releaseBuffersLocked doc: it performs only buffer mechanics. It no longer cancels
  the context, detaches the session, or clears a lifecycle flag — the caller cancels
  the context and finishLocked detaches the session and stores reqFinished.

- TestServe_CloseRaceIsSafe: capture the outcome instead of swallowing every panic.
  Accept only "jaws: TestServe" setup-failure panics (subscription timeout or the
  refused startServe transition), fail on any other panic (resurrection, nil deref,
  double-finish assertion) or a serve+panic combination, and assert the Request ends
  reqFinished. A measured race split (both branches occur) confirms the assertions
  have teeth; the double-finish assertion under -tags debug catches a terminal->
  running resurrection regression.

- TestFinishLockedTerminalStatePanicsInDebug: hold jw.mu as well as rq.mu, matching
  the documented finishLocked contract and production lock order.
The strengthened Close-race test passed onPanic func(any){}, discarding any panic
recovered from TestServe's process/recycle goroutine; only the synchronous setup
panic was asserted. A panic escaping recycle — notably finishLocked's terminal-state
assertion after a terminal->running resurrection — was therefore swallowed while the
final state still read reqFinished, so the test could pass through the very bug it
guards against.

Capture the onPanic argument (published before doneCh closes, so the post-<-doneCh
read is race-free) and fail if it is non-nil. Verified by fault injection: forcing an
inconsistent terminal state before recycle makes finishLocked's assertion escape and
is now caught (per-element handler panics remain recovered inside process and are out
of scope).
@linkdata
linkdata merged commit 7ac9bbf into main Jul 21, 2026
7 checks passed
@linkdata
linkdata deleted the fix/request-lifecycle-state branch July 21, 2026 20:16
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