Skip to content

fix: reload associated Requests on Session.Close before their WebSocket subscribes#220

Merged
linkdata merged 6 commits into
mainfrom
fix/215-session-close-pending-reload
Jul 23, 2026
Merged

fix: reload associated Requests on Session.Close before their WebSocket subscribes#220
linkdata merged 6 commits into
mainfrom
fix/215-session-close-pending-reload

Conversation

@linkdata

@linkdata linkdata commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #215. Session.Close documents that Requests already associated with the Session "will ask the browser to reload the pages", but it delivered the reload only through a key-targeted Jaws.Broadcast. The serve loop's mustBroadcast fans out only to Requests present in its subscription map at that instant, so a Request in its pending window — page rendered by NewRequest, but its /jaws/<key> WebSocket has not yet reached subscribe — never received the reload. deadSession also cleared rq.session, so the Request kept no record of the close and never reloaded when it finally connected. The page kept running stale authenticated UI after logout / credential rotation.

This is distinct from #161, which covered broadcasts issued from ConnectFn (after the subscription exists); #215 is about a reload sent before the WebSocket request begins at all.

Approach

  • deadSession now queues exactly one Reload frame directly onto the Request's wsQueue, under the rq.mu it already holds. process drains wsQueue before its first select, so the reload is delivered as soon as the WebSocket connects — independent of subscription timing. Holding rq.mu excludes a concurrent recycle and the process loop's getSendMsgs, so the append is safe (rq.mumuQueue order).
  • Session.Close broadcasts a key-targeted what.Update purely as a wake-up: it makes an already-running process loop iterate and flush the queued reload. handleBroadcast resolves a key destination to no elements, so the Update itself performs no browser operation — exactly one reload is delivered on every path (no duplicate window.location.reload()).
  • mustBroadcast overload classification: only the internal periodic dirty-render tick (a nil-destination Update) is droppable. distributeDirt has already moved the dirty tags into each Request's todoDirt and cleared the global set, so the tick carries no payload — it only nudges the Request. The dirt is still rendered without it in both phases: a Request already in its process loop is woken by the message that filled the channel and drains todoDirt on the next pass, and one still starting up (subscribed before onConnect) drains todoDirt on its first pass without needing a wake. Every addressed message now fails-fast an overloaded Request with ErrRequestOverloaded, including a tag-targeted Update (one-shot; this also fixes a pre-existing latent loss of tag-targeted Updates under backlog) and the key-targeted Update wake-up. The check is a plain msg.Dest != nil, so lib/key is no longer imported by serve.go.

Note: Session.Broadcast addresses by key, so Session.Broadcast(Update) is fail-fast rather than droppable — consistent with the rule that only the internal nil-destination tick is coalescible.

Docs updated to match: Session.Close, deadSession, mustBroadcast, the runWebSocket buffer comment, and ErrRequestOverloaded (which now notes the one droppable exception).

Tests

  • TestSessionCloseReloadsAssociatedPendingRequest — deterministic regression test for the pending path: a drained control subscription proves (via broadcast ordering) that Close's wake was processed while the target had no subscription, then the target dials and exactly one Reload is asserted by reading through a post-connect marker and counting every Reload operation. Confirmed to fail on the pre-fix code.
  • TestSessionCloseReloadsConnectedRequestExactlyOnce — connected path: closes the session while the Request is connected, then reads through a post-close marker and asserts exactly one Reload, so a delayed duplicate would be caught (a single-frame read could miss one).
  • TestServeKeyTargetedUpdateFailFast — covers all three destination classes: a nil-destination Update overload leaves the Request alive and proves the subscription survived (drains the tick, then a targeted message must still arrive); a tag-targeted Update overload and a key-targeted Update overload each fail-fast with ErrRequestOverloaded.
  • TestSession_ProducersSkipRecycled — updated to expect the Update wake, assert exactly one queued Reload on the live Request, and assert the recycled request's queue stays empty.
  • TestSession_Delete and TestSessionCloseDoesNotReachLaterRequest — preserved.

Mutation-checked: all-Updates-droppable, killSub-without-cancel on the droppable path, and a duplicate queued reload each fail the new assertions.

Verification

go test -race ./..., go test ./... (release renderer), go vet ./..., gofmt -l ., and staticcheck ./... all pass. deadSession, Session.Close, and ServeWithTimeout report 100% coverage; the targeted tests pass repeatedly with no flakiness.

linkdata added 6 commits July 23, 2026 17:44
…et subscribes

Session.Close promised that Requests associated with the Session would ask
the browser to reload, but it delivered the reload only via a key-targeted
broadcast. The serve loop distributes broadcasts solely to Requests already
present in its subscription map, so a Request still in its pending window
(page rendered, WebSocket not yet subscribed) never received the reload and
kept running stale UI after logout or session teardown.

deadSession now queues one Reload frame directly onto the Request's wsQueue
while holding rq.mu, so process delivers it as soon as the WebSocket connects,
independent of subscription timing. Session.Close broadcasts a key-targeted
what.Update purely as a wake-up for an already-running process loop; since
handleBroadcast resolves a key destination to no elements, the Update queues
no browser command, so exactly one reload is delivered on every path.

To preserve the previous fail-fast behavior of the reload broadcast, a
key-targeted Update is treated as critical in mustBroadcast: an overloaded
subscription is cancelled rather than silently dropped, while an ordinary
(nil or tag destination) Update remains droppable.

Fixes #215
…droppable

Address review feedback on the overload classification and tests.

Restrict droppability to the internal periodic dirty-render tick: only a
nil-destination Update is coalescible and safe to drop. Every addressed
message, including a tag-targeted Update (one-shot, no periodic re-send) and
the key-targeted Update wake-up from Session.Close, now fails-fast an
overloaded Request instead of being silently lost. This is a plain
nil-destination check, so the lib/key import is no longer needed, and it fixes
the pre-existing latent loss of tag-targeted Updates under backlog. Comments in
mustBroadcast and runWebSocket are corrected to match.

Tests:
- Add TestSessionCloseReloadsConnectedRequestExactlyOnce: reads through a
  post-close marker and counts every Reload, so a delayed duplicate on the
  connected path is caught (the single-frame read in TestSession_Delete could
  miss one).
- TestServeKeyTargetedUpdateFailFast now proves the droppable subscription
  survives (drains the tick, then a targeted message must still arrive) and
  adds a tag-targeted case, covering all three destination classes.
- Assert the recycled request's queue stays empty in
  TestSession_ProducersSkipRecycled, and count every Reload operation rather
  than only the exact empty-data encoding in the pending-reload test.

Verified by mutation: all-Updates-droppable, killSub-without-cancel on the
droppable path, and a duplicate queued reload each fail the new assertions.
…rloaded

ErrRequestOverloaded said overflowing broadcasts are never dropped, but the
internal periodic dirty-render tick (a nil-destination Update) is intentionally
coalescible and dropped on overflow. Document that one exception.
The nil-destination Update tick is not dropped because a later tick re-sends
the work; distributeDirt has already moved the dirty tags into each Request's
todoDirt and cleared the global set, so the tick is only a wake-up. It is safe
to drop on a full channel because the already-buffered message wakes
Request.process, whose next pass drains todoDirt before waiting again. Correct
the comments on ErrRequestOverloaded and mustBroadcast.
A dropped nil-destination Update tick is safe in both phases: a Request already
in its process loop is woken by the message that filled the channel and drains
todoDirt on the next pass, while one still starting up (subscribed before
onConnect) drains todoDirt on its first pass without needing a wake. Also fix
the mustBroadcast comment referring to the updateTicker case as 'above' when it
is below.
@linkdata
linkdata merged commit a71480a into main Jul 23, 2026
7 checks passed
@linkdata
linkdata deleted the fix/215-session-close-pending-reload branch July 23, 2026 17:58
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.

Session.Close drops reloads for associated Requests that have not subscribed yet

1 participant