From cceee3082f9337324f07dcce27cbb9e38277aaaa Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 16:06:07 +0200 Subject: [PATCH 1/2] docs: define ServeWithTimeout timing contract Document the supported whole-second duration domain, approximate retirement before WebSocket processing, and direct per-ping timeout behavior. Align related API and README terminology. Closes #248 --- README.md | 22 +++++++++++++++++++--- errors.go | 9 ++++++--- jaws.go | 5 ++++- requestpool.go | 5 +++-- serve.go | 21 +++++++++++++++++++-- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 76ecee41..b61a518c 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)` supports positive `requestTimeout` values that +are exact multiples of `time.Second`; the minimum is `time.Second`. Behavior is +unspecified for other values. + +For pending Requests and claimed Requests whose WebSocket processing has not +started, this setting controls approximate periodic lifecycle cleanup, not a +hard wall-clock deadline. Activity is stored as whole-second samples relative to +the epoch captured by `jaws.New()`, and timeout-based retirement occurs only +during maintenance passes. The effective retirement time is therefore not +measured precisely from a Request's creation or latest `ui.RequestWriter` write. + +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 whole-second 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. @@ -450,6 +462,10 @@ Set `Jaws.WebSocketPingInterval` to control this. The default is `jaws.DefaultWebSocketPingInterval` (1 minute). Set it to `0` or a negative value to disable keepalive pings. +Each ping uses the `requestTimeout` passed to `ServeWithTimeout` directly; see +[Request lifecycle invariants](#request-lifecycle-invariants) for how this differs +from retirement before WebSocket processing starts. + ### Safe to call before `Serve()` The following APIs are safe to call before starting the JaWS processing diff --git a/errors.go b/errors.go index e18877b0..794183f5 100644 --- a/errors.go +++ b/errors.go @@ -207,9 +207,12 @@ 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 periodic maintenance retires a Request +// before its WebSocket processing starts. +// +// See [Jaws.ServeWithTimeout] for the supported timeout and approximate +// retirement semantics. The most common reason is that the client is not using +// JavaScript. var ErrNoWebSocketRequest errNoWebSocketRequest type errNoWebSocketRequest struct { diff --git a/jaws.go b/jaws.go index 63662ff7..fdb1ce5a 100644 --- a/jaws.go +++ b/jaws.go @@ -64,7 +64,10 @@ 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 used by [Jaws.Serve]. + // + // See [Jaws.ServeWithTimeout] for Request retirement before WebSocket + // processing and active WebSocket keepalive semantics. DefaultWebSocketTimeout = time.Second * 10 // DefaultMaxPendingRequestsPerIP is the default maximum number of unclaimed diff --git a/requestpool.go b/requestpool.go index 024441fb..9425d991 100644 --- a/requestpool.go +++ b/requestpool.go @@ -33,8 +33,9 @@ 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 pending Requests that remain idle +// past its approximate timeout. [Jaws.Serve] uses [DefaultWebSocketTimeout]; see +// [Jaws.ServeWithTimeout] for the supported duration and full timing semantics. // // When [Jaws.MaxPendingRequestsPerIP] is positive and already reached, // NewRequest retires the oldest idle pending Request from the same IP. If every diff --git a/serve.go b/serve.go index 2c296ae2..cdf14924 100644 --- a/serve.go +++ b/serve.go @@ -29,7 +29,24 @@ func (jw *Jaws) getWebSocketTimeout() (t time.Duration) { return } -// ServeWithTimeout begins processing requests with the given timeout. +// ServeWithTimeout begins processing requests. +// +// requestTimeout must be positive and an exact multiple of [time.Second]. The +// minimum supported value is [time.Second]. Values outside this domain have +// unspecified behavior. +// +// For pending Requests and claimed Requests whose WebSocket processing has not +// started, requestTimeout controls approximate periodic lifecycle cleanup, not +// a hard wall-clock deadline. Activity is represented by whole-second samples +// relative to the epoch captured when the [Jaws] instance is created by [New], +// and timeout-based retirement occurs only during maintenance passes. The +// effective retirement time is not measured precisely from a Request's creation +// or latest recorded write. +// +// When [Jaws.WebSocketPingInterval] is positive, requestTimeout is also passed +// directly as each keepalive ping's timeout on an active WebSocket. Ping timing +// does not use those whole-second activity samples or the maintenance schedule. +// // It is intended to run on its own goroutine. // It returns when [Jaws.Close] is called. func (jw *Jaws) ServeWithTimeout(requestTimeout time.Duration) { @@ -128,7 +145,7 @@ func (jw *Jaws) ServeWithTimeout(requestTimeout time.Duration) { } } -// Serve calls ServeWithTimeout(DefaultWebSocketTimeout). +// Serve calls [Jaws.ServeWithTimeout] with [DefaultWebSocketTimeout]. // It is intended to run on its own goroutine. // It returns when [Jaws.Close] is called. func (jw *Jaws) Serve() { From e273b2701c4175337cdaa8a45e623a6c31fccc2d Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 16:26:12 +0200 Subject: [PATCH 2/2] docs: tighten timeout lifecycle contract State the exact supported timeout range and activity markers. Keep exported docs caller-facing while moving implementation terminology out of the rendered API contract. --- README.md | 24 ++++++++++-------------- errors.go | 8 ++------ jaws.go | 5 +---- lib/ui/requestwriter.go | 2 +- request.go | 13 ++++++++----- requestpool.go | 8 +++++--- serve.go | 31 ++++++++++++++----------------- 7 files changed, 41 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index b61a518c..c50ae1ac 100644 --- a/README.md +++ b/README.md @@ -371,20 +371,20 @@ 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)` supports positive `requestTimeout` values that -are exact multiples of `time.Second`; the minimum is `time.Second`. Behavior is -unspecified for other values. +`ServeWithTimeout(requestTimeout)` requires an exact multiple of `time.Second` +from `time.Second` through 2,147,483,646 seconds. Other values have unspecified +behavior. -For pending Requests and claimed Requests whose WebSocket processing has not -started, this setting controls approximate periodic lifecycle cleanup, not a -hard wall-clock deadline. Activity is stored as whole-second samples relative to -the epoch captured by `jaws.New()`, and timeout-based retirement occurs only -during maintenance passes. The effective retirement time is therefore not -measured precisely from a Request's creation or latest `ui.RequestWriter` write. +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 whole-second activity samples or the maintenance schedule. +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 @@ -462,10 +462,6 @@ Set `Jaws.WebSocketPingInterval` to control this. The default is `jaws.DefaultWebSocketPingInterval` (1 minute). Set it to `0` or a negative value to disable keepalive pings. -Each ping uses the `requestTimeout` passed to `ServeWithTimeout` directly; see -[Request lifecycle invariants](#request-lifecycle-invariants) for how this differs -from retirement before WebSocket processing starts. - ### Safe to call before `Serve()` The following APIs are safe to call before starting the JaWS processing diff --git a/errors.go b/errors.go index 794183f5..12942416 100644 --- a/errors.go +++ b/errors.go @@ -207,12 +207,8 @@ func newErrTooManyPendingRequests(remoteIP netip.Addr, limit int) error { return errTooManyPendingRequests{Addr: remoteIP, Limit: limit} } -// ErrNoWebSocketRequest is reported when periodic maintenance retires a Request -// before its WebSocket processing starts. -// -// See [Jaws.ServeWithTimeout] for the supported timeout and approximate -// retirement semantics. 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 fdb1ce5a..61d7a9aa 100644 --- a/jaws.go +++ b/jaws.go @@ -64,10 +64,7 @@ const ( // DefaultWebSocketPingInterval is the default WebSocket keepalive ping interval. DefaultWebSocketPingInterval = time.Minute - // DefaultWebSocketTimeout is the timeout used by [Jaws.Serve]. - // - // See [Jaws.ServeWithTimeout] for Request retirement before WebSocket - // processing and active WebSocket keepalive semantics. + // 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 9425d991..1992d883 100644 --- a/requestpool.go +++ b/requestpool.go @@ -33,9 +33,8 @@ import ( // Do not retain the pointer beyond the initial HTTP handling and rendering; see // [Request]. // -// [Jaws.ServeWithTimeout] periodically retires pending Requests that remain idle -// past its approximate timeout. [Jaws.Serve] uses [DefaultWebSocketTimeout]; see -// [Jaws.ServeWithTimeout] for the supported duration and full timing semantics. +// [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 @@ -216,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 cdf14924..42455a77 100644 --- a/serve.go +++ b/serve.go @@ -31,24 +31,21 @@ func (jw *Jaws) getWebSocketTimeout() (t time.Duration) { // ServeWithTimeout begins processing requests. // -// requestTimeout must be positive and an exact multiple of [time.Second]. The -// minimum supported value is [time.Second]. Values outside this domain have -// unspecified behavior. +// requestTimeout must be an exact multiple of [time.Second] from [time.Second] +// through 2,147,483,646 seconds. Other values have unspecified behavior. // -// For pending Requests and claimed Requests whose WebSocket processing has not -// started, requestTimeout controls approximate periodic lifecycle cleanup, not -// a hard wall-clock deadline. Activity is represented by whole-second samples -// relative to the epoch captured when the [Jaws] instance is created by [New], -// and timeout-based retirement occurs only during maintenance passes. The -// effective retirement time is not measured precisely from a Request's creation -// or latest recorded write. +// 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, requestTimeout is also passed -// directly as each keepalive ping's timeout on an active WebSocket. Ping timing -// does not use those whole-second activity samples or the maintenance schedule. +// 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. -// It returns when [Jaws.Close] is called. +// 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) @@ -146,8 +143,8 @@ func (jw *Jaws) ServeWithTimeout(requestTimeout time.Duration) { } // Serve calls [Jaws.ServeWithTimeout] with [DefaultWebSocketTimeout]. -// It is intended to run on its own goroutine. -// It returns when [Jaws.Close] is called. +// It is intended to run on its own goroutine and returns when [Jaws.Close] is +// called. func (jw *Jaws) Serve() { jw.ServeWithTimeout(DefaultWebSocketTimeout) }