Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ var ErrValueUnchanged = errors.New("value unchanged")
// A Request is overloaded when its buffered broadcast channel or its internal
// event-call channel fills before it can drain them. Rather than silently dropping
// messages, which could leave the browser and backend in inconsistent and
// nonreproducible states, the Request is cancelled. The cancellation cause reachable
// via [context.Cause] on [Request.Context] wraps this sentinel, so it can be matched
// with [errors.Is]; the wrapped text identifies which channel overflowed.
// nonreproducible states, the Request is cancelled. The one exception is the
// internal periodic dirty-render tick (a nil-destination Update broadcast): the
// dirty work has already been moved into the Request's pending dirt, so the tick
// is only a nudge and can be dropped when the channel is full. The dirt is still
// rendered — a running Request is woken by the already-buffered message and drains
// it on the next pass, and one still starting up drains it on its first processing
// pass — so no work is lost. The cancellation cause reachable via [context.Cause]
// on [Request.Context] wraps this sentinel, so it can be matched with [errors.Is];
// the wrapped text identifies which channel overflowed.
var ErrRequestOverloaded = errors.New("request overloaded")

// ErrValueNotFinite indicates a [Request] was torn down because a NaN or infinite
Expand Down
17 changes: 13 additions & 4 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,14 +328,23 @@ func (rq *Request) killSession(wasClaimed bool) {
rq.mu.Unlock()
}

// deadSession detaches sess and returns the Request identity that belonged to it.
// deadSession atomically detaches sess and arms one page reload, returning the
// Request identity that belonged to it.
//
// A zero return means rq no longer belongs to sess: it has finished, or has been
// detached from this Session.
//
// The reload is queued onto wsQueue rather than broadcast, so a Request whose
// WebSocket has not subscribed yet still reloads on connect: process drains
// wsQueue before its first select. Holding rq.mu here excludes both a concurrent
// recycle and the process loop's getSendMsgs, so the queue append is safe.
// Session.Close wakes an already-running process with a key-targeted Update.
func (rq *Request) deadSession(sess *Session) (k key.Key) {
rq.mu.Lock()
if rq.session == sess {
rq.session = nil
k = rq.JawsKey
rq.queue(wire.WsMsg{What: what.Reload})
}
rq.mu.Unlock()
return
Expand Down Expand Up @@ -1079,9 +1088,9 @@ func (rq *Request) runWebSocket(ws *websocket.Conn, pingInterval, wsTimeout time
numElems := len(rq.elems)
rq.mu.RUnlock()
// Size the broadcast buffer with headroom that scales with the page's element
// count. mustBroadcast (see Jaws.Serve) sends here non-blocking and, for any
// non-Update message, kills the subscription and cancels this request if the
// send would block.
// count. mustBroadcast (see Jaws.Serve) sends here non-blocking and, if the send
// would block, kills the subscription and cancels this request for every message
// except the coalescible nil-destination Update tick, which it drops instead.
pendingSubscription := rq.Jaws.subscribe(rq, 4+numElems*4)
defer func() {
// onConnect is user code and may return an error or panic. Release its
Expand Down
15 changes: 13 additions & 2 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,19 @@ func (jw *Jaws) ServeWithTimeout(requestTimeout time.Duration) {
select {
case msgCh <- msg:
default:
// the exception is Update messages, more will follow eventually
if msg.What != what.Update {
// Only the internal periodic dirty-render tick, a nil-destination
// Update (see the updateTicker case below), is safe to drop.
// 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 pending dirt is still rendered
// without it: 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 is one-shot and must not be silently dropped — including a
// tag-targeted Update and the key-targeted Update wake-up from
// Session.Close — so an overloaded Request is failed-fast instead.
if msg.What != what.Update || msg.Dest != nil {
killSub(msgCh)
rq.cancel(fmt.Errorf("%w: %v: broadcast channel full sending %s", ErrRequestOverloaded, rq, msg.String()))
}
Expand Down
14 changes: 11 additions & 3 deletions session.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,14 @@ func (sess *Session) Cookie() (cookie *http.Cookie) {
// Close invalidates and expires the [Session].
// Future [Request] values won't be able to associate with it, and [Session.Cookie] will return a deletion cookie.
//
// Existing [Request] values already associated with the [Session] will ask the browser to reload the pages.
// Existing [Request] values already associated with the [Session] will ask the
// browser to reload the pages. This holds even for a [Request] whose WebSocket
// has not connected yet: the reload is queued on the [Request] and delivered when
// it connects.
// Key/value pairs in the [Session] are left unmodified; use [Session.Clear] to remove all of them.
//
// It must not be called before the JaWS processing loop ([Jaws.Serve] or
// [Jaws.ServeWithTimeout]) is running, because reload broadcasts may block.
// [Jaws.ServeWithTimeout]) is running, because the wake-up broadcasts may block.
//
// Returns a cookie to be sent to the client browser that will delete the browser cookie.
// It is safe to call on a nil [Session], in which case it returns nil; for any
Expand All @@ -199,7 +202,12 @@ func (sess *Session) Close() (cookie *http.Cookie) {
*cookie = sess.cookie
sess.mu.Unlock()

msg := wire.Message{What: what.Reload}
// deadSession queues the reload directly onto each Request, covering those
// whose WebSocket has not subscribed yet. This key-targeted Update is only 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.
msg := wire.Message{What: what.Update}
for _, rq := range requests {
if k := rq.deadSession(sess); k != 0 {
msg.Dest = k
Expand Down
Loading
Loading