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
33 changes: 27 additions & 6 deletions AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,25 @@ Elements only to their owners. Broadcasts, session reload/close helpers, and
dirty updates share the serving loop. Start `Serve` or `ServeWithTimeout` before
using them.

### Status metrics

Status-tag updates are opt-in. `Store`, `Or`, or `And` status metric flags in
`Jaws.StatusMetrics`; its default zero value disables sampling and tag updates.
Each status-tag accessor returns a stable, comparable tag unique to that Jaws
instance and metric. Attach it to the Element that renders the matching count.

While `Serve` or `ServeWithTimeout` is running, maintenance samples selected
metrics after Request and Session cleanup. It dirties each tag on the first sample
after selection and whenever its sampled count changes; intermediate changes may
coalesce.

Active Requests are Requests whose `ServeHTTP` loops are running, so tabs count
separately. Active Sessions are distinct registered Sessions attached to at least
one such Request, so several tabs sharing a Session count once. `SessionCount`
also includes Sessions retained during disconnect grace. A non-nil error reported
through a non-nil `Jaws` or one of its Requests increments `ErrorCount`, even
without a Logger or after shutdown; `StatusMetricErrors` controls only tag updates.

### Calls before Serve

The following operations are safe before the processing loop starts:
Expand All @@ -191,7 +210,8 @@ The following operations are safe before the processing loop starts:
`RemoveTemplateLookuper`, `LookupTemplate`, `GenerateHeadHTML`, `Setup`, and
`FaviconURL`.
* Inspection and logging: `RequestCount`, `RequestCounts`, `Pending`,
`SessionCount`, `Sessions`, `Log`, and `MustLog`.
`SessionCount`, `ActiveSessionCount`, `Sessions`, `ErrorCount`, status-tag
accessors, atomic `StatusMetrics` operations, `Log`, and `MustLog`.
* Static and ping endpoints through `ServeHTTP`: `/jaws/.ping`, the hashed
built-in JavaScript URL, and the hashed built-in stylesheet URL.

Expand Down Expand Up @@ -300,11 +320,12 @@ transport failures ordinarily end the Request as an ordinary cancellation and
are not sent to the logger. When `Jaws.Debug` is enabled, their underlying
transport error is retained in the Request cancellation cause, which is passed
to `Jaws.Log`.
`Jaws.Log` and configured `MustLog` calls enqueue `Logger.Error` callbacks for
serial asynchronous delivery. Callback panics are contained. `Close` stops
accepting new log entries and lets accepted entries drain without waiting;
`Serve` and `ServeWithTimeout` wait for the drain before returning normally. A
blocked logger callback delays later entries and the final drain.
Errors accepted for Logger delivery are dispatched through `Logger.Error`
serially and asynchronously. Callback panics are contained. `Close` stops
accepting Logger deliveries; later reports still increment `ErrorCount` while
accepted entries drain. `Serve` and `ServeWithTimeout` wait for the drain before
returning normally. A blocked logger callback delays later entries and the final
drain.

## Routing

Expand Down
88 changes: 52 additions & 36 deletions jaws.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ type Jid = jid.Jid // convenience alias
// WebSockets. The zero value is not ready for use; construct instances with
// [New] to ensure the helper goroutines and static assets are prepared.
//
// The exported configuration fields are ordinary fields, not live synchronized
// settings. Several are consulted on each connection or request (for example
// MaxPendingRequestsPerIP and WebSocketPingInterval), so set them all before
// exposing handlers, creating Requests, or starting [Jaws.Serve] /
// [Jaws.ServeWithTimeout]; mutating one after serving has begun is an
// unsynchronized write and is not supported. Methods document their own
// concurrency behavior and may be called concurrently when stated.
// Except for [Jaws.StatusMetrics], the exported configuration fields are ordinary
// fields, not live synchronized settings. Several are consulted on each connection
// or request (for example MaxPendingRequestsPerIP and WebSocketPingInterval), so set
// them all before exposing handlers, creating Requests, or starting [Jaws.Serve] /
// [Jaws.ServeWithTimeout]; mutating one after serving has begun is an unsynchronized
// write and is not supported. StatusMetrics is atomic and may be changed while
// serving. Methods document their own concurrency behavior and may be called
// concurrently when stated.
type Jaws struct {
// CookieName is the name used for session cookies.
//
Expand Down Expand Up @@ -117,6 +118,18 @@ type Jaws struct {
// function must return promptly and must not synchronously call this Jaws or
// one of its Requests, or wait for work that does. See [Request.SetContext].
BaseContext context.Context
// StatusMetrics selects status metrics for tag updates.
//
// While [Jaws.Serve] or [Jaws.ServeWithTimeout] is running, each maintenance
// pass samples selected metrics and dirties each newly selected or changed
// metric's tag. Changes between samples are coalesced.
//
// Use [atomic.Uint32.Store], [atomic.Uint32.Or], or [atomic.Uint32.And] with
// [StatusMetricAll] or individual status metric flags. Only bits in
// StatusMetricAll are interpreted. The zero value disables status sampling and
// tag updates. Atomic operations may be used concurrently, including before
// serving.
StatusMetrics atomic.Uint32
// WebSocketPingInterval controls read-idle keepalive pings.
//
// When a WebSocket read remains pending for this interval, JaWS pings the peer.
Expand All @@ -136,12 +149,14 @@ type Jaws struct {
unsubCh chan chan wire.Message
updateTicker *time.Ticker
serving atomic.Bool
reportedErrors atomic.Uint64
loggerQueue *loggerQueue
defaultAuthOnce sync.Once // guards lazy creation of defaultAuthVal
defaultAuthVal *DefaultAuth // shared fail-open Auth; see [Jaws.DefaultAuth]
requestBufferPool sync.Pool // reusable *requestBuffers; Requests themselves are never pooled or reused
serveJS *staticserve.StaticServe
serveCSS *staticserve.StaticServe
statusTags statusTags
mu deadlock.RWMutex // protects following
headPrefix string
faviconURL string
Expand All @@ -155,6 +170,7 @@ type Jaws struct {
sessions map[key.Key]*Session
dirty map[any]int
dirtOrder int
statusSample statusSample
}

// New allocates a JaWS instance with the default configuration.
Expand Down Expand Up @@ -215,11 +231,11 @@ func New() (jw *Jaws, err error) {
//
// Calls to [Jaws.NewRequest] after shutdown begins return Requests with
// already-canceled contexts that [Jaws.UseRequest] cannot claim. Broadcasts and
// sends may be discarded after Done closes. Close stops accepting errors from
// [Jaws.Log] and lets those already accepted drain without waiting for their
// callbacks; later Log calls are discarded. On normal return after shutdown,
// [Jaws.Serve] and [Jaws.ServeWithTimeout] wait for the drain. Subsequent calls
// to Close have no effect.
// sends may be discarded after Done closes. Close stops accepting errors for
// Logger delivery. Accepted errors continue draining asynchronously; later
// [Jaws.Log] calls are counted but not delivered. On normal return after shutdown,
// [Jaws.Serve] and [Jaws.ServeWithTimeout] wait for the drain. Subsequent calls to
// Close have no effect.
func (jw *Jaws) Close() {
jw.mu.Lock()
select {
Expand Down Expand Up @@ -315,13 +331,7 @@ func (jw *Jaws) RequestCounts() (total, active int) {
jw.mu.RLock()
defer jw.mu.RUnlock()
total = jw.requestCount
for _, rq := range jw.requests {
if rq != nil {
if rq.loadState() == reqRunning {
active++
}
}
}
active = jw.activeRequestCountLocked()
return
}

Expand All @@ -335,34 +345,40 @@ func (jw *Jaws) RequestCount() (n int) {
return
}

// Log queues an error for the [Jaws.Logger] and returns err.
// Log reports an error and returns err.
//
// Delivery is asynchronous and FIFO-serialized for each Jaws instance. Logger.Error
// runs without JaWS core locks and may re-enter the same Jaws subject to the normal
// lifecycle rules. A panic from Logger.Error is recovered by the logging dispatcher.
// Each non-nil err increments [Jaws.ErrorCount]. A nil Logger or a report after
// [Jaws.Done] closes prevents delivery but not counting. Reports accepted for
// Logger delivery are dispatched asynchronously and FIFO-serialized for each
// Jaws instance. Logger.Error runs without JaWS core locks and may re-enter the
// same Jaws subject to the normal lifecycle rules. A panic from Logger.Error is
// recovered by the logging dispatcher.
//
// Log is safe for concurrent use, including with [Jaws.Close]. It has no effect
// if jw is nil, err is nil, the Logger is nil, or shutdown has begun. It always
// returns err. The queue applies no capacity backpressure, so errors accumulate
// in memory when Logger.Error does not keep pace. Log retains err for delivery;
// callers must not mutate state exposed by err concurrently after passing it.
// Log is safe for concurrent use, including with [Jaws.Close]. A nil receiver or
// nil err is not counted or delivered. Log always returns err. The queue applies
// no capacity backpressure, so errors accumulate in memory when Logger.Error does
// not keep pace. Log retains err for delivery; callers must not mutate state
// exposed by err concurrently after passing it.
func (jw *Jaws) Log(err error) error {
if err != nil && jw != nil && jw.Logger != nil {
jw.loggerQueue.enqueue(jw.Logger, err)
if err != nil && jw != nil {
jw.reportedErrors.Add(1)
if logger := jw.Logger; logger != nil {
jw.loggerQueue.enqueue(logger, err)
}
}
return err
}

// MustLog passes a non-nil err to [Jaws.Log], or panics if no [Jaws.Logger] is
// MustLog passes a non-nil err to [Jaws.Log], then panics if no [Jaws.Logger] is
// configured.
//
// A nil err has no effect, including on a nil receiver. With a configured
// Logger, errors submitted after shutdown begins are discarded by Log.
// A nil err has no effect, including on a nil receiver. With a non-nil err, a nil
// receiver panics without counting; a non-nil receiver counts the error and then
// panics if Logger is nil. See [Jaws.Log] for delivery and shutdown behavior.
func (jw *Jaws) MustLog(err error) {
if err != nil {
if jw != nil && jw.Logger != nil {
_ = jw.Log(err)
} else {
_ = jw.Log(err)
if jw == nil || jw.Logger == nil {
panic(err)
}
}
Expand Down
5 changes: 5 additions & 0 deletions jaws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,15 @@ func TestMustLog_PanicsWithoutLogger(t *testing.T) {
t.Fatal(err)
}
defer jw.Close()
_ = jw.Log(nil)
jw.MustLog(nil)
defer func() {
if recover() == nil {
t.Error("MustLog with no Logger must panic")
}
if got := jw.ErrorCount(); got != 1 {
t.Errorf("ErrorCount() = %d, want 1", got)
}
}()
jw.MustLog(errors.New("boom"))
}
Expand Down
9 changes: 9 additions & 0 deletions loggerqueue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ func TestJawsLogReturnsBeforeLoggerAndCloseDrains(t *testing.T) {
if got := <-logger.started; got != wantErr {
t.Fatalf("Logger.Error error = %v, want %v", got, wantErr)
}
if got := jw.ErrorCount(); got != 1 {
t.Fatalf("ErrorCount() while Logger.Error is blocked = %d, want 1", got)
}

secondErr := errors.New("queued while logger blocked")
_ = jw.Log(secondErr)
Expand All @@ -104,6 +107,9 @@ func TestJawsLogReturnsBeforeLoggerAndCloseDrains(t *testing.T) {
if got := jw.Log(errors.New("after close")); got == nil {
t.Fatal("Log after Close returned nil")
}
if got := jw.ErrorCount(); got != 3 {
t.Fatalf("ErrorCount() after Close = %d, want 3", got)
}
close(logger.release)
synctest.Wait()
<-jw.loggerQueue.doneCh
Expand Down Expand Up @@ -208,4 +214,7 @@ func TestJawsLogSerializesRecoversAndAllowsReentry(t *testing.T) {
if got := <-logger.calls; got != thirdErr {
t.Fatalf("third Logger.Error = %v, want %v", got, thirdErr)
}
if got := jw.ErrorCount(); got != 3 {
t.Fatalf("ErrorCount() = %d, want 3", got)
}
}
6 changes: 3 additions & 3 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -1100,10 +1100,10 @@ func (asw *autoSessionWriter) WriteHeader(statusCode int) {
asw.ResponseWriter.WriteHeader(statusCode)
}

// Log queues an error for the [Jaws.Logger] and returns err.
// Log reports an error through [Jaws.Log] and returns err.
//
// A nil Request behaves like a nil [Jaws]: err is returned without logging. See
// [Jaws.Log] for delivery and shutdown behavior.
// A nil Request returns err without counting or delivery. See [Jaws.Log] for
// counting, delivery, and shutdown behavior.
func (rq *Request) Log(err error) error {
var jw *Jaws
if rq != nil {
Expand Down
34 changes: 26 additions & 8 deletions request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,9 @@ func TestRequest_Log(t *testing.T) {
if loggedErr := logger.next(t); !errors.Is(loggedErr, wantErr) {
t.Fatalf("Request.Log() logged %v, want %v", loggedErr, wantErr)
}
if got := jw.ErrorCount(); got != 1 {
t.Fatalf("ErrorCount() = %d, want 1", got)
}
}

// TestRequest_MustLog covers the nil-receiver forwarding that exists purely for
Expand All @@ -1843,19 +1846,34 @@ func TestRequest_MustLog(t *testing.T) {
if loggedErr := logger.next(t); !errors.Is(loggedErr, wantErr) {
t.Fatalf("Request.MustLog() logged %v, want %v", loggedErr, wantErr)
}
if got := jw.ErrorCount(); got != 1 {
t.Fatalf("ErrorCount() = %d, want 1", got)
}

// A nil error is a no-op even without a Logger.
(*Request)(nil).MustLog(nil)

// Without a Logger it panics, including through a nil *Request.
func() {
defer func() {
if recover() == nil {
t.Error("Request.MustLog with no Logger must panic")
}
}()
(&Request{Jaws: &Jaws{}}).MustLog(wantErr)
}()
missingLogger := new(Jaws)
for _, tt := range []struct {
name string
rq *Request
}{
{"missing Logger", &Request{Jaws: missingLogger}},
{"nil Request", nil},
} {
t.Run(tt.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("MustLog must panic")
}
}()
tt.rq.MustLog(wantErr)
})
}
if got := missingLogger.ErrorCount(); got != 1 {
t.Errorf("ErrorCount() = %d, want 1", got)
}
}

func TestRequest_Dirty(t *testing.T) {
Expand Down
5 changes: 2 additions & 3 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ import (
func (jw *Jaws) Pending() (n int) {
jw.mu.RLock()
defer jw.mu.RUnlock()
for _, pending := range jw.pending {
n += len(pending)
}
n = jw.pendingRequestCountLocked()
return
}

Expand Down Expand Up @@ -202,5 +200,6 @@ func (jw *Jaws) maintenance(requestTimeout time.Duration) {
delete(jw.sessions, k)
}
}
jw.updateStatusLocked()
jw.mu.Unlock()
}
4 changes: 3 additions & 1 deletion session.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@ func (sess *Session) Broadcast(msg wire.Message) {
}
}

// SessionCount returns the number of registered sessions.
// SessionCount returns the number of registered Sessions.
//
// It includes Sessions retained during their disconnect grace period.
func (jw *Jaws) SessionCount() (n int) {
jw.mu.RLock()
n = len(jw.sessions)
Expand Down
Loading
Loading