diff --git a/README.md b/README.md index 76ecee41..c50ae1ac 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/errors.go b/errors.go index e18877b0..12942416 100644 --- a/errors.go +++ b/errors.go @@ -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 { diff --git a/jaws.go b/jaws.go index 63662ff7..61d7a9aa 100644 --- a/jaws.go +++ b/jaws.go @@ -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 diff --git a/lib/ui/requestwriter.go b/lib/ui/requestwriter.go index a15bee1a..1eabeb36 100644 --- a/lib/ui/requestwriter.go +++ b/lib/ui/requestwriter.go @@ -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() diff --git a/request.go b/request.go index 6fef87d5..760dff19 100644 --- a/request.go +++ b/request.go @@ -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 diff --git a/requestpool.go b/requestpool.go index 024441fb..1992d883 100644 --- a/requestpool.go +++ b/requestpool.go @@ -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 @@ -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. diff --git a/serve.go b/serve.go index 2c296ae2..42455a77 100644 --- a/serve.go +++ b/serve.go @@ -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) @@ -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) }