Skip to content

Commit 918264e

Browse files
committed
refactor: replace time.Time lastWrite with atomic Int64 for lock-free write timestamping
- Replace `lastWrite time.Time` with `lastWriteNano atomic.Int64` to enable lock-free updates in hot paths. - Update maintenance logic to use monotonic nanoseconds instead of wall-clock time. - Add `MarkWritten()` method for explicit write-timestamp updates. - Remove `Rendering atomic.Bool` and associated logic, as it was redundant with the new timestamp mechanism. - Delete obsolete test `TestRequest_JawsKeyReadsAreLockedDuringRecycle`, which is no longer relevant after lock-free JawsKey access removal. Signed-off-by: Johan Lindh <johan@linkdata.se>
1 parent 2022697 commit 918264e

3 files changed

Lines changed: 138 additions & 156 deletions

File tree

‎recyclerace_test.go‎

Lines changed: 0 additions & 98 deletions
This file was deleted.

‎request.go‎

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,27 +50,26 @@ type ConnectFn = func(rq *Request) error
5050
// [Request.Log] and [Request.MustLog] let it forward to the logger; both exist only for
5151
// that diagnostic use, not as a public nil-safe contract.
5252
type Request struct {
53-
Jaws *Jaws // (read-only) the JaWS instance the Request belongs to
54-
JawsKey key.Key // (read-only) random key identifying this Request in the WebSocket URI and the broadcast/tail target; read under mu by the identity check (destKey, wantMessage) and the render path (JawsKeyString, HeadHTML)
55-
remoteIP netip.Addr // (read-only) remote IP, or the zero netip.Addr if unset
56-
Rendering atomic.Bool // set to true by RequestWriter.Write()
57-
running atomic.Bool // if ServeHTTP() is running
58-
claimed atomic.Bool // if UseRequest() has been called for it
59-
mu deadlock.RWMutex // protects following
60-
lastJid Jid // last element Jid allocated within this Request
61-
lastWrite time.Time // when the initial HTML was last written to, used for automatic cleanup
62-
initial *http.Request // initial HTTP request passed to Jaws.NewRequest
63-
session *Session // session, if established
64-
todoDirt []any // dirty tags
65-
ctx context.Context // current context, derived from either Jaws or WS HTTP req
66-
httpDoneCh <-chan struct{} // once claimed, set to http.Request.Context().Done()
67-
cancelFn context.CancelCauseFunc // cancel function
68-
connectFn ConnectFn // a ConnectFn to call before starting message processing for the Request
69-
elems []*Element // our Elements
70-
tagMap map[any][]*Element // maps tags to Elements
71-
muQueue deadlock.Mutex // protects wsQueue and tailsent
72-
wsQueue []wire.WsMsg // queued messages to send
73-
tailsent bool
53+
Jaws *Jaws // (read-only) the JaWS instance the Request belongs to
54+
JawsKey key.Key // (read-only) random key identifying this Request in the WebSocket URI and the broadcast/tail target; read under mu by the identity check (destKey, wantMessage) and the render path (JawsKeyString, HeadHTML)
55+
remoteIP netip.Addr // (read-only) remote IP, or the zero netip.Addr if unset
56+
running atomic.Bool // if ServeHTTP() is running
57+
claimed atomic.Bool // if UseRequest() has been called for it
58+
lastWriteNano atomic.Int64 // monotonic nanos (Jaws.nowNano) of the most recent RequestWriter write; lock-free, drives pending-eviction recency (oldestEvictablePendingLocked) and idle expiry (maintenance)
59+
mu deadlock.RWMutex // protects following
60+
lastJid Jid // last element Jid allocated within this Request
61+
initial *http.Request // initial HTTP request passed to Jaws.NewRequest
62+
session *Session // session, if established
63+
todoDirt []any // dirty tags
64+
ctx context.Context // current context, derived from either Jaws or WS HTTP req
65+
httpDoneCh <-chan struct{} // once claimed, set to http.Request.Context().Done()
66+
cancelFn context.CancelCauseFunc // cancel function
67+
connectFn ConnectFn // a ConnectFn to call before starting message processing for the Request
68+
elems []*Element // our Elements
69+
tagMap map[any][]*Element // maps tags to Elements
70+
muQueue deadlock.Mutex // protects wsQueue and tailsent
71+
wsQueue []wire.WsMsg // queued messages to send
72+
tailsent bool
7473
}
7574

7675
type eventFnCall struct {
@@ -122,6 +121,16 @@ func (rq *Request) String() string {
122121
return "Request<" + rq.JawsKeyString() + ">"
123122
}
124123

124+
// MarkWritten records that the Request's initial HTML is being written, so the
125+
// pending-eviction logic spares it while a render is in flight.
126+
//
127+
// [RequestWriter.Write] calls it on every write; the recorded instant drives the
128+
// recency window in [Jaws.oldestEvictablePendingLocked] and the idle expiry in
129+
// [Request.maintenance]. It is lock-free and safe to call concurrently.
130+
func (rq *Request) MarkWritten() {
131+
rq.lastWriteNano.Store(rq.Jaws.nowNano())
132+
}
133+
125134
// destKey returns the Request's current identity key, read under rq.mu, for use as
126135
// a broadcast destination. Targeting the key value rather than the *Request pointer
127136
// lets the Serve loop reject a message aimed at a request that was recycled and
@@ -169,10 +178,10 @@ func (rq *Request) claim(r *http.Request) error {
169178
}
170179
}
171180
rq.httpDoneCh = httpDoneCh
172-
// Refresh lastWrite so a request claimed long after its initial render
173-
// (a throttled or backgrounded tab) is not treated as idle and recycled
174-
// in the window before ServeHTTP sets running.
175-
rq.lastWrite = time.Now()
181+
// Refresh the write timestamp so a request claimed long after its initial
182+
// render (a throttled or backgrounded tab) is not treated as idle and
183+
// recycled in the window before ServeHTTP sets running.
184+
rq.lastWriteNano.Store(rq.Jaws.nowNano())
176185
return nil
177186
}
178187
}
@@ -223,12 +232,11 @@ func (rq *Request) clearLocked() *Request {
223232
rq.JawsKey = 0
224233
rq.lastJid = 0
225234
rq.connectFn = nil
226-
rq.lastWrite = time.Time{}
235+
rq.lastWriteNano.Store(0)
227236
rq.initial = nil
228237
rq.killSessionLocked()
229238
rq.running.Store(false)
230239
rq.claimed.Store(false)
231-
rq.Rendering.Store(false)
232240
if rq.cancelFn != nil {
233241
rq.cancelFn(nil)
234242
}
@@ -468,21 +476,19 @@ func (rq *Request) SetContext(fn func(oldCtx context.Context) (newCtx context.Co
468476

469477
// maintenance reports whether rq has expired and should be recycled. For a
470478
// request that never went live it cancels and reports expiry once it has been
471-
// idle longer than requestTimeout, or immediately if its context is already
472-
// done. Called from the Serve loop's maintenance pass while jw.mu is held.
479+
// idle (no [RequestWriter] write) longer than requestTimeout, or immediately if its
480+
// context is already done. nowNano is the reference instant (see [Jaws.nowNano]).
481+
// Called from the Serve loop's maintenance pass while jw.mu is held.
473482
//
474483
// It returns the cancellation cause (or nil) rather than logging it, so the caller
475484
// can log it after releasing jw.mu — logging runs the user [Jaws.Logger], which
476485
// must not be invoked under a lock.
477-
func (rq *Request) maintenance(now time.Time, requestTimeout time.Duration) (expired bool, cause error) {
486+
func (rq *Request) maintenance(nowNano int64, requestTimeout time.Duration) (expired bool, cause error) {
478487
if !rq.running.Load() {
479488
rq.mu.Lock()
480-
if rq.Rendering.Swap(false) {
481-
rq.lastWrite = now
482-
}
483489
if rq.ctx.Err() != nil {
484490
expired = true
485-
} else if now.Sub(rq.lastWrite) > requestTimeout {
491+
} else if nowNano-rq.lastWriteNano.Load() > int64(requestTimeout) {
486492
cause = rq.cancelLocked(newErrNoWebSocketRequest(rq))
487493
expired = true
488494
}

‎request_test.go‎

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"html/template"
11+
"io"
1112
"log/slog"
1213
"net/http"
1314
"net/http/httptest"
@@ -676,15 +677,13 @@ func TestRequest_ClaimRefreshesLastWriteAndStartServeGuards(t *testing.T) {
676677
rq := jw.NewRequest(hr)
677678

678679
// Simulate a page that rendered long ago (idle) before its WebSocket connects.
679-
rq.mu.Lock()
680-
rq.lastWrite = time.Now().Add(-time.Hour)
681-
rq.mu.Unlock()
680+
rq.lastWriteNano.Store(jw.nowNano() - int64(time.Hour))
682681

683682
if jw.UseRequest(rq.JawsKey, hr) != rq {
684683
t.Fatal("expected claim to succeed")
685684
}
686-
// claim refreshed lastWrite, so the just-claimed request is not idle-eligible.
687-
if expired, _ := rq.maintenance(time.Now(), time.Second); expired {
685+
// claim refreshed the write timestamp, so the just-claimed request is not idle.
686+
if expired, _ := rq.maintenance(jw.nowNano(), time.Second); expired {
688687
t.Fatal("a freshly claimed request must not be treated as idle by maintenance")
689688
}
690689

@@ -1733,9 +1732,7 @@ func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) {
17331732
<-unsubDone
17341733

17351734
// Request timeout path.
1736-
rq.mu.Lock()
1737-
rq.lastWrite = time.Now().Add(-time.Hour)
1738-
rq.mu.Unlock()
1735+
rq.lastWriteNano.Store(jw.nowNano() - int64(time.Hour))
17391736
jw.maintenance(time.Second)
17401737
if got := jw.RequestCount(); got != 0 {
17411738
t.Fatalf("expected request recycled, got %d", got)
@@ -1786,32 +1783,25 @@ func TestCoverage_RequestMaintenanceClaimAndErrors(t *testing.T) {
17861783
t.Fatalf("unexpected error text: %v", err)
17871784
}
17881785

1789-
now := time.Now()
1786+
nowNano := jw.nowNano()
17901787
rqM := jw.NewRequest(httptest.NewRequest("GET", "/", nil))
1791-
rqM.lastWrite = now.Add(-time.Hour)
1792-
if expired, _ := rqM.maintenance(now, time.Second); !expired {
1788+
rqM.lastWriteNano.Store(nowNano - int64(time.Hour))
1789+
if expired, _ := rqM.maintenance(nowNano, time.Second); !expired {
17931790
t.Fatal("expected maintenance timeout")
17941791
}
17951792
rqR := jw.NewRequest(httptest.NewRequest("GET", "/", nil))
1796-
nowR := time.Now()
1797-
rqR.Rendering.Store(true)
1798-
if expired, _ := rqR.maintenance(nowR, time.Hour); expired {
1799-
t.Fatal("expected maintenance continue")
1800-
}
1801-
rqR.mu.RLock()
1802-
lastWrite := rqR.lastWrite
1803-
rqR.mu.RUnlock()
1804-
if lastWrite != nowR {
1805-
t.Fatalf("expected lastWrite updated to now, got %v want %v", lastWrite, nowR)
1793+
rqR.MarkWritten()
1794+
if expired, _ := rqR.maintenance(jw.nowNano(), time.Hour); expired {
1795+
t.Fatal("a freshly written request must not be idle-expired")
18061796
}
18071797
rqC := jw.NewRequest(httptest.NewRequest("GET", "/", nil))
18081798
rqC.cancel(errors.New("cancelled"))
1809-
if expired, _ := rqC.maintenance(time.Now(), time.Hour); !expired {
1799+
if expired, _ := rqC.maintenance(jw.nowNano(), time.Hour); !expired {
18101800
t.Fatal("expected maintenance cancellation")
18111801
}
18121802
rqOK := jw.NewRequest(httptest.NewRequest("GET", "/", nil))
1813-
rqOK.lastWrite = time.Now()
1814-
if expired, _ := rqOK.maintenance(time.Now(), time.Hour); expired {
1803+
rqOK.MarkWritten()
1804+
if expired, _ := rqOK.maintenance(jw.nowNano(), time.Hour); expired {
18151805
t.Fatal("expected maintenance keepalive")
18161806
}
18171807

@@ -2862,3 +2852,87 @@ func TestDelRequestNilsVacatedSlot(t *testing.T) {
28622852
}
28632853
})
28642854
}
2855+
2856+
// TestRequest_JawsKeyReadsAreLockedDuringRecycle verifies that the request-key
2857+
// readers used while the application renders the initial HTML page
2858+
// (JawsKeyString, String, HeadHTML and TailHTML) read rq.JawsKey under rq.mu, so
2859+
// they do not race the rq.mu-guarded writes to rq.JawsKey that clearLocked and
2860+
// getRequestLocked perform when a still-pending request is recycled (for example
2861+
// by limitPendingRequestsLocked evicting it). Run with -race.
2862+
func TestRequest_JawsKeyReadsAreLockedDuringRecycle(t *testing.T) {
2863+
jw, err := New()
2864+
if err != nil {
2865+
t.Fatal(err)
2866+
}
2867+
t.Cleanup(jw.Close)
2868+
2869+
rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil))
2870+
2871+
const iterations = 2000
2872+
var wg sync.WaitGroup
2873+
wg.Add(2)
2874+
2875+
// Writer: mimic clearLocked / getRequestLocked, which assign rq.JawsKey while
2876+
// holding rq.mu.
2877+
go func() {
2878+
defer wg.Done()
2879+
for i := range iterations {
2880+
rq.mu.Lock()
2881+
rq.JawsKey = key.Key(uint64(i) + 1)
2882+
rq.mu.Unlock()
2883+
}
2884+
}()
2885+
2886+
// Reader: the lock-free render-path readers that previously read rq.JawsKey
2887+
// without holding rq.mu.
2888+
go func() {
2889+
defer wg.Done()
2890+
for range iterations {
2891+
_ = rq.JawsKeyString()
2892+
_ = rq.String()
2893+
_ = rq.HeadHTML(io.Discard)
2894+
_ = rq.TailHTML(io.Discard)
2895+
}
2896+
}()
2897+
2898+
wg.Wait()
2899+
}
2900+
2901+
// TestServe_MarksRequestRunningSoMaintenanceSkips verifies that TestServe marks
2902+
// the request running before driving rq.process, mirroring ServeHTTP/startServe.
2903+
// The maintenance pass recycles only not-running requests (clearing their
2904+
// elements via clearLocked), so a request whose process loop is live must report
2905+
// running and must survive a maintenance pass that would otherwise expire it.
2906+
func TestServe_MarksRequestRunningSoMaintenanceSkips(t *testing.T) {
2907+
jw, err := New()
2908+
if err != nil {
2909+
t.Fatal(err)
2910+
}
2911+
t.Cleanup(jw.Close)
2912+
2913+
go jw.Serve()
2914+
2915+
tr := NewTestRequest(jw, nil)
2916+
if tr == nil {
2917+
t.Fatal("expected test request")
2918+
}
2919+
defer func() {
2920+
tr.Close()
2921+
<-tr.DoneCh
2922+
}()
2923+
2924+
<-tr.ReadyCh
2925+
if !tr.running.Load() {
2926+
t.Fatal("TestServe must mark the request running so maintenance cannot recycle it mid-process")
2927+
}
2928+
2929+
// Make the request look long-idle, then run a maintenance pass directly. A
2930+
// running request must not be recycled; before the fix it would be removed
2931+
// from jw.requests and its elements cleared while process is still using them.
2932+
tr.lastWriteNano.Store(jw.nowNano() - int64(time.Hour))
2933+
jw.maintenance(time.Millisecond)
2934+
2935+
if got := jw.RequestCount(); got != 1 {
2936+
t.Fatalf("running request was recycled by maintenance: RequestCount() = %d, want 1", got)
2937+
}
2938+
}

0 commit comments

Comments
 (0)