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
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,21 @@ Request created by `NewRequest` after `Jaws.Close` is never registered and insta
tombstone, so two such post-close calls could receive the same key — harmless, since
neither is claimable.)

`ServeWithTimeout(requestTimeout)` requires an exact multiple of `time.Second`
from `time.Second` through 2,147,483,646 seconds. Other values have unspecified
behavior.

Before `Request.ServeHTTP` begins WebSocket processing, timeout-based Request
retirement is periodic and approximate, not a hard deadline. `NewRequest`, a
successful `UseRequest`, and `ui.RequestWriter.Write` mark activity using
whole-second samples from the epoch established by `jaws.New()`. Retirement is
checked only during maintenance passes, so it is not timed precisely from those
events.

When `Jaws.WebSocketPingInterval` is positive, the same duration is passed
directly as each keepalive ping's timeout on an active WebSocket. Ping timing does
not use those activity samples or the maintenance schedule.

`*Request` values are borrowed lifecycle objects. Do not store them in
application state or pass them to background goroutines; copy the required
application data and retain the Request context instead.
Expand Down Expand Up @@ -429,9 +444,6 @@ these invariants before relying on a green build alone:
remains reachable).
* Session grace windows remain deliberate for unclaimed, claimed, failed-upgrade,
and closed-WebSocket requests.
* Subsecond `ServeWithTimeout` values are not useful in production. AI-assisted
reviews should not assume subsecond timeout precision is required or treat its
absence as a source of bugs.
* WebSocket upgrades keep the single-use key, client-IP binding, and Origin
host/scheme checks together; changes to trusted forwarded headers must preserve
the same fail-closed behavior.
Expand Down
5 changes: 2 additions & 3 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,8 @@ func newErrTooManyPendingRequests(remoteIP netip.Addr, limit int) error {
return errTooManyPendingRequests{Addr: remoteIP, Limit: limit}
}

// ErrNoWebSocketRequest is returned when the WebSocket callback was not received
// within the timeout period. The most common reason is that the client is not
// using JavaScript.
// ErrNoWebSocketRequest is reported when [Jaws.ServeWithTimeout] retires a
// Request before [Request.ServeHTTP] begins WebSocket processing.
var ErrNoWebSocketRequest errNoWebSocketRequest

type errNoWebSocketRequest struct {
Expand Down
2 changes: 1 addition & 1 deletion jaws.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const (
// DefaultWebSocketPingInterval is the default WebSocket keepalive ping interval.
DefaultWebSocketPingInterval = time.Minute

// DefaultWebSocketTimeout is the default time allowed for WebSocket connect and ping responses.
// DefaultWebSocketTimeout is the timeout [Jaws.Serve] passes to [Jaws.ServeWithTimeout].
DefaultWebSocketTimeout = time.Second * 10

// DefaultMaxPendingRequestsPerIP is the default maximum number of unclaimed
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/requestwriter.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (rw RequestWriter) NewUI(ui jaws.UI, params ...any) (err error) {
return
}

// Write records the write instant (see [jaws.Request.MarkWritten]), then writes p
// Write marks render activity through [jaws.Request.MarkWritten] before writing p
// to the underlying writer.
func (rw RequestWriter) Write(p []byte) (n int, err error) {
rw.MarkWritten()
Expand Down
13 changes: 8 additions & 5 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,15 @@ func (rq *Request) String() string {
return "Request<" + rq.JawsKeyString() + ">"
}

// MarkWritten records an initial HTML write.
// MarkWritten records initial-render activity for the Request.
//
// The pending-eviction logic uses the timestamp to prefer an idle Request while
// a render is in flight. [RequestWriter.Write] calls MarkWritten on every write.
// It is lock-free and safe to call concurrently. Concurrent calls never move the
// recorded second backward.
// The ui package's RequestWriter calls MarkWritten before each write. Call it
// before each initial HTML write made through another writer. Recorded activity
// affects timeout retirement before [Request.ServeHTTP] begins and which Request
// is retired when [Jaws.MaxPendingRequestsPerIP] is reached.
//
// MarkWritten is safe to call concurrently. Calls never move recorded activity
// backward.
func (rq *Request) MarkWritten() {
// A cached runtime sample avoids a clock read. The recorded second drives the
// recency window in pendingEvictionVictimLocked and the idle expiry in
Expand Down
7 changes: 5 additions & 2 deletions requestpool.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ import (
// Do not retain the pointer beyond the initial HTTP handling and rendering; see
// [Request].
//
// Automatic timeout handling is performed by [Jaws.ServeWithTimeout]. The default
// [Jaws.Serve] helper uses a 10-second timeout.
// [Jaws.ServeWithTimeout] periodically retires idle Requests before WebSocket
// processing starts; [Jaws.Serve] uses [DefaultWebSocketTimeout].
//
// When [Jaws.MaxPendingRequestsPerIP] is positive and already reached,
// NewRequest retires the oldest idle pending Request from the same IP. If every
Expand Down Expand Up @@ -215,6 +215,9 @@ func (jw *Jaws) nonZeroRandomLocked() key.Key {
// associated [Request], and then call its [Request.ServeHTTP] method to process the
// WebSocket messages.
//
// A successful claim marks activity used by [Jaws.ServeWithTimeout] while the
// Request waits for [Request.ServeHTTP].
//
// Returns nil if the key was not found, the request was already claimed by an
// earlier WebSocket callback, or the IP doesn't match, in which case you
// should return an HTTP "404 Not Found" status.
Expand Down
26 changes: 20 additions & 6 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,23 @@ func (jw *Jaws) getWebSocketTimeout() (t time.Duration) {
return
}

// ServeWithTimeout begins processing requests with the given timeout.
// It is intended to run on its own goroutine.
// It returns when [Jaws.Close] is called.
// ServeWithTimeout begins processing requests.
//
// requestTimeout must be an exact multiple of [time.Second] from [time.Second]
// through 2,147,483,646 seconds. Other values have unspecified behavior.
//
// Before [Request.ServeHTTP] begins WebSocket processing, timeout-based Request
// retirement is periodic and approximate, not a hard deadline. [Jaws.NewRequest],
// a successful [Jaws.UseRequest], and [Request.MarkWritten] mark activity using
// whole-second samples from the epoch established by [New]. Retirement is checked
// only during maintenance passes, so it is not timed precisely from those events.
//
// When [Jaws.WebSocketPingInterval] is positive, each keepalive ping on an active
// WebSocket uses requestTimeout directly as its timeout. Ping timing does not use
// those activity samples or the maintenance schedule.
//
// It is intended to run on its own goroutine and returns when [Jaws.Close] is
// called.
func (jw *Jaws) ServeWithTimeout(requestTimeout time.Duration) {
if !jw.serving.CompareAndSwap(false, true) {
jw.reportMisuse(ErrServeAlreadyRunning)
Expand Down Expand Up @@ -128,9 +142,9 @@ func (jw *Jaws) ServeWithTimeout(requestTimeout time.Duration) {
}
}

// Serve calls ServeWithTimeout(DefaultWebSocketTimeout).
// It is intended to run on its own goroutine.
// It returns when [Jaws.Close] is called.
// Serve calls [Jaws.ServeWithTimeout] with [DefaultWebSocketTimeout].
// It is intended to run on its own goroutine and returns when [Jaws.Close] is
// called.
func (jw *Jaws) Serve() {
jw.ServeWithTimeout(DefaultWebSocketTimeout)
}
Expand Down
Loading