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
30 changes: 24 additions & 6 deletions AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,18 +284,36 @@ can access the Session. `Request.Get` returns nil and `Request.Set` is a no-op
when no Session exists. `Jaws.Close` invalidates every Session, clears its data,
and prevents new Session creation.

Maintenance checks Session expiry every tenth pass; expired Sessions count
until cleanup. `MaxSessions` caps registered Sessions globally, while
`MaxSessionsPerIP` caps them per client address bucket. Both default to zero
(disabled). Set the per-IP cap below the global cap to reserve capacity for
other buckets. `SessionMiddleware` skips its handler and returns HTTP 429 at
the per-IP cap while global capacity remains, or HTTP 503 at the global cap
or on other creation failure.
Existing Sessions remain usable. `NewSession` returns nil when creation fails;
`AutoSession` may leave a Request without one, so use the middleware when a
Session is required. Rotation needs a free slot under each enabled cap and
preserves the old Session on refusal.

One client making cookie-less requests can fill `MaxSessions`; a per-IP cap
limits one bucket, but shared addresses share that limit and clients using
multiple addresses can still exhaust the global cap. Rate-limit
Session-creating routes at the proxy.

Loopback addresses are treated as the same client so a loopback reverse proxy
does not break binding. If all traffic reaches JaWS from loopback, binding is
effectively disabled unless trusted forwarding is configured behind a single
controlled proxy. `CookieName` must be a valid non-empty HTTP cookie name; its
default derives from the executable and falls back to `jaws`.
effectively disabled and visitors using the same proxy address share the per-IP
cap unless trusted forwarding is configured behind a single controlled proxy.
`CookieName` must be a valid non-empty HTTP cookie name; its default derives
from the executable and falls back to `jaws`.

## Configuration and logging

Set all exported `Jaws` configuration fields immediately after `New` and before
exposing handlers, creating Requests, or starting a serve loop. They are ordinary
fields, not synchronized live settings. If `Debug` or the resource list changes,
call `GenerateHeadHTML` before rendering more pages.
exposing handlers, creating Sessions or Requests, or starting a serve loop.
They are ordinary fields, not synchronized live settings. If `Debug` or the
resource list changes, call `GenerateHeadHTML` before rendering more pages.

`GenerateHeadHTML` emits common JavaScript and CSS URLs, recognizes image and
font resources, and passes parsed URLs to automatic Content-Security-Policy
Expand Down
29 changes: 23 additions & 6 deletions jaws.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,19 @@ type Jid = jid.Jid // convenience alias
// 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.
// them all before exposing handlers, creating Sessions or 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.
//
// It defaults to [assets.DefaultCookieName], which is derived from the
// executable and falls back to "jaws". CookieName must be a valid, non-empty
// HTTP cookie name; see [http.Cookie.Valid].
CookieName string
AutoSession bool // Create and associate a session during a successful WebSocket upgrade when a Request has none. Defaults to false.
AutoSession bool // Create a session during a successful WebSocket upgrade when a Request has none and the Session limits allow it. Defaults to false.
// TrustForwardedHeaders enables trusted proxy header processing.
//
// It governs the session cookie Secure flag and WebSocket Origin scheme
Expand Down Expand Up @@ -140,6 +140,20 @@ type Jaws struct {
// It defaults to [DefaultWebSocketPingInterval] and must be positive;
// non-positive values do not disable probing.
WebSocketPingInterval time.Duration
// MaxSessions limits the number of registered Sessions.
//
// A non-positive value disables the cap, which is the default. Sessions
// waiting for expiry cleanup count toward it. At the limit, new Session
// creation returns nil without evicting existing Sessions.
MaxSessions int
// MaxSessionsPerIP limits registered Sessions per client address bucket.
//
// Buckets match [Jaws.MaxPendingRequestsPerIP] and use the client IP selected
// by [Jaws.TrustForwardedHeaders]. A non-positive value disables the cap,
// which is the default. Existing Sessions remain usable at the limit;
// [Jaws.SessionMiddleware] returns HTTP 429 for new ones if global capacity
// remains.
MaxSessionsPerIP int
// MaxPendingRequestsPerIP limits unclaimed Requests per client address bucket.
//
// IPv4 and NAT64 addresses in 64:ff9b::/96 use their IPv4 address; other
Expand Down Expand Up @@ -177,6 +191,8 @@ type Jaws struct {
requestCount int // number of non-nil entries in requests
pending map[netip.Addr][]*Request
sessions map[key.Key]*Session
sessionBucketCounts map[netip.Addr]int
sessionSweep uint8 // maintenance passes since the last Session expiry scan
dirty map[any]int
dirtOrder int
}
Expand Down Expand Up @@ -210,6 +226,7 @@ func New() (jw *Jaws, err error) {
requests: make(map[key.Key]*Request),
pending: make(map[netip.Addr][]*Request),
sessions: make(map[key.Key]*Session),
sessionBucketCounts: make(map[netip.Addr]int),
dirty: make(map[any]int),
closeCh: make(chan struct{}),
}
Expand Down
2 changes: 1 addition & 1 deletion jaws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3622,7 +3622,7 @@ func newBenchRequest(b *testing.B, n int) *Request {

func newUnpooledBenchRequest(jw *Jaws) (rq *Request) {
remoteIP := jw.clientIP(nil)
bucketKey := pendingBucketKey(remoteIP)
bucketKey := clientBucketKey(remoteIP)
jw.mu.Lock()
defer jw.mu.Unlock()
jw.limitPendingRequestsLocked(bucketKey)
Expand Down
2 changes: 1 addition & 1 deletion request.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ func (rq *Request) newAutoSession(r *http.Request) (sess *Session) {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.session == nil {
if sess = jw.newSessionLocked(remoteIP, secure); sess != nil {
if sess, _ = jw.newSessionLocked(remoteIP, secure); sess != nil {
sess.addRequest(rq)
rq.session = sess
jw.registerSessionLocked(sess, true)
Expand Down
6 changes: 4 additions & 2 deletions request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3151,11 +3151,13 @@ func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) {
}

// Dead session cleanup path.
sess := jw.newSession(nil, hr)
sess, _ := jw.newSession(nil, hr)
sess.mu.Lock()
sess.deadline = time.Now().Add(-time.Second)
sess.mu.Unlock()
jw.maintenance(time.Second)
for range 10 {
jw.maintenance(time.Second)
}
if got := jw.SessionCount(); got != 0 {
t.Fatalf("expected dead session cleanup, got %d", got)
}
Expand Down
8 changes: 4 additions & 4 deletions requestpool.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ func (jw *Jaws) NewRequest(w http.ResponseWriter, r *http.Request) *Request {

var wellKnownNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96")

// pendingBucketKey uses the embedded IPv4 address for the well-known NAT64 prefix.
// clientBucketKey uses the embedded IPv4 address for the well-known NAT64 prefix.
// Other IPv6 addresses share a /64; IPv4 addresses use their full address.
func pendingBucketKey(addr netip.Addr) netip.Addr {
func clientBucketKey(addr netip.Addr) netip.Addr {
addr = addr.Unmap()
if wellKnownNAT64Prefix.Contains(addr) {
a := addr.As16()
Expand All @@ -83,7 +83,7 @@ func pendingBucketKey(addr netip.Addr) netip.Addr {

func (jw *Jaws) newRequest(r *http.Request) (rq *Request) {
remoteIP := jw.clientIP(r)
bucketKey := pendingBucketKey(remoteIP)
bucketKey := clientBucketKey(remoteIP)

func() {
jw.mu.Lock()
Expand Down Expand Up @@ -208,7 +208,7 @@ func (jw *Jaws) pendingEvictionVictimLocked(bucketKey netip.Addr, nowSeconds int
}

func (jw *Jaws) removePendingRequestLocked(rq *Request) {
bucketKey := pendingBucketKey(rq.remoteIP)
bucketKey := clientBucketKey(rq.remoteIP)
pending := jw.pending[bucketKey]
if i := slices.Index(pending, rq); i >= 0 {
pending = slices.Delete(pending, i, i+1)
Expand Down
11 changes: 8 additions & 3 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,14 @@ func (jw *Jaws) maintenance(requestTimeout time.Duration) {
jw.retireNonRunningRequestLocked(rq)
}
}
for _, sess := range jw.sessions {
if sess.isDead() {
jw.deleteSessionIfCurrentLocked(sess)
// Unattached Sessions cannot expire until their one-minute deadline.
jw.sessionSweep++
if jw.sessionSweep == 10 {
jw.sessionSweep = 0
for _, sess := range jw.sessions {
if sess.isDead() {
jw.deleteSessionIfCurrentLocked(sess)
}
}
}
jw.updateStatusLocked()
Expand Down
48 changes: 48 additions & 0 deletions serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"path"
"slices"
Expand Down Expand Up @@ -423,6 +424,53 @@ func TestJaws_ServeOverloadLoggerCanBroadcastRepeatedly(t *testing.T) {
})
}

// BenchmarkJawsMaintenanceSessions measures maintenance with 10,000 live Sessions.
func BenchmarkJawsMaintenanceSessions(b *testing.B) {
jw, err := New()
if err != nil {
b.Fatal(err)
}
b.Cleanup(jw.Close)
jw.mu.Lock()
for range 10_000 {
sess, _ := jw.newSessionLocked(netip.Addr{}, false)
jw.registerSessionLocked(sess, false)
}
jw.mu.Unlock()

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
jw.maintenance(time.Hour)
}
}

func TestJawsMaintenanceSweepsSessionsEveryTenTicks(t *testing.T) {
jw, err := New()
if err != nil {
t.Fatal(err)
}
t.Cleanup(jw.Close)
sess := jw.NewSession(nil, httptest.NewRequest(http.MethodGet, "/", nil))
if sess == nil {
t.Fatal("NewSession returned nil")
}
sess.mu.Lock()
sess.deadline = time.Now().Add(-time.Second)
sess.mu.Unlock()

for i := range 9 {
jw.maintenance(time.Hour)
if got := jw.SessionCount(); got != 1 {
t.Fatalf("session count after tick %d = %d, want 1", i+1, got)
}
}
jw.maintenance(time.Hour)
if got := jw.SessionCount(); got != 0 {
t.Fatalf("session count after tenth tick = %d, want 0", got)
}
}

func TestJaws_MaintenanceRetiresExpiredRequestOnce(t *testing.T) {
jw, err := New()
if err != nil {
Expand Down
Loading
Loading