From 5fc7104fd5a536efc42cabff82dbc5be2fd45270 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sat, 22 Aug 2026 18:35:11 +0200 Subject: [PATCH 1/2] feat: publish opt-in status metrics --- AI.md | 20 ++- jaws.go | 87 +++++++------ jaws_test.go | 5 + loggerqueue_test.go | 9 ++ request.go | 6 +- request_test.go | 34 ++++-- serve.go | 52 +++++++- session.go | 4 +- status.go | 140 +++++++++++++++++++++ status_error_test.go | 61 ++++++++++ status_session_test.go | 208 +++++++++++++++++++++++++++++++ status_test.go | 269 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 841 insertions(+), 54 deletions(-) create mode 100644 status.go create mode 100644 status_error_test.go create mode 100644 status_session_test.go create mode 100644 status_test.go diff --git a/AI.md b/AI.md index 803050bb..65acd426 100644 --- a/AI.md +++ b/AI.md @@ -182,6 +182,23 @@ 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. + +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. `ErrorCount` always +counts non-nil reports; `StatusMetricErrors` controls only its tag updates. + ### Calls before Serve The following operations are safe before the processing loop starts: @@ -191,7 +208,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. diff --git a/jaws.go b/jaws.go index df8f1c3c..9b0d07ad 100644 --- a/jaws.go +++ b/jaws.go @@ -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. // @@ -117,6 +118,16 @@ 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 the status metrics JaWS publishes through status tags. + // + // 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. + StatusMetrics atomic.Uint32 // WebSocketPingInterval controls read-idle keepalive pings. // // When a WebSocket read remains pending for this interval, JaWS pings the peer. @@ -136,12 +147,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 @@ -155,6 +168,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. @@ -215,11 +229,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 new Logger +// deliveries; accepted errors continue draining asynchronously, and later +// [Jaws.Log] calls still increment [Jaws.ErrorCount]. 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 { @@ -313,15 +327,9 @@ func (jw *Jaws) LookupTemplate(name string) *template.Template { // count includes Requests whose [Request.ServeHTTP] loop is running. 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() + jw.mu.RUnlock() return } @@ -335,34 +343,39 @@ 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], including when Logger is nil or +// shutdown has begun. Delivery to Logger 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. // -// 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. For a non-nil err, Log +// runs before the Logger check; a nil receiver or missing Logger then panics. See +// [Jaws.Log] for counting, 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) } } diff --git a/jaws_test.go b/jaws_test.go index a0458555..387f8ccf 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -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")) } diff --git a/loggerqueue_test.go b/loggerqueue_test.go index c528f763..e93d6c72 100644 --- a/loggerqueue_test.go +++ b/loggerqueue_test.go @@ -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) @@ -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 @@ -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) + } } diff --git a/request.go b/request.go index d835756a..50519cf1 100644 --- a/request.go +++ b/request.go @@ -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 { diff --git a/request_test.go b/request_test.go index 1f912058..d2b79068 100644 --- a/request_test.go +++ b/request_test.go @@ -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 @@ -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) { diff --git a/serve.go b/serve.go index 5a0cd3f7..10db0f66 100644 --- a/serve.go +++ b/serve.go @@ -15,10 +15,8 @@ import ( // Pending returns the number of requests waiting for their WebSocket callbacks. func (jw *Jaws) Pending() (n int) { jw.mu.RLock() - defer jw.mu.RUnlock() - for _, pending := range jw.pending { - n += len(pending) - } + n = jw.pendingRequestCountLocked() + jw.mu.RUnlock() return } @@ -186,6 +184,9 @@ func (jw *Jaws) unsubscribe(msgCh chan wire.Message) { } func (jw *Jaws) maintenance(requestTimeout time.Duration) { + var dirtyStorage [5]any + dirtyTags := dirtyStorage[:0] + jw.mu.Lock() nowSeconds := jw.runtimeSeconds.Load() for _, rq := range jw.requests { @@ -202,5 +203,48 @@ func (jw *Jaws) maintenance(requestTimeout time.Duration) { delete(jw.sessions, k) } } + enabled := jw.StatusMetrics.Load() & StatusMetricAll + sample := &jw.statusSample + newlyEnabled := enabled &^ sample.enabled + if enabled&StatusMetricActiveRequests != 0 { + activeRequests := jw.activeRequestCountLocked() + if newlyEnabled&StatusMetricActiveRequests != 0 || activeRequests != sample.activeRequests { + dirtyTags = append(dirtyTags, jw.ActiveRequestCountTag()) + } + sample.activeRequests = activeRequests + } + if enabled&StatusMetricPendingRequests != 0 { + pendingRequests := jw.pendingRequestCountLocked() + if newlyEnabled&StatusMetricPendingRequests != 0 || pendingRequests != sample.pendingRequests { + dirtyTags = append(dirtyTags, jw.PendingRequestCountTag()) + } + sample.pendingRequests = pendingRequests + } + if enabled&StatusMetricSessions != 0 { + sessions := len(jw.sessions) + if newlyEnabled&StatusMetricSessions != 0 || sessions != sample.sessions { + dirtyTags = append(dirtyTags, jw.SessionCountTag()) + } + sample.sessions = sessions + } + if enabled&StatusMetricActiveSessions != 0 { + activeSessions := jw.activeSessionCountLocked() + if newlyEnabled&StatusMetricActiveSessions != 0 || activeSessions != sample.activeSessions { + dirtyTags = append(dirtyTags, jw.ActiveSessionCountTag()) + } + sample.activeSessions = activeSessions + } + if enabled&StatusMetricErrors != 0 { + errorCount := jw.reportedErrors.Load() + if newlyEnabled&StatusMetricErrors != 0 || errorCount != sample.errors { + dirtyTags = append(dirtyTags, jw.ErrorCountTag()) + } + sample.errors = errorCount + } + sample.enabled = enabled jw.mu.Unlock() + + if len(dirtyTags) > 0 { + jw.setDirty(dirtyTags) + } } diff --git a/session.go b/session.go index c1381eae..6a32caee 100644 --- a/session.go +++ b/session.go @@ -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) diff --git a/status.go b/status.go new file mode 100644 index 00000000..69cac6f6 --- /dev/null +++ b/status.go @@ -0,0 +1,140 @@ +package jaws + +const ( + // StatusMetricActiveRequests enables dirtying [Jaws.ActiveRequestCountTag]. + StatusMetricActiveRequests uint32 = 1 << iota + // StatusMetricPendingRequests enables dirtying [Jaws.PendingRequestCountTag]. + StatusMetricPendingRequests + // StatusMetricSessions enables dirtying [Jaws.SessionCountTag]. + StatusMetricSessions + // StatusMetricActiveSessions enables dirtying [Jaws.ActiveSessionCountTag]. + StatusMetricActiveSessions + // StatusMetricErrors enables dirtying [Jaws.ErrorCountTag]. + StatusMetricErrors + // StatusMetricAll selects every status metric. + StatusMetricAll = StatusMetricActiveRequests | + StatusMetricPendingRequests | + StatusMetricSessions | + StatusMetricActiveSessions | + StatusMetricErrors +) + +// statusTag is non-zero-sized so pointers to distinct fields cannot share an +// address. Its value is immaterial; pointer identity is the dependency key. +type statusTag uint8 + +type statusTags struct { + activeRequests statusTag + pendingRequests statusTag + sessions statusTag + activeSessions statusTag + errors statusTag +} + +type statusSample struct { + enabled uint32 + activeRequests int + pendingRequests int + sessions int + activeSessions int + errors uint64 +} + +// ActiveRequestCountTag returns this instance's active-Request count tag. +// +// Use it with the active result from [Jaws.RequestCounts]. The tag is stable for +// the Jaws lifetime and unique to this instance and metric. Select +// [StatusMetricActiveRequests] to have maintenance dirty it when the count changes. +func (jw *Jaws) ActiveRequestCountTag() any { + return &jw.statusTags.activeRequests +} + +// PendingRequestCountTag returns this instance's pending-Request count tag. +// +// Use it with [Jaws.Pending]. The tag is stable for the Jaws lifetime and unique +// to this instance and metric. Select [StatusMetricPendingRequests] to have +// maintenance dirty it when the count changes. +func (jw *Jaws) PendingRequestCountTag() any { + return &jw.statusTags.pendingRequests +} + +// SessionCountTag returns this instance's registered-Session count tag. +// +// Use it with [Jaws.SessionCount]. The tag is stable for the Jaws lifetime and +// unique to this instance and metric. Select [StatusMetricSessions] to have +// maintenance dirty it when the count changes. +func (jw *Jaws) SessionCountTag() any { + return &jw.statusTags.sessions +} + +// ActiveSessionCount returns the active Session count. +// +// It counts registered Sessions attached to at least one Request whose +// [Request.ServeHTTP] loop is running. A Session shared by several running Requests +// counts once. A Session retained only for its disconnect grace period is inactive. +// ActiveSessionCount is safe for concurrent use. +func (jw *Jaws) ActiveSessionCount() (n int) { + jw.mu.RLock() + n = jw.activeSessionCountLocked() + jw.mu.RUnlock() + return +} + +// ActiveSessionCountTag returns this instance's active-Session count tag. +// +// Use it with [Jaws.ActiveSessionCount]. The tag is stable for the Jaws lifetime +// and unique to this instance and metric. Select [StatusMetricActiveSessions] to +// have maintenance dirty it when the count changes. +func (jw *Jaws) ActiveSessionCountTag() any { + return &jw.statusTags.activeSessions +} + +// ErrorCount returns the number of errors reported to this instance. +// +// Every non-nil error passed to [Jaws.Log] or [Jaws.MustLog] counts, including +// calls through [Request.Log] and [Request.MustLog], calls without a Logger, and +// calls after shutdown. Logger delivery, latency, and panics do not affect the +// count. ErrorCount is safe for concurrent use. [StatusMetricErrors] controls tag +// updates, not counting. +func (jw *Jaws) ErrorCount() uint64 { + return jw.reportedErrors.Load() +} + +// ErrorCountTag returns this instance's reported-error count tag. +// +// Use it with [Jaws.ErrorCount]. The tag is stable for the Jaws lifetime and +// unique to this instance and metric. Select [StatusMetricErrors] to have +// maintenance dirty it when the count changes. +func (jw *Jaws) ErrorCountTag() any { + return &jw.statusTags.errors +} + +func (jw *Jaws) activeRequestCountLocked() (n int) { + for _, rq := range jw.requests { + if rq != nil && rq.loadState() == reqRunning { + n++ + } + } + return +} + +func (jw *Jaws) pendingRequestCountLocked() (n int) { + for _, pending := range jw.pending { + n += len(pending) + } + return +} + +func (jw *Jaws) activeSessionCountLocked() (n int) { + for _, sess := range jw.sessions { + sess.mu.RLock() + for _, rq := range sess.requests { + if rq.loadState() == reqRunning { + n++ + break + } + } + sess.mu.RUnlock() + } + return +} diff --git a/status_error_test.go b/status_error_test.go new file mode 100644 index 00000000..36c295dc --- /dev/null +++ b/status_error_test.go @@ -0,0 +1,61 @@ +package jaws + +import ( + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestJaws_ErrorCountConcurrentReports(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + first := jw.newRequest(httptest.NewRequest(http.MethodGet, "/first", nil)) + second := jw.newRequest(httptest.NewRequest(http.MethodGet, "/second", nil)) + firstElem := first.NewElement(new(testUi)) + secondElem := second.NewElement(new(testUi)) + firstElem.Tag(jw.ErrorCountTag()) + secondElem.Tag(jw.ErrorCountTag()) + jw.StatusMetrics.Store(StatusMetricErrors) + jw.maintenance(time.Hour) + if got := jw.distributeDirt(); got != 1 { + t.Fatalf("initial sample distributed %d selectors, want 1", got) + } + requireUpdateList(t, first, firstElem) + requireUpdateList(t, second, secondElem) + + wantErr := errors.New("concurrent report") + const workers = 16 + const reportsPerWorker = 64 + var wg sync.WaitGroup + wg.Add(workers) + for worker := range workers { + go func() { + defer wg.Done() + for report := range reportsPerWorker { + if (worker+report)%2 == 0 { + _ = jw.Log(wantErr) + } else { + _ = first.Log(wantErr) + } + } + }() + } + wg.Wait() + + want := uint64(workers * reportsPerWorker) + if got := jw.ErrorCount(); got != want { + t.Fatalf("ErrorCount() = %d, want %d", got, want) + } + jw.maintenance(time.Hour) + if got := jw.distributeDirt(); got != 1 { + t.Fatalf("distributed selectors = %d, want 1", got) + } + requireUpdateList(t, first, firstElem) + requireUpdateList(t, second, secondElem) +} diff --git a/status_session_test.go b/status_session_test.go new file mode 100644 index 00000000..1165868d --- /dev/null +++ b/status_session_test.go @@ -0,0 +1,208 @@ +package jaws + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newSessionTestRequest(sess *Session, path string) *http.Request { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.AddCookie(sess.Cookie()) + return r +} + +func waitTestRequestReady(t *testing.T, tr *TestRequest) { + t.Helper() + select { + case <-tr.ReadyCh: + case <-tr.DoneCh: + t.Fatal("Request finished before becoming ready") + case <-time.After(testTimeout): + t.Fatal("timeout waiting for Request loop") + } +} + +func stopTestRequest(t *testing.T, tr *TestRequest) { + t.Helper() + select { + case <-tr.DoneCh: + return + default: + } + tr.Close() + select { + case <-tr.DoneCh: + case <-time.After(testTimeout): + t.Fatal("timeout waiting for Request loop") + } +} + +func requireSessionCounts(t *testing.T, jw *Jaws, sessions, active int) { + t.Helper() + if got := jw.SessionCount(); got != sessions { + t.Errorf("SessionCount() = %d, want %d", got, sessions) + } + if got := jw.ActiveSessionCount(); got != active { + t.Errorf("ActiveSessionCount() = %d, want %d", got, active) + } +} + +func TestJaws_SessionCountTag(t *testing.T) { + jw := newStatusTestJaws(t, func(jw *Jaws) { + jw.StatusMetrics.Store(StatusMetricSessions) + }) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.SessionCountTag()) + + first := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/first", nil)) + if first == nil { + t.Fatal("NewSession returned nil") + } + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.SessionCountTag()) + + first.Close() + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.SessionCountTag()) + + expired := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/expired", nil)) + if expired == nil { + t.Fatal("NewSession returned nil") + } + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.SessionCountTag()) + expired.mu.Lock() + expired.deadline = time.Now().Add(-time.Second) + expired.mu.Unlock() + jw.maintenance(time.Hour) + if got := jw.SessionCount(); got != 0 { + t.Fatalf("SessionCount() = %d, want 0", got) + } + requireDirtyTags(t, jw, jw.SessionCountTag()) +} + +func TestJaws_ActiveSessionCount(t *testing.T) { + jw := newStatusTestJaws(t, func(jw *Jaws) { + jw.StatusMetrics.Store(StatusMetricActiveSessions) + }) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.ActiveSessionCountTag()) + + firstSession := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/first", nil)) + secondSession := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/second", nil)) + if firstSession == nil || secondSession == nil { + t.Fatal("NewSession returned nil") + } + requireSessionCounts(t, jw, 2, 0) + waitingRequest := newSessionTestRequest(firstSession, "/waiting") + waiting := jw.newRequest(waitingRequest) + if got := waiting.Session(); got != firstSession { + t.Fatalf("pending Request Session() = %p, want %p", got, firstSession) + } + requireSessionCounts(t, jw, 2, 0) + if got := jw.UseRequest(waiting.JawsKey, waitingRequest); got != waiting { + t.Fatalf("UseRequest() = %p, want %p", got, waiting) + } + requireSessionCounts(t, jw, 2, 0) + jw.mu.Lock() + jw.retireNonRunningRequestLocked(waiting) + jw.mu.Unlock() + + firstTab := NewTestRequest(jw, newSessionTestRequest(firstSession, "/first-tab")) + if firstTab == nil { + t.Fatal("NewTestRequest returned nil") + } + t.Cleanup(func() { stopTestRequest(t, firstTab) }) + waitTestRequestReady(t, firstTab) + requireSessionCounts(t, jw, 2, 1) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.ActiveSessionCountTag()) + + secondTab := NewTestRequest(jw, newSessionTestRequest(firstSession, "/second-tab")) + if secondTab == nil { + t.Fatal("NewTestRequest returned nil") + } + t.Cleanup(func() { stopTestRequest(t, secondTab) }) + waitTestRequestReady(t, secondTab) + requireSessionCounts(t, jw, 2, 1) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw) + + thirdTab := NewTestRequest(jw, newSessionTestRequest(secondSession, "/third-tab")) + if thirdTab == nil { + t.Fatal("NewTestRequest returned nil") + } + t.Cleanup(func() { stopTestRequest(t, thirdTab) }) + waitTestRequestReady(t, thirdTab) + requireSessionCounts(t, jw, 2, 2) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.ActiveSessionCountTag()) + + stopTestRequest(t, firstTab) + requireSessionCounts(t, jw, 2, 2) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw) + + stopTestRequest(t, secondTab) + requireSessionCounts(t, jw, 2, 1) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.ActiveSessionCountTag()) + + secondSession.Close() + // The first Session remains registered during its disconnect grace period. + requireSessionCounts(t, jw, 1, 0) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.ActiveSessionCountTag()) + stopTestRequest(t, thirdTab) +} + +func TestJaws_ActiveSessionCountAutoSession(t *testing.T) { + jw := newStatusTestJaws(t, func(jw *Jaws) { + jw.AutoSession = true + jw.StatusMetrics.Store(StatusMetricSessions | StatusMetricActiveSessions) + }) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.SessionCountTag(), jw.ActiveSessionCountTag()) + + server := httptest.NewServer(jw) + defer server.Close() + initial := httptest.NewRequest(http.MethodGet, server.URL+"/", nil) + initial.RemoteAddr = "127.0.0.1:1" + rq := jw.NewRequest(httptest.NewRecorder(), initial) + if rq == nil { + t.Fatal("NewRequest returned nil") + } + connected := make(chan *Session, 1) + rq.SetConnectFn(func(rq *Request) error { + connected <- rq.Session() + return nil + }) + + conn := dialJawsRequest(t, server.URL, rq) + connectionClosed := false + defer func() { + if !connectionClosed { + if closeErr := conn.CloseNow(); closeErr != nil { + t.Errorf("closing WebSocket: %v", closeErr) + } + } + }() + sess := waitForConnectSession(t, connected) + if sess == nil || rq.Session() != sess { + t.Fatalf("AutoSession = %p, Request.Session() = %p", sess, rq.Session()) + } + requireSessionCounts(t, jw, 1, 1) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.SessionCountTag(), jw.ActiveSessionCountTag()) + + if err := conn.CloseNow(); err != nil { + t.Errorf("CloseNow: %v", err) + } + connectionClosed = true + waitForRequestCount(t, jw, 0, testTimeout) + requireSessionCounts(t, jw, 1, 0) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.ActiveSessionCountTag()) +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 00000000..b0960c2b --- /dev/null +++ b/status_test.go @@ -0,0 +1,269 @@ +package jaws + +import ( + "errors" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/coder/websocket" +) + +func newStatusTestJaws(t *testing.T, configure func(*Jaws)) *Jaws { + t.Helper() + jw, err := New() + if err != nil { + t.Fatal(err) + } + // These tests drive maintenance explicitly and inspect its dirt, so prevent + // the independent browser-update ticker from draining that dirt. + jw.updateTicker.Stop() + configure(jw) + serveDone := make(chan struct{}) + go func() { + jw.Serve() + close(serveDone) + }() + t.Cleanup(func() { + jw.Close() + select { + case <-serveDone: + case <-time.After(testTimeout): + t.Error("timeout waiting for Jaws Serve loop") + } + }) + waitForServeLoop(t, jw) + return jw +} + +func requireDirtyTags(t *testing.T, jw *Jaws, want ...any) { + t.Helper() + got := make(map[any]struct{}) + jw.mu.Lock() + for tagValue := range jw.dirty { + got[tagValue] = struct{}{} + } + clear(jw.dirty) + jw.dirtOrder = 0 + jw.mu.Unlock() + if len(got) != len(want) { + t.Fatalf("dirty tags = %v, want %v", got, want) + } + for _, tagValue := range want { + if _, ok := got[tagValue]; !ok { + t.Fatalf("dirty tags = %v, want %v", got, want) + } + } +} + +func requireUpdateList(t *testing.T, rq *Request, want ...*Element) { + t.Helper() + if got := rq.makeUpdateList(); !slices.Equal(got, want) { + t.Fatalf("update list = %v, want %v", got, want) + } +} + +func dialJawsRequest(t *testing.T, serverURL string, rq *Request) *websocket.Conn { + t.Helper() + header := http.Header{} + header.Set("Origin", serverURL) + requestURL := serverURL + "/jaws/" + rq.JawsKeyString() + conn, response, err := websocket.Dial(t.Context(), requestURL, &websocket.DialOptions{HTTPHeader: header}) + if err != nil { + status := 0 + if response != nil { + status = response.StatusCode + } + t.Fatalf("dialing %s: status=%d err=%v", requestURL, status, err) + } + return conn +} + +func TestJaws_StatusMetrics(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + if got := jw.StatusMetrics.Load(); got != 0 { + t.Fatalf("default StatusMetrics = %v, want 0", got) + } + + initial := httptest.NewRequest(http.MethodGet, "/", nil) + first := jw.newRequest(initial) + if sess := jw.NewSession(httptest.NewRecorder(), initial); sess == nil { + t.Fatal("NewSession returned nil") + } + _ = jw.Log(errors.New("disabled")) + if got := jw.ErrorCount(); got != 1 { + t.Fatalf("ErrorCount() with status updates disabled = %d, want 1", got) + } + jw.StatusMetrics.Store(1 << 31) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw) + + selected := StatusMetricPendingRequests | StatusMetricErrors + jw.StatusMetrics.Store(selected) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.PendingRequestCountTag(), jw.ErrorCountTag()) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw) + + if got := jw.UseRequest(first.JawsKey, initial); got != first { + t.Fatalf("UseRequest() = %p, want %p", got, first) + } + _ = jw.Log(errors.New("enabled")) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.PendingRequestCountTag(), jw.ErrorCountTag()) + + jw.StatusMetrics.And(0) + jw.newRequest(httptest.NewRequest(http.MethodGet, "/second", nil)) + _ = jw.Log(errors.New("disabled again")) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw) + + jw.StatusMetrics.Or(selected) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, jw.PendingRequestCountTag(), jw.ErrorCountTag()) + + jw.StatusMetrics.Store(StatusMetricAll) + jw.maintenance(time.Hour) + requireDirtyTags(t, jw, + jw.ActiveRequestCountTag(), + jw.SessionCountTag(), + jw.ActiveSessionCountTag(), + ) +} + +func TestJaws_StatusTags(t *testing.T) { + first, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(first.Close) + second, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(second.Close) + + getters := []func(*Jaws) any{ + (*Jaws).ActiveRequestCountTag, + (*Jaws).PendingRequestCountTag, + (*Jaws).SessionCountTag, + (*Jaws).ActiveSessionCountTag, + (*Jaws).ErrorCountTag, + } + seen := make(map[any]struct{}, len(getters)*2) + for _, getter := range getters { + firstTag := getter(first) + if got := getter(first); got != firstTag { + t.Fatalf("tag changed from %p to %p", firstTag, got) + } + secondTag := getter(second) + for _, tagValue := range []any{firstTag, secondTag} { + if _, ok := seen[tagValue]; ok { + t.Fatalf("duplicate status tag %p", tagValue) + } + seen[tagValue] = struct{}{} + } + } + + rq := second.newRequest(httptest.NewRequest(http.MethodGet, "/", nil)) + foreign := rq.NewElement(new(testUi)) + own := rq.NewElement(new(testUi)) + foreign.Tag(first.ActiveRequestCountTag()) + own.Tag(second.ActiveRequestCountTag()) + second.StatusMetrics.Store(StatusMetricActiveRequests) + second.maintenance(time.Hour) + if got := second.distributeDirt(); got != 1 { + t.Fatalf("distributed selectors = %d, want 1", got) + } + requireUpdateList(t, rq, own) +} + +func TestJaws_ActiveRequestCountTag(t *testing.T) { + ts := newTestServerNoSession(t) + defer ts.Close() + jw := ts.jw + jw.StatusMetrics.Store(StatusMetricActiveRequests) + + // Drain the first enabled sample before registering the observer Element. + jw.maintenance(time.Hour) + jw.distributeDirt() + requireUpdateList(t, ts.rq) + + activeValues := make(chan int, 3) + statusElem := ts.rq.NewElement(&testUi{updateFn: func(*Element) { + _, active := jw.RequestCounts() + activeValues <- active + }}) + statusElem.Tag(jw.ActiveRequestCountTag()) + + wantActive := func(want int) { + t.Helper() + select { + case got := <-activeValues: + if got != want { + t.Fatalf("rendered active Request count = %d, want %d", got, want) + } + case <-time.After(testTimeout): + t.Fatalf("timeout waiting for active Request count %d", want) + } + } + + observerConn, _, err := ts.Dial() + if err != nil { + t.Fatal(err) + } + defer func() { + if closeErr := observerConn.CloseNow(); closeErr != nil { + t.Errorf("closing observer WebSocket: %v", closeErr) + } + }() + select { + case <-ts.connectedCh: + case <-time.After(testTimeout): + t.Fatal("timeout waiting for observer WebSocket") + } + jw.maintenance(time.Hour) + wantActive(1) + + targetInitial := httptest.NewRequest(http.MethodGet, ts.srv.URL+"/target", nil) + targetInitial.RemoteAddr = "127.0.0.1:1" + target := jw.NewRequest(httptest.NewRecorder(), targetInitial) + if target == nil { + t.Fatal("NewRequest returned nil") + } + targetConnected := make(chan struct{}) + target.SetConnectFn(func(*Request) error { + close(targetConnected) + return nil + }) + targetConn := dialJawsRequest(t, ts.srv.URL, target) + targetClosed := false + defer func() { + if !targetClosed { + if closeErr := targetConn.CloseNow(); closeErr != nil { + t.Errorf("closing target WebSocket: %v", closeErr) + } + } + }() + select { + case <-targetConnected: + case <-time.After(testTimeout): + t.Fatal("timeout waiting for target WebSocket") + } + jw.maintenance(time.Hour) + wantActive(2) + + if closeErr := targetConn.Close(websocket.StatusNormalClosure, "done"); closeErr != nil { + t.Fatal(closeErr) + } + targetClosed = true + waitForRequestCount(t, jw, 1, testTimeout) + jw.maintenance(time.Hour) + wantActive(1) +} From b39a9b554dea40c120cd30ca05a629731ebde3ba Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sat, 22 Aug 2026 19:23:07 +0200 Subject: [PATCH 2/2] fix: tighten status metric lifecycle --- AI.md | 23 +++++++------ jaws.go | 39 +++++++++++----------- serve.go | 49 ++-------------------------- status.go | 87 ++++++++++++++++++++++++++++++++++++++++---------- status_test.go | 7 +++- 5 files changed, 112 insertions(+), 93 deletions(-) diff --git a/AI.md b/AI.md index 65acd426..4595a7ac 100644 --- a/AI.md +++ b/AI.md @@ -189,15 +189,17 @@ Status-tag updates are opt-in. `Store`, `Or`, or `And` status metric flags in 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. -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. +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. `ErrorCount` always -counts non-nil reports; `StatusMetricErrors` controls only its tag updates. +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 @@ -318,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 diff --git a/jaws.go b/jaws.go index 9b0d07ad..34c792ff 100644 --- a/jaws.go +++ b/jaws.go @@ -118,15 +118,17 @@ 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 the status metrics JaWS publishes through status tags. + // StatusMetrics selects status metrics for tag updates. // - // Each maintenance pass samples selected metrics and dirties each newly selected - // or changed metric's tag. Changes between samples are coalesced. + // 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. + // tag updates. Atomic operations may be used concurrently, including before + // serving. StatusMetrics atomic.Uint32 // WebSocketPingInterval controls read-idle keepalive pings. // @@ -229,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 new Logger -// deliveries; accepted errors continue draining asynchronously, and later -// [Jaws.Log] calls still increment [Jaws.ErrorCount]. 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 { @@ -327,9 +329,9 @@ func (jw *Jaws) LookupTemplate(name string) *template.Template { // count includes Requests whose [Request.ServeHTTP] loop is running. func (jw *Jaws) RequestCounts() (total, active int) { jw.mu.RLock() + defer jw.mu.RUnlock() total = jw.requestCount active = jw.activeRequestCountLocked() - jw.mu.RUnlock() return } @@ -345,11 +347,12 @@ func (jw *Jaws) RequestCount() (n int) { // Log reports an error and returns err. // -// Each non-nil err increments [Jaws.ErrorCount], including when Logger is nil or -// shutdown has begun. Delivery to Logger 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]. A nil receiver or // nil err is not counted or delivered. Log always returns err. The queue applies @@ -369,9 +372,9 @@ func (jw *Jaws) Log(err error) error { // 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. For a non-nil err, Log -// runs before the Logger check; a nil receiver or missing Logger then panics. See -// [Jaws.Log] for counting, delivery, and shutdown behavior. +// 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 { _ = jw.Log(err) diff --git a/serve.go b/serve.go index 10db0f66..036bf5dc 100644 --- a/serve.go +++ b/serve.go @@ -15,8 +15,8 @@ import ( // Pending returns the number of requests waiting for their WebSocket callbacks. func (jw *Jaws) Pending() (n int) { jw.mu.RLock() + defer jw.mu.RUnlock() n = jw.pendingRequestCountLocked() - jw.mu.RUnlock() return } @@ -184,9 +184,6 @@ func (jw *Jaws) unsubscribe(msgCh chan wire.Message) { } func (jw *Jaws) maintenance(requestTimeout time.Duration) { - var dirtyStorage [5]any - dirtyTags := dirtyStorage[:0] - jw.mu.Lock() nowSeconds := jw.runtimeSeconds.Load() for _, rq := range jw.requests { @@ -203,48 +200,6 @@ func (jw *Jaws) maintenance(requestTimeout time.Duration) { delete(jw.sessions, k) } } - enabled := jw.StatusMetrics.Load() & StatusMetricAll - sample := &jw.statusSample - newlyEnabled := enabled &^ sample.enabled - if enabled&StatusMetricActiveRequests != 0 { - activeRequests := jw.activeRequestCountLocked() - if newlyEnabled&StatusMetricActiveRequests != 0 || activeRequests != sample.activeRequests { - dirtyTags = append(dirtyTags, jw.ActiveRequestCountTag()) - } - sample.activeRequests = activeRequests - } - if enabled&StatusMetricPendingRequests != 0 { - pendingRequests := jw.pendingRequestCountLocked() - if newlyEnabled&StatusMetricPendingRequests != 0 || pendingRequests != sample.pendingRequests { - dirtyTags = append(dirtyTags, jw.PendingRequestCountTag()) - } - sample.pendingRequests = pendingRequests - } - if enabled&StatusMetricSessions != 0 { - sessions := len(jw.sessions) - if newlyEnabled&StatusMetricSessions != 0 || sessions != sample.sessions { - dirtyTags = append(dirtyTags, jw.SessionCountTag()) - } - sample.sessions = sessions - } - if enabled&StatusMetricActiveSessions != 0 { - activeSessions := jw.activeSessionCountLocked() - if newlyEnabled&StatusMetricActiveSessions != 0 || activeSessions != sample.activeSessions { - dirtyTags = append(dirtyTags, jw.ActiveSessionCountTag()) - } - sample.activeSessions = activeSessions - } - if enabled&StatusMetricErrors != 0 { - errorCount := jw.reportedErrors.Load() - if newlyEnabled&StatusMetricErrors != 0 || errorCount != sample.errors { - dirtyTags = append(dirtyTags, jw.ErrorCountTag()) - } - sample.errors = errorCount - } - sample.enabled = enabled + jw.updateStatusLocked() jw.mu.Unlock() - - if len(dirtyTags) > 0 { - jw.setDirty(dirtyTags) - } } diff --git a/status.go b/status.go index 69cac6f6..3eb45922 100644 --- a/status.go +++ b/status.go @@ -11,14 +11,15 @@ const ( StatusMetricActiveSessions // StatusMetricErrors enables dirtying [Jaws.ErrorCountTag]. StatusMetricErrors - // StatusMetricAll selects every status metric. - StatusMetricAll = StatusMetricActiveRequests | - StatusMetricPendingRequests | - StatusMetricSessions | - StatusMetricActiveSessions | - StatusMetricErrors ) +// StatusMetricAll selects every status metric. +const StatusMetricAll = StatusMetricActiveRequests | + StatusMetricPendingRequests | + StatusMetricSessions | + StatusMetricActiveSessions | + StatusMetricErrors + // statusTag is non-zero-sized so pointers to distinct fields cannot share an // address. Its value is immaterial; pointer identity is the dependency key. type statusTag uint8 @@ -33,10 +34,10 @@ type statusTags struct { type statusSample struct { enabled uint32 - activeRequests int - pendingRequests int - sessions int - activeSessions int + activeRequests uint64 + pendingRequests uint64 + sessions uint64 + activeSessions uint64 errors uint64 } @@ -72,11 +73,11 @@ func (jw *Jaws) SessionCountTag() any { // It counts registered Sessions attached to at least one Request whose // [Request.ServeHTTP] loop is running. A Session shared by several running Requests // counts once. A Session retained only for its disconnect grace period is inactive. -// ActiveSessionCount is safe for concurrent use. +// It is safe for concurrent use. func (jw *Jaws) ActiveSessionCount() (n int) { jw.mu.RLock() + defer jw.mu.RUnlock() n = jw.activeSessionCountLocked() - jw.mu.RUnlock() return } @@ -91,10 +92,12 @@ func (jw *Jaws) ActiveSessionCountTag() any { // ErrorCount returns the number of errors reported to this instance. // -// Every non-nil error passed to [Jaws.Log] or [Jaws.MustLog] counts, including -// calls through [Request.Log] and [Request.MustLog], calls without a Logger, and -// calls after shutdown. Logger delivery, latency, and panics do not affect the -// count. ErrorCount is safe for concurrent use. [StatusMetricErrors] controls tag +// A non-nil error reported through this instance's [Jaws.Log] or [Jaws.MustLog] +// increments the count, including calls through [Request.Log] or [Request.MustLog]. +// Counting continues without a Logger and after [Jaws.Done] closes; Logger +// delivery, latency, and panics do not affect it. +// +// ErrorCount is safe for concurrent use. [StatusMetricErrors] controls tag // updates, not counting. func (jw *Jaws) ErrorCount() uint64 { return jw.reportedErrors.Load() @@ -109,6 +112,10 @@ func (jw *Jaws) ErrorCountTag() any { return &jw.statusTags.errors } +func statusCount(n int) uint64 { + return uint64(n) // #nosec G115 -- collection-derived status counts are non-negative. +} + func (jw *Jaws) activeRequestCountLocked() (n int) { for _, rq := range jw.requests { if rq != nil && rq.loadState() == reqRunning { @@ -129,7 +136,7 @@ func (jw *Jaws) activeSessionCountLocked() (n int) { for _, sess := range jw.sessions { sess.mu.RLock() for _, rq := range sess.requests { - if rq.loadState() == reqRunning { + if rq != nil && rq.loadState() == reqRunning { n++ break } @@ -138,3 +145,49 @@ func (jw *Jaws) activeSessionCountLocked() (n int) { } return } + +func (jw *Jaws) statusMetricLocked(metric uint32) (tag *statusTag, value uint64, previous *uint64) { + switch metric { + case StatusMetricActiveRequests: + tag = &jw.statusTags.activeRequests + value = statusCount(jw.activeRequestCountLocked()) + previous = &jw.statusSample.activeRequests + case StatusMetricPendingRequests: + tag = &jw.statusTags.pendingRequests + value = statusCount(jw.pendingRequestCountLocked()) + previous = &jw.statusSample.pendingRequests + case StatusMetricSessions: + tag = &jw.statusTags.sessions + value = statusCount(len(jw.sessions)) + previous = &jw.statusSample.sessions + case StatusMetricActiveSessions: + tag = &jw.statusTags.activeSessions + value = statusCount(jw.activeSessionCountLocked()) + previous = &jw.statusSample.activeSessions + case StatusMetricErrors: + tag = &jw.statusTags.errors + value = jw.reportedErrors.Load() + previous = &jw.statusSample.errors + } + return +} + +func (jw *Jaws) updateStatusLocked() { + enabled := jw.StatusMetrics.Load() & StatusMetricAll + if enabled == 0 { + jw.statusSample.enabled = 0 + return + } + newlyEnabled := enabled &^ jw.statusSample.enabled + for metric := StatusMetricActiveRequests; metric != 0 && metric <= StatusMetricAll; metric <<= 1 { + if enabled&metric != 0 { + tag, value, previous := jw.statusMetricLocked(metric) + if newlyEnabled&metric != 0 || value != *previous { + jw.dirtOrder++ + jw.dirty[tag] = jw.dirtOrder + } + *previous = value + } + } + jw.statusSample.enabled = enabled +} diff --git a/status_test.go b/status_test.go index b0960c2b..d218bcb4 100644 --- a/status_test.go +++ b/status_test.go @@ -82,6 +82,10 @@ func dialJawsRequest(t *testing.T, serverURL string, rq *Request) *websocket.Con } func TestJaws_StatusMetrics(t *testing.T) { + if got, want := StatusMetricAll, StatusMetricErrors<<1-1; got != want { + t.Fatalf("StatusMetricAll = %#x, want %#x", got, want) + } + jw, err := New() if err != nil { t.Fatal(err) @@ -130,7 +134,8 @@ func TestJaws_StatusMetrics(t *testing.T) { jw.StatusMetrics.Store(StatusMetricAll) jw.maintenance(time.Hour) - requireDirtyTags(t, jw, + requireDirtyTags( + t, jw, jw.ActiveRequestCountTag(), jw.SessionCountTag(), jw.ActiveSessionCountTag(),