detached a7dcf8b

fix(render): eliminate timer worker self-joins

commit
a7dcf8b2f9f850060f9e3f34436aa98557f3d45b
author
brice · · 2026-07-14 04:00 UTC
parent
06d1abf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +459 -472
M internal/usecase/daemon/daemon_test.go +1 -1
@@ -472,7 +472,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
472472 timer := awaitLatestCoordinatorTimer(t, clock)
473473 rc := sess.renderCoordinator()
474474 rc.mu.Lock()
475- workerDone := rc.normalWorkerDone
475+ workerDone := rc.normalLane.token.done
476476 rc.mu.Unlock()
477477 require.NotNil(t, workerDone)
478478 timer.ch <- time.Time{}
@@ -472,7 +472,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
472 timer := awaitLatestCoordinatorTimer(t, clock)472 timer := awaitLatestCoordinatorTimer(t, clock)
473 rc := sess.renderCoordinator()473 rc := sess.renderCoordinator()
474 rc.mu.Lock()474 rc.mu.Lock()
475- workerDone := rc.normalWorkerDone475+ workerDone := rc.normalLane.token.done
476 rc.mu.Unlock()476 rc.mu.Unlock()
477 require.NotNil(t, workerDone)477 require.NotNil(t, workerDone)
478 timer.ch <- time.Time{}478 timer.ch <- time.Time{}
M internal/usecase/daemon/lease_callback_guard_test.go +10 -6
@@ -14,14 +14,14 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
1414 for _, tc := range []struct {
1515 name string
1616 schedule func(*renderCoordinator, *attachedClient, *attachmentLease, func())
17- done func(*renderCoordinator) <-chan struct{}
17+ done func(*testing.T, *renderCoordinator) <-chan struct{}
1818 }{
1919 {
2020 name: "resize",
2121 schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {
2222 rc.scheduleResizeForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease, func(uint64) { run() })
2323 },
24- done: func(rc *renderCoordinator) <-chan struct{} { return rc.resizeCallbackDone() },
24+ done: captureResizeCallbackDone,
2525 },
2626 {
2727 name: "retry",
@@ -31,10 +31,14 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
3131 require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
3232 rc.scheduleResizeRetryForLease(epoch, ac, lease, run)
3333 },
34- done: func(rc *renderCoordinator) <-chan struct{} {
34+ done: func(t *testing.T, rc *renderCoordinator) <-chan struct{} {
35+ t.Helper()
3536 rc.mu.Lock()
3637 defer rc.mu.Unlock()
37- return rc.retryDone
38+ if rc.retryLane.token == nil {
39+ t.Fatal("coordinator did not publish a retry callback completion")
40+ }
41+ return rc.retryLane.token.done
3842 },
3943 },
4044 } {
@@ -47,13 +51,13 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
4751 ran := false
4852 tc.schedule(rc, ac, lease, func() { ran = true })
4953 timer := awaitCoordinatorScheduledTimer(t, clock)
50- done := tc.done(rc)
54+ done := tc.done(t, rc)
5155 require.NotNil(t, done)
5256
5357 rc.notePark(ac)
5458 rc.attach(ac) // resume the exact same object under a new incarnation
5559 timer.ch <- time.Time{}
56- <-done
60+ awaitTestCompletion(t, done, "stale callback did not complete")
5761 for range 32 {
5862 runtime.Gosched()
5963 }
@@ -14,14 +14,14 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
14 for _, tc := range []struct {14 for _, tc := range []struct {
15 name string15 name string
16 schedule func(*renderCoordinator, *attachedClient, *attachmentLease, func())16 schedule func(*renderCoordinator, *attachedClient, *attachmentLease, func())
17- done func(*renderCoordinator) <-chan struct{}17+ done func(*testing.T, *renderCoordinator) <-chan struct{}
18 }{18 }{
19 {19 {
20 name: "resize",20 name: "resize",
21 schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {21 schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {
22 rc.scheduleResizeForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease, func(uint64) { run() })22 rc.scheduleResizeForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease, func(uint64) { run() })
23 },23 },
24- done: func(rc *renderCoordinator) <-chan struct{} { return rc.resizeCallbackDone() },24+ done: captureResizeCallbackDone,
25 },25 },
26 {26 {
27 name: "retry",27 name: "retry",
@@ -31,10 +31,14 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
31 require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))31 require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
32 rc.scheduleResizeRetryForLease(epoch, ac, lease, run)32 rc.scheduleResizeRetryForLease(epoch, ac, lease, run)
33 },33 },
34- done: func(rc *renderCoordinator) <-chan struct{} {34+ done: func(t *testing.T, rc *renderCoordinator) <-chan struct{} {
35+ t.Helper()
35 rc.mu.Lock()36 rc.mu.Lock()
36 defer rc.mu.Unlock()37 defer rc.mu.Unlock()
37- return rc.retryDone38+ if rc.retryLane.token == nil {
39+ t.Fatal("coordinator did not publish a retry callback completion")
40+ }
41+ return rc.retryLane.token.done
38 },42 },
39 },43 },
40 } {44 } {
@@ -47,13 +51,13 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
47 ran := false51 ran := false
48 tc.schedule(rc, ac, lease, func() { ran = true })52 tc.schedule(rc, ac, lease, func() { ran = true })
49 timer := awaitCoordinatorScheduledTimer(t, clock)53 timer := awaitCoordinatorScheduledTimer(t, clock)
50- done := tc.done(rc)54+ done := tc.done(t, rc)
51 require.NotNil(t, done)55 require.NotNil(t, done)
52 56
53 rc.notePark(ac)57 rc.notePark(ac)
54 rc.attach(ac) // resume the exact same object under a new incarnation58 rc.attach(ac) // resume the exact same object under a new incarnation
55 timer.ch <- time.Time{}59 timer.ch <- time.Time{}
56- <-done60+ awaitTestCompletion(t, done, "stale callback did not complete")
57 for range 32 {61 for range 32 {
58 runtime.Gosched()62 runtime.Gosched()
59 }63 }
M internal/usecase/daemon/output_state_sync_test.go +6 -1
@@ -308,8 +308,9 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
308308 d.clock = clock
309309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
310310 timer := <-clock.timers
311+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
311312 timer.ch <- time.Time{}
312- awaitResizeCallback(t, sess.renderCoordinator())
313+ awaitTestCompletion(t, done, "resize callback did not complete")
313314 require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
314315 require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
315316
@@ -342,7 +343,9 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
342343 }
343344 requireNoOutputFrame(t, sends)
344345
346+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
345347 timer.ch <- time.Now()
348+ awaitTestCompletion(t, done, "resize callback did not complete")
346349 frame := awaitFrame(t, sends, ports.MsgOutput)
347350 client := vt.NewScreen(120, 24)
348351 out := mustApplyOutput(t, client, frame)
@@ -375,9 +378,11 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
375378 t.Fatal("latest resize did not replace the bounded paint")
376379 }
377380
381+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
378382 first.ch <- time.Now()
379383 requireNoOutputFrame(t, sends)
380384 latest.ch <- time.Now()
385+ awaitTestCompletion(t, done, "latest resize callback did not complete")
381386 awaitFrame(t, sends, ports.MsgOutput)
382387 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
383388 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
@@ -308,8 +308,9 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
308 d.clock = clock308 d.clock = clock
309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
310 timer := <-clock.timers310 timer := <-clock.timers
311+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
311 timer.ch <- time.Time{}312 timer.ch <- time.Time{}
312- awaitResizeCallback(t, sess.renderCoordinator())313+ awaitTestCompletion(t, done, "resize callback did not complete")
313 require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")314 require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
314 require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")315 require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
315 316
@@ -342,7 +343,9 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
342 }343 }
343 requireNoOutputFrame(t, sends)344 requireNoOutputFrame(t, sends)
344 345
346+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
345 timer.ch <- time.Now()347 timer.ch <- time.Now()
348+ awaitTestCompletion(t, done, "resize callback did not complete")
346 frame := awaitFrame(t, sends, ports.MsgOutput)349 frame := awaitFrame(t, sends, ports.MsgOutput)
347 client := vt.NewScreen(120, 24)350 client := vt.NewScreen(120, 24)
348 out := mustApplyOutput(t, client, frame)351 out := mustApplyOutput(t, client, frame)
@@ -375,9 +378,11 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
375 t.Fatal("latest resize did not replace the bounded paint")378 t.Fatal("latest resize did not replace the bounded paint")
376 }379 }
377 380
381+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
378 first.ch <- time.Now()382 first.ch <- time.Now()
379 requireNoOutputFrame(t, sends)383 requireNoOutputFrame(t, sends)
380 latest.ch <- time.Now()384 latest.ch <- time.Now()
385+ awaitTestCompletion(t, done, "latest resize callback did not complete")
381 awaitFrame(t, sends, ports.MsgOutput)386 awaitFrame(t, sends, ports.MsgOutput)
382 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)387 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
383 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)388 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
M internal/usecase/daemon/picker_test.go +2 -0
@@ -324,6 +324,7 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
324324
325325 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
326326 timer := <-clock.timers
327+ done := captureResizeCallbackDone(t, sess2.renderCoordinator())
327328 before := sess2.renderCoordinator().resizeSnapshot().epoch
328329
329330 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
@@ -331,6 +332,7 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
331332 // no attachment-local timer survives the transfer.
332333 require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
333334 timer.ch <- time.Time{}
335+ awaitTestCompletion(t, done, "superseded resize callback did not complete")
334336 }
335337
336338 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
@@ -324,6 +324,7 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
324 324
325 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})325 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
326 timer := <-clock.timers326 timer := <-clock.timers
327+ done := captureResizeCallbackDone(t, sess2.renderCoordinator())
327 before := sess2.renderCoordinator().resizeSnapshot().epoch328 before := sess2.renderCoordinator().resizeSnapshot().epoch
328 329
329 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))330 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
@@ -331,6 +332,7 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
331 // no attachment-local timer survives the transfer.332 // no attachment-local timer survives the transfer.
332 require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)333 require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
333 timer.ch <- time.Time{}334 timer.ch <- time.Time{}
335+ awaitTestCompletion(t, done, "superseded resize callback did not complete")
334 }336 }
335 337
336 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {338 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
M internal/usecase/daemon/render_coordinator.go +144 -158
@@ -53,32 +53,16 @@ type renderCoordinator struct {
5353 torndown bool
5454
5555 resize resizeRequestMetadata
56- // resizeTimer is independent from render invalidation deadlines. It owns
57- // resize burst coalescing and is cancelled by the same lifecycle changes.
58- resizeTimer ports.Timer
59- resizeCancel chan struct{}
60- // resizeDone closes after the latest timer callback has either been
61- // rejected or completed its transaction. It provides a completion edge
62- // without polling scheduler state.
63- resizeDone chan struct{}
64- resizeGen uint64
65- // retry is a separate, latest-committed-epoch retry lane. It is cancelled
66- // with resize requests and attachment lifecycle changes; callbacks also
67- // validate their epoch before touching a pane.
68- retryTimer ports.Timer
69- retryCancel chan struct{}
70- retryDone chan struct{}
71- retryGen uint64
72- metrics renderCoordinatorBurstMetrics
73- // generation invalidates callbacks from superseded deadline/watchdog timers.
74- // Each worker also owns an explicit cancellation channel: fake timers may
75- // expose a nil or never-firing C channel, so Stop alone cannot release a
76- // worker waiting in select.
77- generation uint64
78- armed bool
79- normalTimer ports.Timer
80- normalCancel chan struct{}
81- normalWorkerDone chan struct{}
56+ // Each lane owns one complete immutable worker token at a time.
57+ resizeLane timerLane
58+ retryLane timerLane
59+ metrics renderCoordinatorBurstMetrics
60+ // normalLane owns render invalidation deadlines. The supervisor is only
61+ // waited by the session owner after terminal teardown has rejected future
62+ // registrations.
63+ normalLane timerLane
64+ armed bool
65+ supervisor timerSupervisor
8266 // syncBatches is keyed by the stable pane owner. Each pane owns an
8367 // independent watchdog; composition remains gated until every batch ends.
8468 syncBatches map[*pane]*syncBatch
@@ -94,25 +78,10 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
9478 return &renderCoordinator{opts: opts}
9579 }
9680
97-// normalTimerOwner owns normal deadline cancellation and completion tickets.
98-// Timer methods remain unlocked at its call sites.
99-func (c *renderCoordinator) normalTimerOwner() timerOwnership {
100- return timerOwner(&c.normalTimer, &c.normalCancel, &c.normalWorkerDone, &c.generation)
101-}
102-
103-func (c *renderCoordinator) resizeTimerOwner() timerOwnership {
104- return timerOwner(&c.resizeTimer, &c.resizeCancel, &c.resizeDone, &c.resizeGen)
105-}
106-
107-func (c *renderCoordinator) retryTimerOwner() timerOwnership {
108- return timerOwner(&c.retryTimer, &c.retryCancel, &c.retryDone, &c.retryGen)
109-}
110-
111-// detachNormalTimerLocked releases coordinator ownership. The caller must stop
112-// and join the returned worker after dropping c.mu: timer methods and worker
113-// callbacks are external coordinator re-entry points.
114-func (c *renderCoordinator) detachNormalTimerLocked() timerWorker {
115- return c.normalTimerOwner().detachLocked()
81+// detachNormalTimerLocked removes the complete current token. The caller may
82+// stop it only after dropping c.mu and must never wait for its worker.
83+func (c *renderCoordinator) detachNormalTimerLocked() *timerToken {
84+ return c.normalLane.detachLocked()
11685 }
11786
11887 type syncBatch struct {
@@ -121,19 +90,13 @@ type syncBatch struct {
12190 // acquired by this predicate.
12291 renderable func() bool
12392 force func()
124- timer ports.Timer
125- cancel chan struct{}
126- done chan struct{}
127-}
128-
129-func (batch *syncBatch) timerOwner() timerOwnership {
130- return timerOwner(&batch.timer, &batch.cancel, &batch.done, &batch.generation)
93+ lane timerLane
13194 }
13295
133-func detachSyncBatchLocked(batch *syncBatch) timerWorker { return batch.timerOwner().detachLocked() }
96+func detachSyncBatchLocked(batch *syncBatch) *timerToken { return batch.lane.detachLocked() }
13497
135-func (c *renderCoordinator) detachSyncTimersLocked() []timerWorker {
136- workers := make([]timerWorker, 0, len(c.syncBatches))
98+func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken {
99+ workers := make([]*timerToken, 0, len(c.syncBatches))
137100 for pane, batch := range c.syncBatches {
138101 workers = append(workers, detachSyncBatchLocked(batch))
139102 delete(c.syncBatches, pane)
@@ -209,11 +172,11 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
209172 // pending work. Completion republishes urgently and must reserve a fresh
210173 // deadline rather than leaving that already-fired timer as the only arm.
211174 arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
212- var old timerWorker
175+ var old *timerToken
213176 if arm {
214- _, old = c.normalTimerOwner().replaceLocked()
177+ _, old = c.normalLane.replaceLocked()
215178 }
216- gen := c.generation
179+ gen := c.normalLane.generation
217180 delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
218181 if c.pendingUrgent {
219182 delay = urgentRenderDeadline
@@ -225,7 +188,7 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
225188 if queueStart && observer != nil {
226189 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
227190 }
228- stopAndJoinTimerWorker(old, nil)
191+ stopDetachedTimer(old)
229192 if !arm || clock == nil {
230193 if onInvalidate != nil {
231194 onInvalidate(inv)
@@ -238,31 +201,34 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
238201 timer := clock.NewTimer(delay)
239202 timerC := timer.C()
240203 c.mu.Lock()
241- valid := !c.torndown && c.pending && c.generation == gen
242- var cancel, done chan struct{}
204+ valid := !c.torndown && c.pending && c.normalLane.generation == gen
205+ var token *timerToken
243206 if valid && timerC != nil {
244- cancel, done, valid = c.normalTimerOwner().publishLocked(gen, timer)
207+ token = c.normalLane.publishLocked(gen, timer)
208+ valid = token != nil
209+ if valid {
210+ c.supervisor.startLocked(token, timerC, func() {
211+ c.markDeadlineDue(token)
212+ c.fireFromTimer(token, false, true)
213+ })
214+ }
245215 }
246216 c.mu.Unlock()
247217 if !valid {
248- stopTimer(timer)
218+ stopDetachedTimer(&timerToken{timer: timer})
249219 if onInvalidate != nil {
250220 onInvalidate(inv)
251221 }
252222 return true
253223 }
254224 if timerC == nil {
255- stopTimer(timer)
225+ stopDetachedTimer(&timerToken{timer: timer})
256226 c.fire(gen, false, true)
257227 if onInvalidate != nil {
258228 onInvalidate(inv)
259229 }
260230 return true
261231 }
262- runTimerWorker(timerC, cancel, done, func() {
263- c.markDeadlineDue(gen)
264- c.fireFromTimer(gen, false, true, done)
265- })
266232 // Test-visible observation follows deadline publication, so a producer
267233 // callback cannot observe a successful invalidation before its timer owns
268234 // a worker (and race tests cannot finish while a mock expectation remains).
@@ -282,7 +248,7 @@ func (c *renderCoordinator) notifyAck() {
282248 c.mu.Unlock()
283249 return
284250 }
285- gen := c.generation
251+ gen := c.normalLane.generation
286252 blocked := c.ackBlocked
287253 c.ackBlocked = nil
288254 c.mu.Unlock()
@@ -296,9 +262,9 @@ func (c *renderCoordinator) notifyAck() {
296262 // arms that pane's watchdog. Overlapping pane batches are independent.
297263 // markDeadlineDue records a received timer tick before the worker probes ACK
298264 // readiness, so a concurrent ACK cannot miss an already-expired deadline.
299-func (c *renderCoordinator) markDeadlineDue(gen uint64) {
265+func (c *renderCoordinator) markDeadlineDue(token *timerToken) {
300266 c.mu.Lock()
301- if c.fireValidLocked(gen, false) {
267+ if c.fireValidLocked(token, token.generation, false) {
302268 c.deadlineDue = true
303269 }
304270 c.mu.Unlock()
@@ -323,7 +289,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
323289 if c.syncBatches == nil {
324290 c.syncBatches = make(map[*pane]*syncBatch)
325291 }
326- var old timerWorker
292+ var old *timerToken
327293 if previous := c.syncBatches[p]; previous != nil {
328294 old = detachSyncBatchLocked(previous)
329295 }
@@ -335,7 +301,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
335301 c.syncRegistryVersion++
336302 clock := c.opts.clock
337303 c.mu.Unlock()
338- stopAndJoinTimerWorker(old, nil)
304+ stopDetachedTimer(old)
339305 if clock == nil {
340306 return
341307 }
@@ -345,53 +311,59 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
345311 timer := clock.NewTimer(maxSyncUpdateDuration)
346312 timerC := timer.C()
347313 c.mu.Lock()
348- valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen
349- var cancel, done chan struct{}
350- if valid {
351- cancel, done, valid = batch.timerOwner().publishLocked(gen, timer)
314+ valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen && batch.lane.generation == 0
315+ var token *timerToken
316+ if valid && timerC != nil {
317+ batch.lane.generation = gen
318+ token = batch.lane.publishLocked(gen, timer)
319+ valid = token != nil
320+ if valid {
321+ c.supervisor.startLocked(token, timerC, func() { c.runSyncWatchdog(p, batch, token) })
322+ }
352323 }
353324 c.mu.Unlock()
325+ if !valid || timerC == nil {
326+ stopDetachedTimer(&timerToken{timer: timer})
327+ return
328+ }
329+}
330+
331+func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) {
332+ // Keep the batch registered while force runs. A concurrent render must
333+ // continue to observe the synchronized-output gate until the VT has
334+ // authoritatively closed the batch.
335+ c.mu.Lock()
336+ current := c.syncBatches[p]
337+ valid := !c.torndown && current == batch && current.generation == token.generation && current.lane.token == token
338+ c.mu.Unlock()
354339 if !valid {
355- stopTimer(timer)
356340 return
357341 }
358- runTimerWorker(timerC, cancel, done, func() {
359- // Keep the batch registered while force runs. A concurrent render must
360- // continue to observe the synchronized-output gate until the VT has
361- // authoritatively closed the batch.
362- c.mu.Lock()
363- current := c.syncBatches[p]
364- valid := !c.torndown && current == batch && current.generation == gen
365- c.mu.Unlock()
366- if !valid {
367- return
368- }
369- if batch.force != nil {
370- batch.force()
371- }
342+ if batch.force != nil {
343+ batch.force()
344+ }
372345
373- // force may synchronously end this batch, replace it with a newer
374- // generation, or tear down the coordinator. Remove only the exact
375- // snapshot that expired, then publish one urgent completion wake.
376- c.mu.Lock()
377- current = c.syncBatches[p]
378- valid = !c.torndown && current == batch && current.generation == gen
379- var stopped timerWorker
380- if valid {
381- delete(c.syncBatches, p)
382- c.syncRegistryVersion++
383- stopped = detachSyncBatchLocked(batch)
384- if len(c.syncBatches) == 0 && c.pending {
385- c.pendingUrgent = true
386- }
387- }
388- c.mu.Unlock()
389- if valid {
390- stopAndJoinTimerWorker(stopped, done)
391- // Reevaluate the aggregate gate only after forcing this pane.
392- c.fireCurrent(true)
346+ // force may synchronously end this batch, replace it with a newer
347+ // generation, or tear down the coordinator. Remove only the exact
348+ // snapshot that expired, then publish one urgent completion wake.
349+ c.mu.Lock()
350+ current = c.syncBatches[p]
351+ valid = !c.torndown && current == batch && current.generation == token.generation && current.lane.token == token
352+ var stopped *timerToken
353+ if valid {
354+ delete(c.syncBatches, p)
355+ c.syncRegistryVersion++
356+ stopped = detachSyncBatchLocked(batch)
357+ if len(c.syncBatches) == 0 && c.pending {
358+ c.pendingUrgent = true
393359 }
394- })
360+ }
361+ c.mu.Unlock()
362+ if valid {
363+ stopDetachedTimer(stopped)
364+ // Reevaluate the aggregate gate only after forcing this pane.
365+ c.fireCurrent(true)
366+ }
395367 }
396368
397369 // noteSyncEnd records completion for exactly one pane batch. It only flushes
@@ -430,21 +402,21 @@ func (c *renderCoordinator) removeSyncEnd(p *pane, gen uint64, renderable bool)
430402 c.pendingUrgent = true
431403 }
432404 c.mu.Unlock()
433- stopAndJoinTimerWorker(worker, nil)
405+ stopDetachedTimer(worker)
434406 return renderable
435407 }
436408
437409 // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
438410 func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
439411 c.mu.Lock()
440- var worker timerWorker
412+ var worker *timerToken
441413 if batch := c.syncBatches[p]; batch != nil {
442414 delete(c.syncBatches, p)
443415 c.syncRegistryVersion++
444416 worker = detachSyncBatchLocked(batch)
445417 }
446418 c.mu.Unlock()
447- stopAndJoinTimerWorker(worker, nil)
419+ stopDetachedTimer(worker)
448420 }
449421
450422 // subscribePreview installs fn as the preview observer for coalesced wakes.
@@ -564,7 +536,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
564536 }
565537
566538 type renderLifecycleCleanup struct {
567- workers []timerWorker
539+ tokens []*timerToken
568540 observer ports.RuntimeObserver
569541 queueCorrelation ports.RuntimeCorrelation
570542 queueMarked bool
@@ -572,7 +544,9 @@ type renderLifecycleCleanup struct {
572544 }
573545
574546 func (cleanup renderLifecycleCleanup) finish() {
575- stopAndJoinTimerWorkers(cleanup.workers)
547+ for _, token := range cleanup.tokens {
548+ stopDetachedTimer(token)
549+ }
576550 if cleanup.queueMarked && cleanup.observer != nil {
577551 cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
578552 }
@@ -581,9 +555,7 @@ func (cleanup renderLifecycleCleanup) finish() {
581555 }
582556 }
583557
584-// beginDetach invalidates pending work while leaving timer joins to the caller.
585-// Callers holding daemon/session locks must publish the lifecycle transition,
586-// release those locks, and only then call cleanup.finish().
558+// beginDetach invalidates pending work. It never waits for detached workers.
587559 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
588560 c.mu.Lock()
589561 var cleanup renderLifecycleCleanup
@@ -595,12 +567,12 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean
595567 c.ackDeferred = false
596568 c.pendingPreview = false
597569 c.coalesced = 0
598- _, timer := c.normalTimerOwner().replaceLocked()
570+ _, timer := c.normalLane.replaceLocked()
599571 c.armed = false
600- _, resizeTimer := c.resizeTimerOwner().replaceLocked()
601- _, retryTimer := c.retryTimerOwner().replaceLocked()
572+ _, resizeTimer := c.resizeLane.replaceLocked()
573+ _, retryTimer := c.retryLane.replaceLocked()
602574 cleanup = renderLifecycleCleanup{
603- workers: []timerWorker{timer, resizeTimer, retryTimer},
575+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
604576 observer: c.opts.observer,
605577 queueCorrelation: c.queueCorrelation,
606578 queueMarked: c.queueMarked,
@@ -617,8 +589,8 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
617589 c.beginDetach(ac).finish()
618590 }
619591
620-// beginReplace hands the coordinator from old to replacement while leaving
621-// detached timer joins to the caller.
592+// beginReplace hands the coordinator from old to replacement without waiting
593+// for detached workers.
622594 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
623595 c.mu.Lock()
624596 var cleanup renderLifecycleCleanup
@@ -634,12 +606,12 @@ func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready
634606 c.ackDeferred = false
635607 c.pendingPreview = false
636608 c.coalesced = 0
637- _, timer := c.normalTimerOwner().replaceLocked()
609+ _, timer := c.normalLane.replaceLocked()
638610 c.armed = false
639- _, resizeTimer := c.resizeTimerOwner().replaceLocked()
640- _, retryTimer := c.retryTimerOwner().replaceLocked()
611+ _, resizeTimer := c.resizeLane.replaceLocked()
612+ _, retryTimer := c.retryLane.replaceLocked()
641613 cleanup = renderLifecycleCleanup{
642- workers: []timerWorker{timer, resizeTimer, retryTimer},
614+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
643615 observer: c.opts.observer,
644616 queueCorrelation: c.queueCorrelation,
645617 queueMarked: c.queueMarked,
@@ -663,9 +635,14 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
663635 // notePark invalidates pending wakes when the attachment parks for resume.
664636 func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
665637
666-// noteSessionTeardown terminally invalidates the coordinator.
667-func (c *renderCoordinator) noteSessionTeardown() {
638+// beginSessionTeardown is terminal phase one. It rejects future worker
639+// registration and atomically detaches every complete lane token.
640+func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
668641 c.mu.Lock()
642+ if c.torndown {
643+ c.mu.Unlock()
644+ return renderLifecycleCleanup{}
645+ }
669646 c.torndown = true
670647 if c.lease != nil {
671648 c.lease.active = false
@@ -673,29 +650,30 @@ func (c *renderCoordinator) noteSessionTeardown() {
673650 c.previewWake = nil
674651 c.previewWakes = nil
675652 c.pending = false
676- _, resizeTimer := c.resizeTimerOwner().replaceLocked()
677- _, retryTimer := c.retryTimerOwner().replaceLocked()
653+ _, resizeTimer := c.resizeLane.replaceLocked()
654+ _, retryTimer := c.retryLane.replaceLocked()
678655 c.ackDeferred = false
679656 c.pendingPreview = false
680- _, timer := c.normalTimerOwner().replaceLocked()
657+ _, timer := c.normalLane.replaceLocked()
681658 c.armed = false
682659 workers := c.detachSyncTimersLocked()
683660 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
684661 ackBlocked := c.ackBlocked
685662 c.queueMarked, c.ackBlocked = false, nil
686663 c.mu.Unlock()
687- if queueMarked && observer != nil {
688- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
664+ return renderLifecycleCleanup{
665+ tokens: append([]*timerToken{timer, resizeTimer, retryTimer}, workers...),
666+ observer: observer,
667+ queueCorrelation: queueCorrelation,
668+ queueMarked: queueMarked,
669+ ackBlocked: ackBlocked,
689670 }
690- if ackBlocked != nil {
691- ackBlocked.finish(false)
692- }
693- stopAndJoinTimerWorker(timer, nil)
694- stopAndJoinTimerWorker(resizeTimer, nil)
695- stopAndJoinTimerWorker(retryTimer, nil)
696- stopAndJoinTimerWorkers(workers)
697671 }
698672
673+// waitForTimerWorkers is terminal phase two. Only the session owner invokes it,
674+// after beginSessionTeardown has made future supervisor registration impossible.
675+func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
676+
699677 // invalidateRender is the sole producer fan-in. In tests and transitional
700678 // headless paths without an attached coordinator it retains the old private
701679 // compositor; attached sessions always schedule through their coordinator.
@@ -728,21 +706,26 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
728706 }
729707
730708 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
731- c.fireWithTimerWorker(gen, watchdog, deadline, nil)
709+ c.mu.Lock()
710+ if c.normalLane.generation != gen {
711+ c.mu.Unlock()
712+ return
713+ }
714+ token := c.normalLane.token
715+ c.mu.Unlock()
716+ c.fireWithTimerToken(token, gen, watchdog, deadline)
732717 }
733718
734-// fireFromTimer consumes a normal deadline from its own worker. It must not
735-// wait for that worker's done channel because runTimerWorker closes it only
736-// after this callback returns.
737-func (c *renderCoordinator) fireFromTimer(gen uint64, watchdog, deadline bool, self <-chan struct{}) {
738- c.fireWithTimerWorker(gen, watchdog, deadline, self)
719+// fireFromTimer consumes a normal deadline using its exact immutable token.
720+func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
721+ c.fireWithTimerToken(token, token.generation, watchdog, deadline)
739722 }
740723
741-func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline bool, self <-chan struct{}) {
724+func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
742725 // Readiness probes enter the attachment send path, which may in turn read
743726 // coordinator metadata. Never hold c.mu across that external callback.
744727 c.mu.Lock()
745- if !c.fireValidLocked(gen, watchdog) {
728+ if !c.fireValidLocked(token, gen, watchdog) {
746729 c.mu.Unlock()
747730 return
748731 }
@@ -759,7 +742,7 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
759742 // syncGateOpenLocked retries predicate evaluation on every registry change
760743 // and returns holding c.mu, so pending consumption has no intervening gap.
761744 c.mu.Lock()
762- if !c.fireValidLocked(gen, watchdog) {
745+ if !c.fireValidLocked(token, gen, watchdog) {
763746 c.mu.Unlock()
764747 return
765748 }
@@ -767,7 +750,7 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
767750 c.mu.Unlock()
768751 return
769752 }
770- if !c.fireValidLocked(gen, watchdog) {
753+ if !c.fireValidLocked(token, gen, watchdog) {
771754 c.mu.Unlock()
772755 return
773756 }
@@ -821,7 +804,7 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
821804 c.queueMarked, c.ackBlocked = false, nil
822805 wake := c.opts.wake
823806 c.mu.Unlock()
824- stopAndJoinTimerWorker(worker, self)
807+ stopDetachedTimer(worker)
825808 if queueMarked && observer != nil {
826809 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
827810 }
@@ -834,8 +817,11 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
834817 c.notifyPreviews(w, preview, previews)
835818 }
836819
837-func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {
838- return !c.torndown && c.pending && (watchdog || gen == c.generation)
820+func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
821+ if c.torndown || !c.pending || watchdog {
822+ return !c.torndown && c.pending
823+ }
824+ return c.normalLane.generation == gen && (token == nil || c.normalLane.token == token)
839825 }
840826
841827 // syncGateOpen evaluates pane visibility without c.mu, then reacquires and
@@ -53,32 +53,16 @@ type renderCoordinator struct {
53 torndown bool53 torndown bool
54 54
55 resize resizeRequestMetadata55 resize resizeRequestMetadata
56- // resizeTimer is independent from render invalidation deadlines. It owns56+ // Each lane owns one complete immutable worker token at a time.
57- // resize burst coalescing and is cancelled by the same lifecycle changes.57+ resizeLane timerLane
58- resizeTimer ports.Timer58+ retryLane timerLane
59- resizeCancel chan struct{}59+ metrics renderCoordinatorBurstMetrics
60- // resizeDone closes after the latest timer callback has either been60+ // normalLane owns render invalidation deadlines. The supervisor is only
61- // rejected or completed its transaction. It provides a completion edge61+ // waited by the session owner after terminal teardown has rejected future
62- // without polling scheduler state.62+ // registrations.
63- resizeDone chan struct{}63+ normalLane timerLane
64- resizeGen uint6464+ armed bool
65- // retry is a separate, latest-committed-epoch retry lane. It is cancelled65+ supervisor timerSupervisor
66- // with resize requests and attachment lifecycle changes; callbacks also
67- // validate their epoch before touching a pane.
68- retryTimer ports.Timer
69- retryCancel chan struct{}
70- retryDone chan struct{}
71- retryGen uint64
72- metrics renderCoordinatorBurstMetrics
73- // generation invalidates callbacks from superseded deadline/watchdog timers.
74- // Each worker also owns an explicit cancellation channel: fake timers may
75- // expose a nil or never-firing C channel, so Stop alone cannot release a
76- // worker waiting in select.
77- generation uint64
78- armed bool
79- normalTimer ports.Timer
80- normalCancel chan struct{}
81- normalWorkerDone chan struct{}
82 // syncBatches is keyed by the stable pane owner. Each pane owns an66 // syncBatches is keyed by the stable pane owner. Each pane owns an
83 // independent watchdog; composition remains gated until every batch ends.67 // independent watchdog; composition remains gated until every batch ends.
84 syncBatches map[*pane]*syncBatch68 syncBatches map[*pane]*syncBatch
@@ -94,25 +78,10 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
94 return &renderCoordinator{opts: opts}78 return &renderCoordinator{opts: opts}
95 }79 }
96 80
97-// normalTimerOwner owns normal deadline cancellation and completion tickets.81+// detachNormalTimerLocked removes the complete current token. The caller may
98-// Timer methods remain unlocked at its call sites.82+// stop it only after dropping c.mu and must never wait for its worker.
99-func (c *renderCoordinator) normalTimerOwner() timerOwnership {83+func (c *renderCoordinator) detachNormalTimerLocked() *timerToken {
100- return timerOwner(&c.normalTimer, &c.normalCancel, &c.normalWorkerDone, &c.generation)84+ return c.normalLane.detachLocked()
101-}
102-
103-func (c *renderCoordinator) resizeTimerOwner() timerOwnership {
104- return timerOwner(&c.resizeTimer, &c.resizeCancel, &c.resizeDone, &c.resizeGen)
105-}
106-
107-func (c *renderCoordinator) retryTimerOwner() timerOwnership {
108- return timerOwner(&c.retryTimer, &c.retryCancel, &c.retryDone, &c.retryGen)
109-}
110-
111-// detachNormalTimerLocked releases coordinator ownership. The caller must stop
112-// and join the returned worker after dropping c.mu: timer methods and worker
113-// callbacks are external coordinator re-entry points.
114-func (c *renderCoordinator) detachNormalTimerLocked() timerWorker {
115- return c.normalTimerOwner().detachLocked()
116 }85 }
117 86
118 type syncBatch struct {87 type syncBatch struct {
@@ -121,19 +90,13 @@ type syncBatch struct {
121 // acquired by this predicate.90 // acquired by this predicate.
122 renderable func() bool91 renderable func() bool
123 force func()92 force func()
124- timer ports.Timer93+ lane timerLane
125- cancel chan struct{}
126- done chan struct{}
127-}
128-
129-func (batch *syncBatch) timerOwner() timerOwnership {
130- return timerOwner(&batch.timer, &batch.cancel, &batch.done, &batch.generation)
131 }94 }
132 95
133-func detachSyncBatchLocked(batch *syncBatch) timerWorker { return batch.timerOwner().detachLocked() }96+func detachSyncBatchLocked(batch *syncBatch) *timerToken { return batch.lane.detachLocked() }
134 97
135-func (c *renderCoordinator) detachSyncTimersLocked() []timerWorker {98+func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken {
136- workers := make([]timerWorker, 0, len(c.syncBatches))99+ workers := make([]*timerToken, 0, len(c.syncBatches))
137 for pane, batch := range c.syncBatches {100 for pane, batch := range c.syncBatches {
138 workers = append(workers, detachSyncBatchLocked(batch))101 workers = append(workers, detachSyncBatchLocked(batch))
139 delete(c.syncBatches, pane)102 delete(c.syncBatches, pane)
@@ -209,11 +172,11 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
209 // pending work. Completion republishes urgently and must reserve a fresh172 // pending work. Completion republishes urgently and must reserve a fresh
210 // deadline rather than leaving that already-fired timer as the only arm.173 // deadline rather than leaving that already-fired timer as the only arm.
211 arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue174 arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
212- var old timerWorker175+ var old *timerToken
213 if arm {176 if arm {
214- _, old = c.normalTimerOwner().replaceLocked()177+ _, old = c.normalLane.replaceLocked()
215 }178 }
216- gen := c.generation179+ gen := c.normalLane.generation
217 delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond180 delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
218 if c.pendingUrgent {181 if c.pendingUrgent {
219 delay = urgentRenderDeadline182 delay = urgentRenderDeadline
@@ -225,7 +188,7 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
225 if queueStart && observer != nil {188 if queueStart && observer != nil {
226 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))189 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
227 }190 }
228- stopAndJoinTimerWorker(old, nil)191+ stopDetachedTimer(old)
229 if !arm || clock == nil {192 if !arm || clock == nil {
230 if onInvalidate != nil {193 if onInvalidate != nil {
231 onInvalidate(inv)194 onInvalidate(inv)
@@ -238,31 +201,34 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
238 timer := clock.NewTimer(delay)201 timer := clock.NewTimer(delay)
239 timerC := timer.C()202 timerC := timer.C()
240 c.mu.Lock()203 c.mu.Lock()
241- valid := !c.torndown && c.pending && c.generation == gen204+ valid := !c.torndown && c.pending && c.normalLane.generation == gen
242- var cancel, done chan struct{}205+ var token *timerToken
243 if valid && timerC != nil {206 if valid && timerC != nil {
244- cancel, done, valid = c.normalTimerOwner().publishLocked(gen, timer)207+ token = c.normalLane.publishLocked(gen, timer)
208+ valid = token != nil
209+ if valid {
210+ c.supervisor.startLocked(token, timerC, func() {
211+ c.markDeadlineDue(token)
212+ c.fireFromTimer(token, false, true)
213+ })
214+ }
245 }215 }
246 c.mu.Unlock()216 c.mu.Unlock()
247 if !valid {217 if !valid {
248- stopTimer(timer)218+ stopDetachedTimer(&timerToken{timer: timer})
249 if onInvalidate != nil {219 if onInvalidate != nil {
250 onInvalidate(inv)220 onInvalidate(inv)
251 }221 }
252 return true222 return true
253 }223 }
254 if timerC == nil {224 if timerC == nil {
255- stopTimer(timer)225+ stopDetachedTimer(&timerToken{timer: timer})
256 c.fire(gen, false, true)226 c.fire(gen, false, true)
257 if onInvalidate != nil {227 if onInvalidate != nil {
258 onInvalidate(inv)228 onInvalidate(inv)
259 }229 }
260 return true230 return true
261 }231 }
262- runTimerWorker(timerC, cancel, done, func() {
263- c.markDeadlineDue(gen)
264- c.fireFromTimer(gen, false, true, done)
265- })
266 // Test-visible observation follows deadline publication, so a producer232 // Test-visible observation follows deadline publication, so a producer
267 // callback cannot observe a successful invalidation before its timer owns233 // callback cannot observe a successful invalidation before its timer owns
268 // a worker (and race tests cannot finish while a mock expectation remains).234 // a worker (and race tests cannot finish while a mock expectation remains).
@@ -282,7 +248,7 @@ func (c *renderCoordinator) notifyAck() {
282 c.mu.Unlock()248 c.mu.Unlock()
283 return249 return
284 }250 }
285- gen := c.generation251+ gen := c.normalLane.generation
286 blocked := c.ackBlocked252 blocked := c.ackBlocked
287 c.ackBlocked = nil253 c.ackBlocked = nil
288 c.mu.Unlock()254 c.mu.Unlock()
@@ -296,9 +262,9 @@ func (c *renderCoordinator) notifyAck() {
296 // arms that pane's watchdog. Overlapping pane batches are independent.262 // arms that pane's watchdog. Overlapping pane batches are independent.
297 // markDeadlineDue records a received timer tick before the worker probes ACK263 // markDeadlineDue records a received timer tick before the worker probes ACK
298 // readiness, so a concurrent ACK cannot miss an already-expired deadline.264 // readiness, so a concurrent ACK cannot miss an already-expired deadline.
299-func (c *renderCoordinator) markDeadlineDue(gen uint64) {265+func (c *renderCoordinator) markDeadlineDue(token *timerToken) {
300 c.mu.Lock()266 c.mu.Lock()
301- if c.fireValidLocked(gen, false) {267+ if c.fireValidLocked(token, token.generation, false) {
302 c.deadlineDue = true268 c.deadlineDue = true
303 }269 }
304 c.mu.Unlock()270 c.mu.Unlock()
@@ -323,7 +289,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
323 if c.syncBatches == nil {289 if c.syncBatches == nil {
324 c.syncBatches = make(map[*pane]*syncBatch)290 c.syncBatches = make(map[*pane]*syncBatch)
325 }291 }
326- var old timerWorker292+ var old *timerToken
327 if previous := c.syncBatches[p]; previous != nil {293 if previous := c.syncBatches[p]; previous != nil {
328 old = detachSyncBatchLocked(previous)294 old = detachSyncBatchLocked(previous)
329 }295 }
@@ -335,7 +301,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
335 c.syncRegistryVersion++301 c.syncRegistryVersion++
336 clock := c.opts.clock302 clock := c.opts.clock
337 c.mu.Unlock()303 c.mu.Unlock()
338- stopAndJoinTimerWorker(old, nil)304+ stopDetachedTimer(old)
339 if clock == nil {305 if clock == nil {
340 return306 return
341 }307 }
@@ -345,53 +311,59 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
345 timer := clock.NewTimer(maxSyncUpdateDuration)311 timer := clock.NewTimer(maxSyncUpdateDuration)
346 timerC := timer.C()312 timerC := timer.C()
347 c.mu.Lock()313 c.mu.Lock()
348- valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen314+ valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen && batch.lane.generation == 0
349- var cancel, done chan struct{}315+ var token *timerToken
350- if valid {316+ if valid && timerC != nil {
351- cancel, done, valid = batch.timerOwner().publishLocked(gen, timer)317+ batch.lane.generation = gen
318+ token = batch.lane.publishLocked(gen, timer)
319+ valid = token != nil
320+ if valid {
321+ c.supervisor.startLocked(token, timerC, func() { c.runSyncWatchdog(p, batch, token) })
322+ }
352 }323 }
353 c.mu.Unlock()324 c.mu.Unlock()
325+ if !valid || timerC == nil {
326+ stopDetachedTimer(&timerToken{timer: timer})
327+ return
328+ }
329+}
330+
331+func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) {
332+ // Keep the batch registered while force runs. A concurrent render must
333+ // continue to observe the synchronized-output gate until the VT has
334+ // authoritatively closed the batch.
335+ c.mu.Lock()
336+ current := c.syncBatches[p]
337+ valid := !c.torndown && current == batch && current.generation == token.generation && current.lane.token == token
338+ c.mu.Unlock()
354 if !valid {339 if !valid {
355- stopTimer(timer)
356 return340 return
357 }341 }
358- runTimerWorker(timerC, cancel, done, func() {342+ if batch.force != nil {
359- // Keep the batch registered while force runs. A concurrent render must343+ batch.force()
360- // continue to observe the synchronized-output gate until the VT has344+ }
361- // authoritatively closed the batch.
362- c.mu.Lock()
363- current := c.syncBatches[p]
364- valid := !c.torndown && current == batch && current.generation == gen
365- c.mu.Unlock()
366- if !valid {
367- return
368- }
369- if batch.force != nil {
370- batch.force()
371- }
372 345
373- // force may synchronously end this batch, replace it with a newer346+ // force may synchronously end this batch, replace it with a newer
374- // generation, or tear down the coordinator. Remove only the exact347+ // generation, or tear down the coordinator. Remove only the exact
375- // snapshot that expired, then publish one urgent completion wake.348+ // snapshot that expired, then publish one urgent completion wake.
376- c.mu.Lock()349+ c.mu.Lock()
377- current = c.syncBatches[p]350+ current = c.syncBatches[p]
378- valid = !c.torndown && current == batch && current.generation == gen351+ valid = !c.torndown && current == batch && current.generation == token.generation && current.lane.token == token
379- var stopped timerWorker352+ var stopped *timerToken
380- if valid {353+ if valid {
381- delete(c.syncBatches, p)354+ delete(c.syncBatches, p)
382- c.syncRegistryVersion++355+ c.syncRegistryVersion++
383- stopped = detachSyncBatchLocked(batch)356+ stopped = detachSyncBatchLocked(batch)
384- if len(c.syncBatches) == 0 && c.pending {357+ if len(c.syncBatches) == 0 && c.pending {
385- c.pendingUrgent = true358+ c.pendingUrgent = true
386- }
387- }
388- c.mu.Unlock()
389- if valid {
390- stopAndJoinTimerWorker(stopped, done)
391- // Reevaluate the aggregate gate only after forcing this pane.
392- c.fireCurrent(true)
393 }359 }
394- })360+ }
361+ c.mu.Unlock()
362+ if valid {
363+ stopDetachedTimer(stopped)
364+ // Reevaluate the aggregate gate only after forcing this pane.
365+ c.fireCurrent(true)
366+ }
395 }367 }
396 368
397 // noteSyncEnd records completion for exactly one pane batch. It only flushes369 // noteSyncEnd records completion for exactly one pane batch. It only flushes
@@ -430,21 +402,21 @@ func (c *renderCoordinator) removeSyncEnd(p *pane, gen uint64, renderable bool)
430 c.pendingUrgent = true402 c.pendingUrgent = true
431 }403 }
432 c.mu.Unlock()404 c.mu.Unlock()
433- stopAndJoinTimerWorker(worker, nil)405+ stopDetachedTimer(worker)
434 return renderable406 return renderable
435 }407 }
436 408
437 // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.409 // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
438 func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {410 func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
439 c.mu.Lock()411 c.mu.Lock()
440- var worker timerWorker412+ var worker *timerToken
441 if batch := c.syncBatches[p]; batch != nil {413 if batch := c.syncBatches[p]; batch != nil {
442 delete(c.syncBatches, p)414 delete(c.syncBatches, p)
443 c.syncRegistryVersion++415 c.syncRegistryVersion++
444 worker = detachSyncBatchLocked(batch)416 worker = detachSyncBatchLocked(batch)
445 }417 }
446 c.mu.Unlock()418 c.mu.Unlock()
447- stopAndJoinTimerWorker(worker, nil)419+ stopDetachedTimer(worker)
448 }420 }
449 421
450 // subscribePreview installs fn as the preview observer for coalesced wakes.422 // subscribePreview installs fn as the preview observer for coalesced wakes.
@@ -564,7 +536,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
564 }536 }
565 537
566 type renderLifecycleCleanup struct {538 type renderLifecycleCleanup struct {
567- workers []timerWorker539+ tokens []*timerToken
568 observer ports.RuntimeObserver540 observer ports.RuntimeObserver
569 queueCorrelation ports.RuntimeCorrelation541 queueCorrelation ports.RuntimeCorrelation
570 queueMarked bool542 queueMarked bool
@@ -572,7 +544,9 @@ type renderLifecycleCleanup struct {
572 }544 }
573 545
574 func (cleanup renderLifecycleCleanup) finish() {546 func (cleanup renderLifecycleCleanup) finish() {
575- stopAndJoinTimerWorkers(cleanup.workers)547+ for _, token := range cleanup.tokens {
548+ stopDetachedTimer(token)
549+ }
576 if cleanup.queueMarked && cleanup.observer != nil {550 if cleanup.queueMarked && cleanup.observer != nil {
577 cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false))551 cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
578 }552 }
@@ -581,9 +555,7 @@ func (cleanup renderLifecycleCleanup) finish() {
581 }555 }
582 }556 }
583 557
584-// beginDetach invalidates pending work while leaving timer joins to the caller.558+// beginDetach invalidates pending work. It never waits for detached workers.
585-// Callers holding daemon/session locks must publish the lifecycle transition,
586-// release those locks, and only then call cleanup.finish().
587 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {559 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
588 c.mu.Lock()560 c.mu.Lock()
589 var cleanup renderLifecycleCleanup561 var cleanup renderLifecycleCleanup
@@ -595,12 +567,12 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean
595 c.ackDeferred = false567 c.ackDeferred = false
596 c.pendingPreview = false568 c.pendingPreview = false
597 c.coalesced = 0569 c.coalesced = 0
598- _, timer := c.normalTimerOwner().replaceLocked()570+ _, timer := c.normalLane.replaceLocked()
599 c.armed = false571 c.armed = false
600- _, resizeTimer := c.resizeTimerOwner().replaceLocked()572+ _, resizeTimer := c.resizeLane.replaceLocked()
601- _, retryTimer := c.retryTimerOwner().replaceLocked()573+ _, retryTimer := c.retryLane.replaceLocked()
602 cleanup = renderLifecycleCleanup{574 cleanup = renderLifecycleCleanup{
603- workers: []timerWorker{timer, resizeTimer, retryTimer},575+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
604 observer: c.opts.observer,576 observer: c.opts.observer,
605 queueCorrelation: c.queueCorrelation,577 queueCorrelation: c.queueCorrelation,
606 queueMarked: c.queueMarked,578 queueMarked: c.queueMarked,
@@ -617,8 +589,8 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
617 c.beginDetach(ac).finish()589 c.beginDetach(ac).finish()
618 }590 }
619 591
620-// beginReplace hands the coordinator from old to replacement while leaving592+// beginReplace hands the coordinator from old to replacement without waiting
621-// detached timer joins to the caller.593+// for detached workers.
622 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {594 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
623 c.mu.Lock()595 c.mu.Lock()
624 var cleanup renderLifecycleCleanup596 var cleanup renderLifecycleCleanup
@@ -634,12 +606,12 @@ func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready
634 c.ackDeferred = false606 c.ackDeferred = false
635 c.pendingPreview = false607 c.pendingPreview = false
636 c.coalesced = 0608 c.coalesced = 0
637- _, timer := c.normalTimerOwner().replaceLocked()609+ _, timer := c.normalLane.replaceLocked()
638 c.armed = false610 c.armed = false
639- _, resizeTimer := c.resizeTimerOwner().replaceLocked()611+ _, resizeTimer := c.resizeLane.replaceLocked()
640- _, retryTimer := c.retryTimerOwner().replaceLocked()612+ _, retryTimer := c.retryLane.replaceLocked()
641 cleanup = renderLifecycleCleanup{613 cleanup = renderLifecycleCleanup{
642- workers: []timerWorker{timer, resizeTimer, retryTimer},614+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
643 observer: c.opts.observer,615 observer: c.opts.observer,
644 queueCorrelation: c.queueCorrelation,616 queueCorrelation: c.queueCorrelation,
645 queueMarked: c.queueMarked,617 queueMarked: c.queueMarked,
@@ -663,9 +635,14 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
663 // notePark invalidates pending wakes when the attachment parks for resume.635 // notePark invalidates pending wakes when the attachment parks for resume.
664 func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }636 func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
665 637
666-// noteSessionTeardown terminally invalidates the coordinator.638+// beginSessionTeardown is terminal phase one. It rejects future worker
667-func (c *renderCoordinator) noteSessionTeardown() {639+// registration and atomically detaches every complete lane token.
640+func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
668 c.mu.Lock()641 c.mu.Lock()
642+ if c.torndown {
643+ c.mu.Unlock()
644+ return renderLifecycleCleanup{}
645+ }
669 c.torndown = true646 c.torndown = true
670 if c.lease != nil {647 if c.lease != nil {
671 c.lease.active = false648 c.lease.active = false
@@ -673,29 +650,30 @@ func (c *renderCoordinator) noteSessionTeardown() {
673 c.previewWake = nil650 c.previewWake = nil
674 c.previewWakes = nil651 c.previewWakes = nil
675 c.pending = false652 c.pending = false
676- _, resizeTimer := c.resizeTimerOwner().replaceLocked()653+ _, resizeTimer := c.resizeLane.replaceLocked()
677- _, retryTimer := c.retryTimerOwner().replaceLocked()654+ _, retryTimer := c.retryLane.replaceLocked()
678 c.ackDeferred = false655 c.ackDeferred = false
679 c.pendingPreview = false656 c.pendingPreview = false
680- _, timer := c.normalTimerOwner().replaceLocked()657+ _, timer := c.normalLane.replaceLocked()
681 c.armed = false658 c.armed = false
682 workers := c.detachSyncTimersLocked()659 workers := c.detachSyncTimersLocked()
683 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked660 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
684 ackBlocked := c.ackBlocked661 ackBlocked := c.ackBlocked
685 c.queueMarked, c.ackBlocked = false, nil662 c.queueMarked, c.ackBlocked = false, nil
686 c.mu.Unlock()663 c.mu.Unlock()
687- if queueMarked && observer != nil {664+ return renderLifecycleCleanup{
688- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))665+ tokens: append([]*timerToken{timer, resizeTimer, retryTimer}, workers...),
666+ observer: observer,
667+ queueCorrelation: queueCorrelation,
668+ queueMarked: queueMarked,
669+ ackBlocked: ackBlocked,
689 }670 }
690- if ackBlocked != nil {
691- ackBlocked.finish(false)
692- }
693- stopAndJoinTimerWorker(timer, nil)
694- stopAndJoinTimerWorker(resizeTimer, nil)
695- stopAndJoinTimerWorker(retryTimer, nil)
696- stopAndJoinTimerWorkers(workers)
697 }671 }
698 672
673+// waitForTimerWorkers is terminal phase two. Only the session owner invokes it,
674+// after beginSessionTeardown has made future supervisor registration impossible.
675+func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
676+
699 // invalidateRender is the sole producer fan-in. In tests and transitional677 // invalidateRender is the sole producer fan-in. In tests and transitional
700 // headless paths without an attached coordinator it retains the old private678 // headless paths without an attached coordinator it retains the old private
701 // compositor; attached sessions always schedule through their coordinator.679 // compositor; attached sessions always schedule through their coordinator.
@@ -728,21 +706,26 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
728 }706 }
729 707
730 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {708 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
731- c.fireWithTimerWorker(gen, watchdog, deadline, nil)709+ c.mu.Lock()
710+ if c.normalLane.generation != gen {
711+ c.mu.Unlock()
712+ return
713+ }
714+ token := c.normalLane.token
715+ c.mu.Unlock()
716+ c.fireWithTimerToken(token, gen, watchdog, deadline)
732 }717 }
733 718
734-// fireFromTimer consumes a normal deadline from its own worker. It must not719+// fireFromTimer consumes a normal deadline using its exact immutable token.
735-// wait for that worker's done channel because runTimerWorker closes it only720+func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
736-// after this callback returns.721+ c.fireWithTimerToken(token, token.generation, watchdog, deadline)
737-func (c *renderCoordinator) fireFromTimer(gen uint64, watchdog, deadline bool, self <-chan struct{}) {
738- c.fireWithTimerWorker(gen, watchdog, deadline, self)
739 }722 }
740 723
741-func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline bool, self <-chan struct{}) {724+func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
742 // Readiness probes enter the attachment send path, which may in turn read725 // Readiness probes enter the attachment send path, which may in turn read
743 // coordinator metadata. Never hold c.mu across that external callback.726 // coordinator metadata. Never hold c.mu across that external callback.
744 c.mu.Lock()727 c.mu.Lock()
745- if !c.fireValidLocked(gen, watchdog) {728+ if !c.fireValidLocked(token, gen, watchdog) {
746 c.mu.Unlock()729 c.mu.Unlock()
747 return730 return
748 }731 }
@@ -759,7 +742,7 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
759 // syncGateOpenLocked retries predicate evaluation on every registry change742 // syncGateOpenLocked retries predicate evaluation on every registry change
760 // and returns holding c.mu, so pending consumption has no intervening gap.743 // and returns holding c.mu, so pending consumption has no intervening gap.
761 c.mu.Lock()744 c.mu.Lock()
762- if !c.fireValidLocked(gen, watchdog) {745+ if !c.fireValidLocked(token, gen, watchdog) {
763 c.mu.Unlock()746 c.mu.Unlock()
764 return747 return
765 }748 }
@@ -767,7 +750,7 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
767 c.mu.Unlock()750 c.mu.Unlock()
768 return751 return
769 }752 }
770- if !c.fireValidLocked(gen, watchdog) {753+ if !c.fireValidLocked(token, gen, watchdog) {
771 c.mu.Unlock()754 c.mu.Unlock()
772 return755 return
773 }756 }
@@ -821,7 +804,7 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
821 c.queueMarked, c.ackBlocked = false, nil804 c.queueMarked, c.ackBlocked = false, nil
822 wake := c.opts.wake805 wake := c.opts.wake
823 c.mu.Unlock()806 c.mu.Unlock()
824- stopAndJoinTimerWorker(worker, self)807+ stopDetachedTimer(worker)
825 if queueMarked && observer != nil {808 if queueMarked && observer != nil {
826 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))809 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
827 }810 }
@@ -834,8 +817,11 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b
834 c.notifyPreviews(w, preview, previews)817 c.notifyPreviews(w, preview, previews)
835 }818 }
836 819
837-func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {820+func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
838- return !c.torndown && c.pending && (watchdog || gen == c.generation)821+ if c.torndown || !c.pending || watchdog {
822+ return !c.torndown && c.pending
823+ }
824+ return c.normalLane.generation == gen && (token == nil || c.normalLane.token == token)
839 }825 }
840 826
841 // syncGateOpen evaluates pane visibility without c.mu, then reacquires and827 // syncGateOpen evaluates pane visibility without c.mu, then reacquires and
M internal/usecase/daemon/render_coordinator_handshake_test.go +2 -2
@@ -20,7 +20,7 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
2020 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
2121 timer := awaitCoordinatorScheduledTimer(t, h.clk)
2222 h.rc.mu.Lock()
23- workerDone := h.rc.normalWorkerDone
23+ workerDone := h.rc.normalLane.token.done
2424 h.rc.mu.Unlock()
2525 require.NotNil(t, workerDone)
2626 timer.ch <- time.Time{}
@@ -55,7 +55,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
5555 h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})
5656 timer := awaitCoordinatorScheduledTimer(t, h.clk)
5757 h.rc.mu.Lock()
58- workerDone := h.rc.normalWorkerDone
58+ workerDone := h.rc.normalLane.token.done
5959 h.rc.mu.Unlock()
6060 require.NotNil(t, workerDone)
6161 timer.ch <- time.Time{}
@@ -20,7 +20,7 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
20 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})20 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
21 timer := awaitCoordinatorScheduledTimer(t, h.clk)21 timer := awaitCoordinatorScheduledTimer(t, h.clk)
22 h.rc.mu.Lock()22 h.rc.mu.Lock()
23- workerDone := h.rc.normalWorkerDone23+ workerDone := h.rc.normalLane.token.done
24 h.rc.mu.Unlock()24 h.rc.mu.Unlock()
25 require.NotNil(t, workerDone)25 require.NotNil(t, workerDone)
26 timer.ch <- time.Time{}26 timer.ch <- time.Time{}
@@ -55,7 +55,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
55 h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})55 h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})
56 timer := awaitCoordinatorScheduledTimer(t, h.clk)56 timer := awaitCoordinatorScheduledTimer(t, h.clk)
57 h.rc.mu.Lock()57 h.rc.mu.Lock()
58- workerDone := h.rc.normalWorkerDone58+ workerDone := h.rc.normalLane.token.done
59 h.rc.mu.Unlock()59 h.rc.mu.Unlock()
60 require.NotNil(t, workerDone)60 require.NotNil(t, workerDone)
61 timer.ch <- time.Time{}61 timer.ch <- time.Time{}
M internal/usecase/daemon/render_coordinator_support.go +1 -1
@@ -21,7 +21,7 @@ func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinato
2121
2222 func (c *renderCoordinator) fireCurrent(watchdog bool) {
2323 c.mu.Lock()
24- gen := c.generation
24+ gen := c.normalLane.generation
2525 c.mu.Unlock()
2626 c.fire(gen, watchdog, watchdog)
2727 }
@@ -21,7 +21,7 @@ func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinato
21 21
22 func (c *renderCoordinator) fireCurrent(watchdog bool) {22 func (c *renderCoordinator) fireCurrent(watchdog bool) {
23 c.mu.Lock()23 c.mu.Lock()
24- gen := c.generation24+ gen := c.normalLane.generation
25 c.mu.Unlock()25 c.mu.Unlock()
26 c.fire(gen, watchdog, watchdog)26 c.fire(gen, watchdog, watchdog)
27 }27 }
M internal/usecase/daemon/render_coordinator_test.go +48 -72
@@ -25,7 +25,6 @@ import (
2525 type coordinatorMockClock struct {
2626 clock *portsmocks.MockClock
2727 timers chan *coordinatorMockTimer
28- inert bool
2928 }
3029
3130 type coordinatorMockTimer struct {
@@ -52,9 +51,7 @@ func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
5251 mock: portsmocks.NewMockTimer(t),
5352 duration: d,
5453 }
55- if !clk.inert {
56- timer.ch = make(chan time.Time, 1)
57- }
54+ timer.ch = make(chan time.Time, 1)
5855 // A normal deadline reads C both in its worker and in the inert-clock
5956 // guard; a watchdog reads it in its worker. The generated expectation
6057 // makes every channel access observable without a hand-written Timer.
@@ -66,22 +63,14 @@ func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
6663 return clk
6764 }
6865
69-func newInertCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
70- clk := newCoordinatorMockClock(t, capacity)
71- clk.inert = true
72- return clk
73-}
74-
7566 // TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
7667 // deadlock regression: every external timer method synchronously reads
7768 // coordinator metadata. Before two-phase timer ownership this blocked while
7869 // c.mu was held during normal arming, urgent replacement, sync arming, and
7970 // detach cancellation.
80-// TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker reproduces the
81-// lifecycle race exposed by timerOwnership extraction. A timer may select its
82-// tick just before detach cancels it; cancellation alone does not prevent the
83-// worker from entering an external readiness callback after lifecycle cleanup.
84-func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) {
71+// Detach cancels and stops a selected worker but never synchronously joins it;
72+// the stale worker must be rejected when its external readiness probe returns.
73+func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T) {
8574 clock := portsmocks.NewMockClock(t)
8675 timer := portsmocks.NewMockTimer(t)
8776 timerC := make(chan time.Time, 1)
@@ -112,14 +101,8 @@ func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) {
112101 close(detached)
113102 }()
114103 <-stopCalled
115- select {
116- case <-detached:
117- t.Fatal("detach returned before the selected deadline worker completed")
118- default:
119- }
120-
104+ awaitTestCompletion(t, detached, "detach synchronously waited for a selected timer worker")
121105 close(release)
122- awaitTestCompletion(t, detached, "detach did not complete after the deadline worker was released")
123106 }
124107
125108 func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
@@ -224,13 +207,14 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
224207 }
225208 }
226209
227-func awaitResizeCallback(t *testing.T, rc *renderCoordinator) {
210+func captureResizeCallbackDone(t *testing.T, rc *renderCoordinator) <-chan struct{} {
228211 t.Helper()
229- done := rc.resizeCallbackDone()
230- if done == nil {
212+ rc.mu.Lock()
213+ defer rc.mu.Unlock()
214+ if rc.resizeLane.token == nil {
231215 t.Fatal("coordinator did not publish a resize callback completion")
232216 }
233- <-done
217+ return rc.resizeLane.token.done
234218 }
235219
236220 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
@@ -474,7 +458,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
474458 timer := awaitCoordinatorScheduledTimer(t, h.clk)
475459 timer.ch <- time.Time{}
476460 requireNoWake(t, h.wakes)
477- h.rc.noteSessionTeardown()
461+ h.rc.beginSessionTeardown().finish()
478462 h.ackReady.Store(true)
479463 h.rc.notifyAck()
480464 requireNoWake(t, h.wakes)
@@ -673,7 +657,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
673657 timers := h.armedTimers(t)
674658 require.Len(t, timers, 1)
675659 h.rc.mu.Lock()
676- normalWorkerDone := h.rc.normalWorkerDone
660+ normalWorkerDone := h.rc.normalLane.token.done
677661 h.rc.mu.Unlock()
678662 require.NotNil(t, normalWorkerDone)
679663 timers[0].ch <- time.Time{}
@@ -870,7 +854,7 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
870854 }{
871855 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
872856 {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},
873- {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.noteSessionTeardown() }},
857+ {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
874858 }
875859 for _, tc := range cases {
876860 t.Run(tc.name, func(t *testing.T) {
@@ -918,7 +902,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
918902 }{
919903 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
920904 {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
921- {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},
905+ {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
922906 }
923907 for _, tc := range cases {
924908 t.Run(tc.name, func(t *testing.T) {
@@ -1034,7 +1018,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
10341018 case "park":
10351019 h.rc.notePark(staleOwner)
10361020 case "session teardown":
1037- h.rc.noteSessionTeardown()
1021+ h.rc.beginSessionTeardown().finish()
10381022 case "replacement":
10391023 h.rc.noteReplace(staleOwner, freshOwner)
10401024 h.rc.attach(freshOwner)
@@ -1168,8 +1152,10 @@ func TestProducerInvalidations(t *testing.T) {
11681152 // Daemon's construction-time clock afterwards.
11691153 sess.renderCoordinator().opts.clock = clk.clock
11701154 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1171- awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}
1172- awaitResizeCallback(t, sess.renderCoordinator())
1155+ timer := awaitCoordinatorScheduledTimer(t, clk)
1156+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1157+ timer.ch <- time.Time{}
1158+ awaitTestCompletion(t, done, "resize callback did not complete")
11731159 },
11741160 },
11751161 {
@@ -1314,26 +1300,18 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
13141300 requireNoCoordinatorOutputFrame(t, ownerSends)
13151301 }
13161302
1317-func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) {
1303+func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
13181304 rc := newRenderCoordinator(renderCoordinatorOptions{})
1319- newer := &portsmocks.MockTimer{}
1305+ newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
1306+ stale := &timerToken{generation: 1}
13201307
13211308 rc.mu.Lock()
1322- rc.resizeGen = 2
1323- rc.resizeTimer = newer
1324- rc.resizeCancel = make(chan struct{})
1325- rc.mu.Unlock()
1326-
1327- rc.clearResizeTimer(1)
1328- rc.mu.Lock()
1329- require.Same(t, newer, rc.resizeTimer)
1330- require.NotNil(t, rc.resizeCancel)
1331- rc.mu.Unlock()
1332-
1333- rc.clearResizeTimer(2)
1334- rc.mu.Lock()
1335- require.Nil(t, rc.resizeTimer)
1336- require.Nil(t, rc.resizeCancel)
1309+ rc.resizeLane.generation = 2
1310+ rc.resizeLane.token = newer
1311+ require.False(t, rc.resizeLane.clearLocked(stale))
1312+ require.Same(t, newer, rc.resizeLane.token)
1313+ require.True(t, rc.resizeLane.clearLocked(newer))
1314+ require.Nil(t, rc.resizeLane.token)
13371315 rc.mu.Unlock()
13381316 }
13391317
@@ -1350,16 +1328,9 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing
13501328 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
13511329 require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")
13521330 rc.mu.Lock()
1353- retryTimer, retryCancel, retryDone := rc.retryTimer, rc.retryCancel, rc.retryDone
1331+ retryToken := rc.retryLane.token
13541332 rc.mu.Unlock()
1355- require.Nil(t, retryTimer)
1356- require.Nil(t, retryCancel)
1357- require.NotNil(t, retryDone)
1358- select {
1359- case <-retryDone:
1360- default:
1361- t.Fatal("nil-clock retry did not publish its completion edge")
1362- }
1333+ require.Nil(t, retryToken, "a disabled retry lane must not retain stale completion ownership")
13631334 }
13641335
13651336 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
@@ -1380,9 +1351,7 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
13801351 require.Equal(t, renderWake{coalesced: 1}, <-wakes)
13811352 require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())
13821353 rc.mu.Lock()
1383- require.Nil(t, rc.normalTimer)
1384- require.Nil(t, rc.normalCancel)
1385- require.Nil(t, rc.normalWorkerDone)
1354+ require.Nil(t, rc.normalLane.token)
13861355 rc.mu.Unlock()
13871356 }
13881357
@@ -1410,7 +1379,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14101379 timers := h.armedTimers(t)
14111380 require.Len(t, timers, 1)
14121381 h.rc.mu.Lock()
1413- normalWorkerDone := h.rc.normalWorkerDone
1382+ normalWorkerDone := h.rc.normalLane.token.done
14141383 h.rc.mu.Unlock()
14151384 require.NotNil(t, normalWorkerDone)
14161385 timers[0].ch <- time.Time{}
@@ -1418,7 +1387,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14181387 requireNoWake(t, h.previews)
14191388
14201389 h.rc.mu.Lock()
1421- require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock)
1390+ require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
14221391 h.rc.mu.Unlock()
14231392 h.rc.noteSyncEnd(p, 1)
14241393 preview := awaitWake(t, h.previews)
@@ -1446,7 +1415,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14461415 timers := h.armedTimers(t)
14471416 require.Len(t, timers, 1)
14481417 h.rc.mu.Lock()
1449- normalWorkerDone := h.rc.normalWorkerDone
1418+ normalWorkerDone := h.rc.normalLane.token.done
14501419 h.rc.mu.Unlock()
14511420 require.NotNil(t, normalWorkerDone)
14521421 timers[0].ch <- time.Time{}
@@ -1454,7 +1423,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14541423 requireNoWake(t, h.wakes)
14551424
14561425 h.rc.mu.Lock()
1457- require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock)
1426+ require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
14581427 h.rc.mu.Unlock()
14591428 h.rc.noteSyncEnd(p, 1)
14601429 wake := awaitWake(t, h.wakes)
@@ -1470,20 +1439,23 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14701439 end func(*renderCoordinator, *pane)
14711440 }{
14721441 {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},
1473- {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.noteSessionTeardown() }},
1442+ {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.beginSessionTeardown().finish() }},
14741443 } {
14751444 t.Run(lifecycle.name, func(t *testing.T) {
1476- clk := newInertCoordinatorMockClock(t, 2)
1445+ clk := newCoordinatorMockClock(t, 2)
14771446 rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
14781447 p := &pane{}
14791448 rc.noteSyncBegin(p, 1)
14801449 syncTimer := <-clk.timers
14811450 rc.mu.Lock()
1482- syncDone := rc.syncBatches[p].done
1451+ syncDone := rc.syncBatches[p].lane.token.done
14831452 rc.mu.Unlock()
14841453 lifecycle.end(rc, p)
14851454 syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
14861455 requireWorkerExit(t, syncDone)
1456+ if lifecycle.name == "session teardown" {
1457+ rc.waitForTimerWorkers()
1458+ }
14871459 })
14881460 }
14891461 })
@@ -1522,8 +1494,9 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15221494 require.Equal(t, uint64(1), snap.epoch)
15231495 requireNoCoordinatorOutputFrame(t, sends)
15241496
1497+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
15251498 timer.ch <- time.Time{}
1526- awaitResizeCallback(t, sess.renderCoordinator())
1499+ awaitTestCompletion(t, done, "resize callback did not complete")
15271500 inv := awaitInvalidation(t, invs)
15281501 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
15291502 requireNoInvalidation(t, invs)
@@ -1540,12 +1513,13 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15401513 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
15411514 "every resize request must advance the coordinator epoch")
15421515
1516+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
15431517 first.ch <- time.Time{}
15441518 requireNoInvalidation(t, invs)
15451519 requireNoCoordinatorOutputFrame(t, sends)
15461520
15471521 latest.ch <- time.Time{}
1548- awaitResizeCallback(t, sess.renderCoordinator())
1522+ awaitTestCompletion(t, done, "latest resize callback did not complete")
15491523 inv := awaitInvalidation(t, invs)
15501524 require.True(t, inv.reset)
15511525 requireNoInvalidation(t, invs)
@@ -1558,9 +1532,11 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15581532
15591533 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
15601534 timer := awaitCoordinatorScheduledTimer(t, clk)
1535+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
15611536 sess.renderCoordinator().noteDetach(ac)
15621537
15631538 timer.ch <- time.Time{}
1539+ awaitTestCompletion(t, done, "cancelled resize callback did not complete")
15641540 requireNoInvalidation(t, invs)
15651541 requireNoCoordinatorOutputFrame(t, sends)
15661542 })
@@ -1612,7 +1588,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
16121588 awaitCoordinatorScheduledTimer(t, clk)
16131589
16141590 // A torn-down coordinator cannot accept a stale attachment's schedule.
1615- sess.renderCoordinator().noteSessionTeardown()
1591+ sess.renderCoordinator().beginSessionTeardown().finish()
16161592 require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
16171593 })
16181594 })
@@ -25,7 +25,6 @@ import (
25 type coordinatorMockClock struct {25 type coordinatorMockClock struct {
26 clock *portsmocks.MockClock26 clock *portsmocks.MockClock
27 timers chan *coordinatorMockTimer27 timers chan *coordinatorMockTimer
28- inert bool
29 }28 }
30 29
31 type coordinatorMockTimer struct {30 type coordinatorMockTimer struct {
@@ -52,9 +51,7 @@ func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
52 mock: portsmocks.NewMockTimer(t),51 mock: portsmocks.NewMockTimer(t),
53 duration: d,52 duration: d,
54 }53 }
55- if !clk.inert {54+ timer.ch = make(chan time.Time, 1)
56- timer.ch = make(chan time.Time, 1)
57- }
58 // A normal deadline reads C both in its worker and in the inert-clock55 // A normal deadline reads C both in its worker and in the inert-clock
59 // guard; a watchdog reads it in its worker. The generated expectation56 // guard; a watchdog reads it in its worker. The generated expectation
60 // makes every channel access observable without a hand-written Timer.57 // makes every channel access observable without a hand-written Timer.
@@ -66,22 +63,14 @@ func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
66 return clk63 return clk
67 }64 }
68 65
69-func newInertCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
70- clk := newCoordinatorMockClock(t, capacity)
71- clk.inert = true
72- return clk
73-}
74-
75 // TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a66 // TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
76 // deadlock regression: every external timer method synchronously reads67 // deadlock regression: every external timer method synchronously reads
77 // coordinator metadata. Before two-phase timer ownership this blocked while68 // coordinator metadata. Before two-phase timer ownership this blocked while
78 // c.mu was held during normal arming, urgent replacement, sync arming, and69 // c.mu was held during normal arming, urgent replacement, sync arming, and
79 // detach cancellation.70 // detach cancellation.
80-// TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker reproduces the71+// Detach cancels and stops a selected worker but never synchronously joins it;
81-// lifecycle race exposed by timerOwnership extraction. A timer may select its72+// the stale worker must be rejected when its external readiness probe returns.
82-// tick just before detach cancels it; cancellation alone does not prevent the73+func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T) {
83-// worker from entering an external readiness callback after lifecycle cleanup.
84-func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) {
85 clock := portsmocks.NewMockClock(t)74 clock := portsmocks.NewMockClock(t)
86 timer := portsmocks.NewMockTimer(t)75 timer := portsmocks.NewMockTimer(t)
87 timerC := make(chan time.Time, 1)76 timerC := make(chan time.Time, 1)
@@ -112,14 +101,8 @@ func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) {
112 close(detached)101 close(detached)
113 }()102 }()
114 <-stopCalled103 <-stopCalled
115- select {104+ awaitTestCompletion(t, detached, "detach synchronously waited for a selected timer worker")
116- case <-detached:
117- t.Fatal("detach returned before the selected deadline worker completed")
118- default:
119- }
120-
121 close(release)105 close(release)
122- awaitTestCompletion(t, detached, "detach did not complete after the deadline worker was released")
123 }106 }
124 107
125 func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {108 func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
@@ -224,13 +207,14 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
224 }207 }
225 }208 }
226 209
227-func awaitResizeCallback(t *testing.T, rc *renderCoordinator) {210+func captureResizeCallbackDone(t *testing.T, rc *renderCoordinator) <-chan struct{} {
228 t.Helper()211 t.Helper()
229- done := rc.resizeCallbackDone()212+ rc.mu.Lock()
230- if done == nil {213+ defer rc.mu.Unlock()
214+ if rc.resizeLane.token == nil {
231 t.Fatal("coordinator did not publish a resize callback completion")215 t.Fatal("coordinator did not publish a resize callback completion")
232 }216 }
233- <-done217+ return rc.resizeLane.token.done
234 }218 }
235 219
236 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {220 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
@@ -474,7 +458,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
474 timer := awaitCoordinatorScheduledTimer(t, h.clk)458 timer := awaitCoordinatorScheduledTimer(t, h.clk)
475 timer.ch <- time.Time{}459 timer.ch <- time.Time{}
476 requireNoWake(t, h.wakes)460 requireNoWake(t, h.wakes)
477- h.rc.noteSessionTeardown()461+ h.rc.beginSessionTeardown().finish()
478 h.ackReady.Store(true)462 h.ackReady.Store(true)
479 h.rc.notifyAck()463 h.rc.notifyAck()
480 requireNoWake(t, h.wakes)464 requireNoWake(t, h.wakes)
@@ -673,7 +657,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
673 timers := h.armedTimers(t)657 timers := h.armedTimers(t)
674 require.Len(t, timers, 1)658 require.Len(t, timers, 1)
675 h.rc.mu.Lock()659 h.rc.mu.Lock()
676- normalWorkerDone := h.rc.normalWorkerDone660+ normalWorkerDone := h.rc.normalLane.token.done
677 h.rc.mu.Unlock()661 h.rc.mu.Unlock()
678 require.NotNil(t, normalWorkerDone)662 require.NotNil(t, normalWorkerDone)
679 timers[0].ch <- time.Time{}663 timers[0].ch <- time.Time{}
@@ -870,7 +854,7 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
870 }{854 }{
871 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},855 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
872 {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},856 {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},
873- {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.noteSessionTeardown() }},857+ {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
874 }858 }
875 for _, tc := range cases {859 for _, tc := range cases {
876 t.Run(tc.name, func(t *testing.T) {860 t.Run(tc.name, func(t *testing.T) {
@@ -918,7 +902,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
918 }{902 }{
919 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},903 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
920 {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},904 {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
921- {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},905+ {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
922 }906 }
923 for _, tc := range cases {907 for _, tc := range cases {
924 t.Run(tc.name, func(t *testing.T) {908 t.Run(tc.name, func(t *testing.T) {
@@ -1034,7 +1018,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1034 case "park":1018 case "park":
1035 h.rc.notePark(staleOwner)1019 h.rc.notePark(staleOwner)
1036 case "session teardown":1020 case "session teardown":
1037- h.rc.noteSessionTeardown()1021+ h.rc.beginSessionTeardown().finish()
1038 case "replacement":1022 case "replacement":
1039 h.rc.noteReplace(staleOwner, freshOwner)1023 h.rc.noteReplace(staleOwner, freshOwner)
1040 h.rc.attach(freshOwner)1024 h.rc.attach(freshOwner)
@@ -1168,8 +1152,10 @@ func TestProducerInvalidations(t *testing.T) {
1168 // Daemon's construction-time clock afterwards.1152 // Daemon's construction-time clock afterwards.
1169 sess.renderCoordinator().opts.clock = clk.clock1153 sess.renderCoordinator().opts.clock = clk.clock
1170 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})1154 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1171- awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}1155+ timer := awaitCoordinatorScheduledTimer(t, clk)
1172- awaitResizeCallback(t, sess.renderCoordinator())1156+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1157+ timer.ch <- time.Time{}
1158+ awaitTestCompletion(t, done, "resize callback did not complete")
1173 },1159 },
1174 },1160 },
1175 {1161 {
@@ -1314,26 +1300,18 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
1314 requireNoCoordinatorOutputFrame(t, ownerSends)1300 requireNoCoordinatorOutputFrame(t, ownerSends)
1315 }1301 }
1316 1302
1317-func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) {1303+func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1318 rc := newRenderCoordinator(renderCoordinatorOptions{})1304 rc := newRenderCoordinator(renderCoordinatorOptions{})
1319- newer := &portsmocks.MockTimer{}1305+ newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
1306+ stale := &timerToken{generation: 1}
1320 1307
1321 rc.mu.Lock()1308 rc.mu.Lock()
1322- rc.resizeGen = 21309+ rc.resizeLane.generation = 2
1323- rc.resizeTimer = newer1310+ rc.resizeLane.token = newer
1324- rc.resizeCancel = make(chan struct{})1311+ require.False(t, rc.resizeLane.clearLocked(stale))
1325- rc.mu.Unlock()1312+ require.Same(t, newer, rc.resizeLane.token)
1326-1313+ require.True(t, rc.resizeLane.clearLocked(newer))
1327- rc.clearResizeTimer(1)1314+ require.Nil(t, rc.resizeLane.token)
1328- rc.mu.Lock()
1329- require.Same(t, newer, rc.resizeTimer)
1330- require.NotNil(t, rc.resizeCancel)
1331- rc.mu.Unlock()
1332-
1333- rc.clearResizeTimer(2)
1334- rc.mu.Lock()
1335- require.Nil(t, rc.resizeTimer)
1336- require.Nil(t, rc.resizeCancel)
1337 rc.mu.Unlock()1315 rc.mu.Unlock()
1338 }1316 }
1339 1317
@@ -1350,16 +1328,9 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing
1350 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })1328 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1351 require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")1329 require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")
1352 rc.mu.Lock()1330 rc.mu.Lock()
1353- retryTimer, retryCancel, retryDone := rc.retryTimer, rc.retryCancel, rc.retryDone1331+ retryToken := rc.retryLane.token
1354 rc.mu.Unlock()1332 rc.mu.Unlock()
1355- require.Nil(t, retryTimer)1333+ require.Nil(t, retryToken, "a disabled retry lane must not retain stale completion ownership")
1356- require.Nil(t, retryCancel)
1357- require.NotNil(t, retryDone)
1358- select {
1359- case <-retryDone:
1360- default:
1361- t.Fatal("nil-clock retry did not publish its completion edge")
1362- }
1363 }1334 }
1364 1335
1365 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {1336 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
@@ -1380,9 +1351,7 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
1380 require.Equal(t, renderWake{coalesced: 1}, <-wakes)1351 require.Equal(t, renderWake{coalesced: 1}, <-wakes)
1381 require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())1352 require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())
1382 rc.mu.Lock()1353 rc.mu.Lock()
1383- require.Nil(t, rc.normalTimer)1354+ require.Nil(t, rc.normalLane.token)
1384- require.Nil(t, rc.normalCancel)
1385- require.Nil(t, rc.normalWorkerDone)
1386 rc.mu.Unlock()1355 rc.mu.Unlock()
1387 }1356 }
1388 1357
@@ -1410,7 +1379,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1410 timers := h.armedTimers(t)1379 timers := h.armedTimers(t)
1411 require.Len(t, timers, 1)1380 require.Len(t, timers, 1)
1412 h.rc.mu.Lock()1381 h.rc.mu.Lock()
1413- normalWorkerDone := h.rc.normalWorkerDone1382+ normalWorkerDone := h.rc.normalLane.token.done
1414 h.rc.mu.Unlock()1383 h.rc.mu.Unlock()
1415 require.NotNil(t, normalWorkerDone)1384 require.NotNil(t, normalWorkerDone)
1416 timers[0].ch <- time.Time{}1385 timers[0].ch <- time.Time{}
@@ -1418,7 +1387,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1418 requireNoWake(t, h.previews)1387 requireNoWake(t, h.previews)
1419 1388
1420 h.rc.mu.Lock()1389 h.rc.mu.Lock()
1421- require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock)1390+ require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1422 h.rc.mu.Unlock()1391 h.rc.mu.Unlock()
1423 h.rc.noteSyncEnd(p, 1)1392 h.rc.noteSyncEnd(p, 1)
1424 preview := awaitWake(t, h.previews)1393 preview := awaitWake(t, h.previews)
@@ -1446,7 +1415,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1446 timers := h.armedTimers(t)1415 timers := h.armedTimers(t)
1447 require.Len(t, timers, 1)1416 require.Len(t, timers, 1)
1448 h.rc.mu.Lock()1417 h.rc.mu.Lock()
1449- normalWorkerDone := h.rc.normalWorkerDone1418+ normalWorkerDone := h.rc.normalLane.token.done
1450 h.rc.mu.Unlock()1419 h.rc.mu.Unlock()
1451 require.NotNil(t, normalWorkerDone)1420 require.NotNil(t, normalWorkerDone)
1452 timers[0].ch <- time.Time{}1421 timers[0].ch <- time.Time{}
@@ -1454,7 +1423,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1454 requireNoWake(t, h.wakes)1423 requireNoWake(t, h.wakes)
1455 1424
1456 h.rc.mu.Lock()1425 h.rc.mu.Lock()
1457- require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock)1426+ require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1458 h.rc.mu.Unlock()1427 h.rc.mu.Unlock()
1459 h.rc.noteSyncEnd(p, 1)1428 h.rc.noteSyncEnd(p, 1)
1460 wake := awaitWake(t, h.wakes)1429 wake := awaitWake(t, h.wakes)
@@ -1470,20 +1439,23 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1470 end func(*renderCoordinator, *pane)1439 end func(*renderCoordinator, *pane)
1471 }{1440 }{
1472 {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},1441 {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},
1473- {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.noteSessionTeardown() }},1442+ {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.beginSessionTeardown().finish() }},
1474 } {1443 } {
1475 t.Run(lifecycle.name, func(t *testing.T) {1444 t.Run(lifecycle.name, func(t *testing.T) {
1476- clk := newInertCoordinatorMockClock(t, 2)1445+ clk := newCoordinatorMockClock(t, 2)
1477 rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})1446 rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
1478 p := &pane{}1447 p := &pane{}
1479 rc.noteSyncBegin(p, 1)1448 rc.noteSyncBegin(p, 1)
1480 syncTimer := <-clk.timers1449 syncTimer := <-clk.timers
1481 rc.mu.Lock()1450 rc.mu.Lock()
1482- syncDone := rc.syncBatches[p].done1451+ syncDone := rc.syncBatches[p].lane.token.done
1483 rc.mu.Unlock()1452 rc.mu.Unlock()
1484 lifecycle.end(rc, p)1453 lifecycle.end(rc, p)
1485 syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)1454 syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
1486 requireWorkerExit(t, syncDone)1455 requireWorkerExit(t, syncDone)
1456+ if lifecycle.name == "session teardown" {
1457+ rc.waitForTimerWorkers()
1458+ }
1487 })1459 })
1488 }1460 }
1489 })1461 })
@@ -1522,8 +1494,9 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1522 require.Equal(t, uint64(1), snap.epoch)1494 require.Equal(t, uint64(1), snap.epoch)
1523 requireNoCoordinatorOutputFrame(t, sends)1495 requireNoCoordinatorOutputFrame(t, sends)
1524 1496
1497+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1525 timer.ch <- time.Time{}1498 timer.ch <- time.Time{}
1526- awaitResizeCallback(t, sess.renderCoordinator())1499+ awaitTestCompletion(t, done, "resize callback did not complete")
1527 inv := awaitInvalidation(t, invs)1500 inv := awaitInvalidation(t, invs)
1528 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")1501 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1529 requireNoInvalidation(t, invs)1502 requireNoInvalidation(t, invs)
@@ -1540,12 +1513,13 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1540 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,1513 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
1541 "every resize request must advance the coordinator epoch")1514 "every resize request must advance the coordinator epoch")
1542 1515
1516+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1543 first.ch <- time.Time{}1517 first.ch <- time.Time{}
1544 requireNoInvalidation(t, invs)1518 requireNoInvalidation(t, invs)
1545 requireNoCoordinatorOutputFrame(t, sends)1519 requireNoCoordinatorOutputFrame(t, sends)
1546 1520
1547 latest.ch <- time.Time{}1521 latest.ch <- time.Time{}
1548- awaitResizeCallback(t, sess.renderCoordinator())1522+ awaitTestCompletion(t, done, "latest resize callback did not complete")
1549 inv := awaitInvalidation(t, invs)1523 inv := awaitInvalidation(t, invs)
1550 require.True(t, inv.reset)1524 require.True(t, inv.reset)
1551 requireNoInvalidation(t, invs)1525 requireNoInvalidation(t, invs)
@@ -1558,9 +1532,11 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1558 1532
1559 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})1533 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1560 timer := awaitCoordinatorScheduledTimer(t, clk)1534 timer := awaitCoordinatorScheduledTimer(t, clk)
1535+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1561 sess.renderCoordinator().noteDetach(ac)1536 sess.renderCoordinator().noteDetach(ac)
1562 1537
1563 timer.ch <- time.Time{}1538 timer.ch <- time.Time{}
1539+ awaitTestCompletion(t, done, "cancelled resize callback did not complete")
1564 requireNoInvalidation(t, invs)1540 requireNoInvalidation(t, invs)
1565 requireNoCoordinatorOutputFrame(t, sends)1541 requireNoCoordinatorOutputFrame(t, sends)
1566 })1542 })
@@ -1612,7 +1588,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1612 awaitCoordinatorScheduledTimer(t, clk)1588 awaitCoordinatorScheduledTimer(t, clk)
1613 1589
1614 // A torn-down coordinator cannot accept a stale attachment's schedule.1590 // A torn-down coordinator cannot accept a stale attachment's schedule.
1615- sess.renderCoordinator().noteSessionTeardown()1591+ sess.renderCoordinator().beginSessionTeardown().finish()
1616 require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))1592 require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1617 })1593 })
1618 })1594 })
M internal/usecase/daemon/render_resize_deadline.go +46 -91
@@ -2,105 +2,79 @@ package daemon
22
33 import "github.com/bnema/vev/internal/domain"
44
5-// Resize deadlines and retries own only scheduling mechanics. Attachment and
6-// epoch validity stay with renderCoordinator, alongside the state they guard.
7-
8-// recordResizeRequest records the latest requested geometry for the attachment
9-// currently owning source. Production callbacks use recordResizeRequestForLease
10-// so a captured lease, rather than a reused attachedClient pointer, is checked.
5+// Resize deadlines and retries retain coordinator-owned attachment, epoch, and
6+// token validation at every callback effect boundary.
117 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
128 return c.recordResizeRequestForLease(size, source, c.attachmentLease(source))
139 }
1410
15-// recordResizeRequestForLease records a latest-wins request only for this exact
16-// attachment incarnation. A same-object resume installs a different lease.
1711 func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 {
1812 c.mu.Lock()
1913 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
2014 c.mu.Unlock()
2115 return 0
2216 }
23- // An intervening request supersedes any failed-pane retry, even if a fake
24- // timer delivers a stopped callback afterwards.
25- _, retryTimer := c.retryTimerOwner().replaceLocked()
17+ _, retry := c.retryLane.replaceLocked()
2618 c.resize.epoch++
27- c.resize.size = size
28- c.resize.source = source
29- c.resize.lease = lease
19+ c.resize.size, c.resize.source, c.resize.lease = size, source, lease
3020 epoch := c.resize.epoch
3121 c.mu.Unlock()
32- stopAndJoinTimerWorker(retryTimer, nil)
22+ stopDetachedTimer(retry)
3323 return epoch
3424 }
3525
36-// scheduleResizeForLease runs apply after the bounded bulk window. The timer
37-// callback validates the precise lease captured at request dispatch.
3826 func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 {
3927 epoch := c.recordResizeRequestForLease(size, source, lease)
4028 if epoch == 0 {
4129 return 0
4230 }
4331 c.mu.Lock()
44- _, old := c.resizeTimerOwner().replaceLocked()
45- gen := c.resizeGen
32+ gen, old := c.resizeLane.replaceLocked()
4633 clock := c.opts.clock
4734 c.mu.Unlock()
48- stopAndJoinTimerWorker(old, nil)
35+ stopDetachedTimer(old)
4936 if clock == nil {
5037 if c.resizeCurrentForLease(epoch, source, lease, false) {
5138 run(epoch)
5239 }
53- c.completeResizeTimer(gen)
5440 return epoch
5541 }
5642 timer := clock.NewTimer(minOutputRenderDeadline)
5743 timerC := timer.C()
5844 if timerC == nil {
59- stopTimer(timer)
45+ stopDetachedTimer(&timerToken{timer: timer})
6046 if c.resizeCurrentForLease(epoch, source, lease, false) {
6147 run(epoch)
6248 }
63- c.completeResizeTimer(gen)
6449 return epoch
6550 }
6651 c.mu.Lock()
67- valid := !c.torndown && c.resizeGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
68- var cancel, done chan struct{}
52+ valid := !c.torndown && c.resizeLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
53+ var token *timerToken
6954 if valid {
70- cancel, done, valid = c.resizeTimerOwner().publishLocked(gen, timer)
55+ token = c.resizeLane.publishLocked(gen, timer)
56+ valid = token != nil
57+ if valid {
58+ c.supervisor.startLocked(token, timerC, func() {
59+ c.mu.Lock()
60+ valid := !c.torndown && c.resizeLane.token == token && c.resizeLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
61+ if valid {
62+ c.resizeLane.clearLocked(token)
63+ }
64+ c.mu.Unlock()
65+ if valid {
66+ run(epoch)
67+ }
68+ })
69+ }
7170 }
7271 c.mu.Unlock()
7372 if !valid {
74- stopTimer(timer)
75- c.completeResizeTimer(gen)
76- return epoch
73+ stopDetachedTimer(&timerToken{timer: timer})
7774 }
78- runTimerWorker(timerC, cancel, done, func() {
79- c.mu.Lock()
80- valid := !c.torndown && c.resizeGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
81- c.mu.Unlock()
82- c.clearResizeTimer(gen)
83- if valid {
84- run(epoch)
85- }
86- })
8775 return epoch
8876 }
8977
90-// clearResizeTimer releases timer ownership only for the matching resize generation.
91-// A stale callback must not clear a newer request's timer or cancellation channel.
92-func (c *renderCoordinator) clearResizeTimer(gen uint64) {
93- c.mu.Lock()
94- defer c.mu.Unlock()
95- c.resizeTimerOwner().clearLocked(gen)
96-}
97-
98-func (c *renderCoordinator) completeResizeTimer(gen uint64) {
99- c.mu.Lock()
100- defer c.mu.Unlock()
101- c.resizeTimerOwner().completeLocked(gen)
102-}
103-
10478 func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease, commit bool) bool {
10579 c.mu.Lock()
10680 defer c.mu.Unlock()
@@ -122,52 +96,42 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
12296 c.mu.Unlock()
12397 return
12498 }
125- _, old := c.retryTimerOwner().replaceLocked()
126- gen := c.retryGen
99+ gen, old := c.retryLane.replaceLocked()
127100 clock := c.opts.clock
128101 c.mu.Unlock()
129- stopAndJoinTimerWorker(old, nil)
102+ stopDetachedTimer(old)
130103 if clock == nil {
131- // A nil clock disables deferred retries. Completing the generation keeps
132- // retry bookkeeping consistent without recursively re-entering resize.
133- c.mu.Lock()
134- c.retryTimerOwner().completeLocked(gen)
135- c.mu.Unlock()
136104 return
137105 }
138106 timer := clock.NewTimer(minOutputRenderDeadline)
139107 timerC := timer.C()
140108 if timerC == nil {
141- // A nil timer channel is the deterministic disabled-clock contract used
142- // by headless tests; do not spin retries synchronously.
143- stopTimer(timer)
144- c.mu.Lock()
145- c.retryTimerOwner().completeLocked(gen)
146- c.mu.Unlock()
109+ stopDetachedTimer(&timerToken{timer: timer})
147110 return
148111 }
149112 c.mu.Lock()
150- valid := !c.torndown && c.retryGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
151- var cancel, done chan struct{}
113+ valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
152114 if valid {
153- cancel, done, valid = c.retryTimerOwner().publishLocked(gen, timer)
115+ token := c.retryLane.publishLocked(gen, timer)
116+ valid = token != nil
117+ if valid {
118+ c.supervisor.startLocked(token, timerC, func() {
119+ c.mu.Lock()
120+ valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
121+ if valid {
122+ c.retryLane.clearLocked(token)
123+ }
124+ c.mu.Unlock()
125+ if valid {
126+ run()
127+ }
128+ })
129+ }
154130 }
155131 c.mu.Unlock()
156132 if !valid {
157- stopTimer(timer)
158- return
133+ stopDetachedTimer(&timerToken{timer: timer})
159134 }
160- runTimerWorker(timerC, cancel, done, func() {
161- c.mu.Lock()
162- valid := !c.torndown && c.retryGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
163- if valid {
164- c.retryTimerOwner().clearLocked(gen)
165- }
166- c.mu.Unlock()
167- if valid {
168- run()
169- }
170- })
171135 }
172136
173137 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
@@ -176,15 +140,6 @@ func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedC
176140 return c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
177141 }
178142
179-// resizeCallbackDone returns the completion edge for the latest scheduled
180-// resize callback. A nil result means no resize deadline is pending.
181-func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
182- c.mu.Lock()
183- defer c.mu.Unlock()
184- return c.resizeDone
185-}
186-
187-// resizeSnapshot returns a locked copy of the latest resize metadata.
188143 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
189144 c.mu.Lock()
190145 defer c.mu.Unlock()
@@ -2,105 +2,79 @@ package daemon
2 2
3 import "github.com/bnema/vev/internal/domain"3 import "github.com/bnema/vev/internal/domain"
4 4
5-// Resize deadlines and retries own only scheduling mechanics. Attachment and5+// Resize deadlines and retries retain coordinator-owned attachment, epoch, and
6-// epoch validity stay with renderCoordinator, alongside the state they guard.6+// token validation at every callback effect boundary.
7-
8-// recordResizeRequest records the latest requested geometry for the attachment
9-// currently owning source. Production callbacks use recordResizeRequestForLease
10-// so a captured lease, rather than a reused attachedClient pointer, is checked.
11 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {7 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
12 return c.recordResizeRequestForLease(size, source, c.attachmentLease(source))8 return c.recordResizeRequestForLease(size, source, c.attachmentLease(source))
13 }9 }
14 10
15-// recordResizeRequestForLease records a latest-wins request only for this exact
16-// attachment incarnation. A same-object resume installs a different lease.
17 func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 {11 func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 {
18 c.mu.Lock()12 c.mu.Lock()
19 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {13 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
20 c.mu.Unlock()14 c.mu.Unlock()
21 return 015 return 0
22 }16 }
23- // An intervening request supersedes any failed-pane retry, even if a fake17+ _, retry := c.retryLane.replaceLocked()
24- // timer delivers a stopped callback afterwards.
25- _, retryTimer := c.retryTimerOwner().replaceLocked()
26 c.resize.epoch++18 c.resize.epoch++
27- c.resize.size = size19+ c.resize.size, c.resize.source, c.resize.lease = size, source, lease
28- c.resize.source = source
29- c.resize.lease = lease
30 epoch := c.resize.epoch20 epoch := c.resize.epoch
31 c.mu.Unlock()21 c.mu.Unlock()
32- stopAndJoinTimerWorker(retryTimer, nil)22+ stopDetachedTimer(retry)
33 return epoch23 return epoch
34 }24 }
35 25
36-// scheduleResizeForLease runs apply after the bounded bulk window. The timer
37-// callback validates the precise lease captured at request dispatch.
38 func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 {26 func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 {
39 epoch := c.recordResizeRequestForLease(size, source, lease)27 epoch := c.recordResizeRequestForLease(size, source, lease)
40 if epoch == 0 {28 if epoch == 0 {
41 return 029 return 0
42 }30 }
43 c.mu.Lock()31 c.mu.Lock()
44- _, old := c.resizeTimerOwner().replaceLocked()32+ gen, old := c.resizeLane.replaceLocked()
45- gen := c.resizeGen
46 clock := c.opts.clock33 clock := c.opts.clock
47 c.mu.Unlock()34 c.mu.Unlock()
48- stopAndJoinTimerWorker(old, nil)35+ stopDetachedTimer(old)
49 if clock == nil {36 if clock == nil {
50 if c.resizeCurrentForLease(epoch, source, lease, false) {37 if c.resizeCurrentForLease(epoch, source, lease, false) {
51 run(epoch)38 run(epoch)
52 }39 }
53- c.completeResizeTimer(gen)
54 return epoch40 return epoch
55 }41 }
56 timer := clock.NewTimer(minOutputRenderDeadline)42 timer := clock.NewTimer(minOutputRenderDeadline)
57 timerC := timer.C()43 timerC := timer.C()
58 if timerC == nil {44 if timerC == nil {
59- stopTimer(timer)45+ stopDetachedTimer(&timerToken{timer: timer})
60 if c.resizeCurrentForLease(epoch, source, lease, false) {46 if c.resizeCurrentForLease(epoch, source, lease, false) {
61 run(epoch)47 run(epoch)
62 }48 }
63- c.completeResizeTimer(gen)
64 return epoch49 return epoch
65 }50 }
66 c.mu.Lock()51 c.mu.Lock()
67- valid := !c.torndown && c.resizeGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source52+ valid := !c.torndown && c.resizeLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
68- var cancel, done chan struct{}53+ var token *timerToken
69 if valid {54 if valid {
70- cancel, done, valid = c.resizeTimerOwner().publishLocked(gen, timer)55+ token = c.resizeLane.publishLocked(gen, timer)
56+ valid = token != nil
57+ if valid {
58+ c.supervisor.startLocked(token, timerC, func() {
59+ c.mu.Lock()
60+ valid := !c.torndown && c.resizeLane.token == token && c.resizeLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
61+ if valid {
62+ c.resizeLane.clearLocked(token)
63+ }
64+ c.mu.Unlock()
65+ if valid {
66+ run(epoch)
67+ }
68+ })
69+ }
71 }70 }
72 c.mu.Unlock()71 c.mu.Unlock()
73 if !valid {72 if !valid {
74- stopTimer(timer)73+ stopDetachedTimer(&timerToken{timer: timer})
75- c.completeResizeTimer(gen)
76- return epoch
77 }74 }
78- runTimerWorker(timerC, cancel, done, func() {
79- c.mu.Lock()
80- valid := !c.torndown && c.resizeGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
81- c.mu.Unlock()
82- c.clearResizeTimer(gen)
83- if valid {
84- run(epoch)
85- }
86- })
87 return epoch75 return epoch
88 }76 }
89 77
90-// clearResizeTimer releases timer ownership only for the matching resize generation.
91-// A stale callback must not clear a newer request's timer or cancellation channel.
92-func (c *renderCoordinator) clearResizeTimer(gen uint64) {
93- c.mu.Lock()
94- defer c.mu.Unlock()
95- c.resizeTimerOwner().clearLocked(gen)
96-}
97-
98-func (c *renderCoordinator) completeResizeTimer(gen uint64) {
99- c.mu.Lock()
100- defer c.mu.Unlock()
101- c.resizeTimerOwner().completeLocked(gen)
102-}
103-
104 func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease, commit bool) bool {78 func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease, commit bool) bool {
105 c.mu.Lock()79 c.mu.Lock()
106 defer c.mu.Unlock()80 defer c.mu.Unlock()
@@ -122,52 +96,42 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
122 c.mu.Unlock()96 c.mu.Unlock()
123 return97 return
124 }98 }
125- _, old := c.retryTimerOwner().replaceLocked()99+ gen, old := c.retryLane.replaceLocked()
126- gen := c.retryGen
127 clock := c.opts.clock100 clock := c.opts.clock
128 c.mu.Unlock()101 c.mu.Unlock()
129- stopAndJoinTimerWorker(old, nil)102+ stopDetachedTimer(old)
130 if clock == nil {103 if clock == nil {
131- // A nil clock disables deferred retries. Completing the generation keeps
132- // retry bookkeeping consistent without recursively re-entering resize.
133- c.mu.Lock()
134- c.retryTimerOwner().completeLocked(gen)
135- c.mu.Unlock()
136 return104 return
137 }105 }
138 timer := clock.NewTimer(minOutputRenderDeadline)106 timer := clock.NewTimer(minOutputRenderDeadline)
139 timerC := timer.C()107 timerC := timer.C()
140 if timerC == nil {108 if timerC == nil {
141- // A nil timer channel is the deterministic disabled-clock contract used109+ stopDetachedTimer(&timerToken{timer: timer})
142- // by headless tests; do not spin retries synchronously.
143- stopTimer(timer)
144- c.mu.Lock()
145- c.retryTimerOwner().completeLocked(gen)
146- c.mu.Unlock()
147 return110 return
148 }111 }
149 c.mu.Lock()112 c.mu.Lock()
150- valid := !c.torndown && c.retryGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source113+ valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
151- var cancel, done chan struct{}
152 if valid {114 if valid {
153- cancel, done, valid = c.retryTimerOwner().publishLocked(gen, timer)115+ token := c.retryLane.publishLocked(gen, timer)
116+ valid = token != nil
117+ if valid {
118+ c.supervisor.startLocked(token, timerC, func() {
119+ c.mu.Lock()
120+ valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
121+ if valid {
122+ c.retryLane.clearLocked(token)
123+ }
124+ c.mu.Unlock()
125+ if valid {
126+ run()
127+ }
128+ })
129+ }
154 }130 }
155 c.mu.Unlock()131 c.mu.Unlock()
156 if !valid {132 if !valid {
157- stopTimer(timer)133+ stopDetachedTimer(&timerToken{timer: timer})
158- return
159 }134 }
160- runTimerWorker(timerC, cancel, done, func() {
161- c.mu.Lock()
162- valid := !c.torndown && c.retryGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
163- if valid {
164- c.retryTimerOwner().clearLocked(gen)
165- }
166- c.mu.Unlock()
167- if valid {
168- run()
169- }
170- })
171 }135 }
172 136
173 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {137 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
@@ -176,15 +140,6 @@ func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedC
176 return c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch140 return c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
177 }141 }
178 142
179-// resizeCallbackDone returns the completion edge for the latest scheduled
180-// resize callback. A nil result means no resize deadline is pending.
181-func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
182- c.mu.Lock()
183- defer c.mu.Unlock()
184- return c.resizeDone
185-}
186-
187-// resizeSnapshot returns a locked copy of the latest resize metadata.
188 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {143 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
189 c.mu.Lock()144 c.mu.Lock()
190 defer c.mu.Unlock()145 defer c.mu.Unlock()
M internal/usecase/daemon/render_test.go +2 -2
@@ -205,8 +205,8 @@ func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordin
205205 }
206206 var done <-chan struct{}
207207 rc.mu.Lock()
208- if rc.normalTimer == timer.mock {
209- done = rc.normalWorkerDone
208+ if rc.normalLane.token.timer == timer.mock {
209+ done = rc.normalLane.token.done
210210 }
211211 rc.mu.Unlock()
212212 timer.ch <- time.Time{}
@@ -205,8 +205,8 @@ func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordin
205 }205 }
206 var done <-chan struct{}206 var done <-chan struct{}
207 rc.mu.Lock()207 rc.mu.Lock()
208- if rc.normalTimer == timer.mock {208+ if rc.normalLane.token.timer == timer.mock {
209- done = rc.normalWorkerDone209+ done = rc.normalLane.token.done
210 }210 }
211 rc.mu.Unlock()211 rc.mu.Unlock()
212 timer.ch <- time.Time{}212 timer.ch <- time.Time{}
M internal/usecase/daemon/render_timer.go +53 -89
@@ -1,126 +1,90 @@
11 package daemon
22
33 import (
4+ "sync"
45 "time"
56
67 "github.com/bnema/vev/internal/ports"
78 )
89
9-// timerOwnership is the coordinator-side ownership protocol for a timer lane.
10-// Its methods ending in Locked require the owning coordinator lock. Timer.C,
11-// Timer.Stop, and callbacks are deliberately kept out of this type and must
12-// run after that lock is released: ports may synchronously re-enter the
13-// coordinator.
14-//
15-// The pointers let each domain lane retain its own fields and predicates while
16-// sharing generation tickets, cancellation, and worker completion ownership.
17-type timerOwnership struct {
18- timer *ports.Timer
19- cancel *chan struct{}
20- done *chan struct{}
21- generation *uint64
10+// timerToken is the complete, immutable identity of one timer worker. A lane
11+// owns either this token or nothing; cancellation and completion are never
12+// retained separately where a stale callback could reacquire them.
13+type timerToken struct {
14+ generation uint64
15+ timer ports.Timer
16+ cancel chan struct{}
17+ done chan struct{}
2218 }
2319
24-func timerOwner(timer *ports.Timer, cancel, done *chan struct{}, generation *uint64) timerOwnership {
25- return timerOwnership{timer: timer, cancel: cancel, done: done, generation: generation}
20+// timerLane is coordinator-owned and must be accessed while renderCoordinator.mu
21+// is held. Detach removes the complete token before any external Timer call.
22+type timerLane struct {
23+ generation uint64
24+ token *timerToken
2625 }
2726
28-// timerWorker is a detached timer and the completion edge of the worker
29-// waiting on it. Stop and wait must happen after releasing the owner lock:
30-// both Timer.Stop and a callback completing the worker may re-enter the
31-// coordinator.
32-type timerWorker struct {
33- timer ports.Timer
34- done <-chan struct{}
27+func (l *timerLane) replaceLocked() (uint64, *timerToken) {
28+ l.generation++
29+ return l.generation, l.detachLocked()
3530 }
3631
37-// replaceLocked invalidates the current ticket and releases its timer worker
38-// for an unlocked cancellation and join. A worker is always released even when
39-// a fake timer's channel is nil or never fires.
40-func (o timerOwnership) replaceLocked() (uint64, timerWorker) {
41- *o.generation = *o.generation + 1
42- return *o.generation, o.detachLocked()
43-}
44-
45-// detachLocked releases the current worker and timer. The returned worker must
46-// be stopped and joined after dropping the owner lock.
47-func (o timerOwnership) detachLocked() timerWorker {
48- worker := timerWorker{timer: *o.timer, done: *o.done}
49- *o.timer = nil
50- if *o.cancel != nil {
51- close(*o.cancel)
52- *o.cancel = nil
32+func (l *timerLane) detachLocked() *timerToken {
33+ token := l.token
34+ l.token = nil
35+ if token != nil {
36+ close(token.cancel)
5337 }
54- return worker
38+ return token
5539 }
5640
57-// publishLocked assigns a timer created and inspected outside the owner lock.
58-// It rejects a stale ticket or an already-replaced timer without invoking a
59-// port method.
60-func (o timerOwnership) publishLocked(ticket uint64, timer ports.Timer) (cancel, done chan struct{}, ok bool) {
61- if *o.generation != ticket || *o.timer != nil {
62- return nil, nil, false
41+func (l *timerLane) publishLocked(generation uint64, timer ports.Timer) *timerToken {
42+ if l.generation != generation || l.token != nil {
43+ return nil
6344 }
64- cancel, done = make(chan struct{}), make(chan struct{})
65- *o.timer, *o.cancel, *o.done = timer, cancel, done
66- return cancel, done, true
67-}
68-
69-// clearLocked releases only the matching ticket after its worker has consumed
70-// a tick. It cannot clear a replacement timer.
71-func (o timerOwnership) clearLocked(ticket uint64) bool {
72- if *o.generation != ticket {
73- return false
45+ token := &timerToken{
46+ generation: generation,
47+ timer: timer,
48+ cancel: make(chan struct{}),
49+ done: make(chan struct{}),
7450 }
75- *o.timer, *o.cancel = nil, nil
76- return true
51+ l.token = token
52+ return token
7753 }
7854
79-// completeLocked records a completed no-worker timer path (such as a disabled
80-// clock) without overwriting a replacement lane's completion edge.
81-func (o timerOwnership) completeLocked(ticket uint64) bool {
82- if *o.generation != ticket {
55+func (l *timerLane) clearLocked(token *timerToken) bool {
56+ if token == nil || l.generation != token.generation || l.token != token {
8357 return false
8458 }
85- done := make(chan struct{})
86- close(done)
87- *o.done = done
59+ l.token = nil
8860 return true
8961 }
9062
91-// runTimerWorker owns the completion edge for an armed timer. A nil timer
92-// channel intentionally does not start a worker; callers apply their
93-// domain-specific disabled-clock behavior synchronously.
94-func runTimerWorker(timerC <-chan time.Time, cancel <-chan struct{}, done chan struct{}, fire func()) {
63+// timerSupervisor owns worker accounting. Workers are registered only while
64+// coordinator ownership is held and terminal teardown rejects registrations
65+// before Wait begins, so Add cannot race the terminal Wait.
66+type timerSupervisor struct{ workers sync.WaitGroup }
67+
68+func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69+ s.workers.Add(1)
9570 go func() {
96- defer close(done)
71+ defer s.workers.Done()
72+ defer close(token.done)
9773 select {
9874 case <-timerC:
9975 fire()
100- case <-cancel:
76+ case <-token.cancel:
10177 }
10278 }()
10379 }
10480
105-func stopTimer(timer ports.Timer) {
106- if timer != nil {
107- timer.Stop()
108- }
109-}
110-
111-// stopAndJoinTimerWorker prevents a cancelled timer callback from escaping a
112-// lifecycle boundary and touching transports or test-owned timer mocks later.
113-// Callers must not hold coordinator locks here. self is supplied only by a
114-// worker that consumed its own tick; waiting for that worker would deadlock.
115-func stopAndJoinTimerWorker(worker timerWorker, self <-chan struct{}) {
116- stopTimer(worker.timer)
117- if worker.done != nil && worker.done != self {
118- <-worker.done
119- }
120-}
81+func (s *timerSupervisor) wait() { s.workers.Wait() }
12182
122-func stopAndJoinTimerWorkers(workers []timerWorker) {
123- for _, worker := range workers {
124- stopAndJoinTimerWorker(worker, nil)
83+// stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
84+// replacement transitions may cancel/stop stale workers but callback stacks
85+// never wait for a worker to finish.
86+func stopDetachedTimer(token *timerToken) {
87+ if token != nil && token.timer != nil {
88+ token.timer.Stop()
12589 }
12690 }
@@ -1,126 +1,90 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync"
4 "time"5 "time"
5 6
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 )8 )
8 9
9-// timerOwnership is the coordinator-side ownership protocol for a timer lane.10+// timerToken is the complete, immutable identity of one timer worker. A lane
10-// Its methods ending in Locked require the owning coordinator lock. Timer.C,11+// owns either this token or nothing; cancellation and completion are never
11-// Timer.Stop, and callbacks are deliberately kept out of this type and must12+// retained separately where a stale callback could reacquire them.
12-// run after that lock is released: ports may synchronously re-enter the13+type timerToken struct {
13-// coordinator.14+ generation uint64
14-//15+ timer ports.Timer
15-// The pointers let each domain lane retain its own fields and predicates while16+ cancel chan struct{}
16-// sharing generation tickets, cancellation, and worker completion ownership.17+ done chan struct{}
17-type timerOwnership struct {
18- timer *ports.Timer
19- cancel *chan struct{}
20- done *chan struct{}
21- generation *uint64
22 }18 }
23 19
24-func timerOwner(timer *ports.Timer, cancel, done *chan struct{}, generation *uint64) timerOwnership {20+// timerLane is coordinator-owned and must be accessed while renderCoordinator.mu
25- return timerOwnership{timer: timer, cancel: cancel, done: done, generation: generation}21+// is held. Detach removes the complete token before any external Timer call.
22+type timerLane struct {
23+ generation uint64
24+ token *timerToken
26 }25 }
27 26
28-// timerWorker is a detached timer and the completion edge of the worker27+func (l *timerLane) replaceLocked() (uint64, *timerToken) {
29-// waiting on it. Stop and wait must happen after releasing the owner lock:28+ l.generation++
30-// both Timer.Stop and a callback completing the worker may re-enter the29+ return l.generation, l.detachLocked()
31-// coordinator.
32-type timerWorker struct {
33- timer ports.Timer
34- done <-chan struct{}
35 }30 }
36 31
37-// replaceLocked invalidates the current ticket and releases its timer worker32+func (l *timerLane) detachLocked() *timerToken {
38-// for an unlocked cancellation and join. A worker is always released even when33+ token := l.token
39-// a fake timer's channel is nil or never fires.34+ l.token = nil
40-func (o timerOwnership) replaceLocked() (uint64, timerWorker) {35+ if token != nil {
41- *o.generation = *o.generation + 136+ close(token.cancel)
42- return *o.generation, o.detachLocked()
43-}
44-
45-// detachLocked releases the current worker and timer. The returned worker must
46-// be stopped and joined after dropping the owner lock.
47-func (o timerOwnership) detachLocked() timerWorker {
48- worker := timerWorker{timer: *o.timer, done: *o.done}
49- *o.timer = nil
50- if *o.cancel != nil {
51- close(*o.cancel)
52- *o.cancel = nil
53 }37 }
54- return worker38+ return token
55 }39 }
56 40
57-// publishLocked assigns a timer created and inspected outside the owner lock.41+func (l *timerLane) publishLocked(generation uint64, timer ports.Timer) *timerToken {
58-// It rejects a stale ticket or an already-replaced timer without invoking a42+ if l.generation != generation || l.token != nil {
59-// port method.43+ return nil
60-func (o timerOwnership) publishLocked(ticket uint64, timer ports.Timer) (cancel, done chan struct{}, ok bool) {
61- if *o.generation != ticket || *o.timer != nil {
62- return nil, nil, false
63 }44 }
64- cancel, done = make(chan struct{}), make(chan struct{})45+ token := &timerToken{
65- *o.timer, *o.cancel, *o.done = timer, cancel, done46+ generation: generation,
66- return cancel, done, true47+ timer: timer,
67-}48+ cancel: make(chan struct{}),
68-49+ done: make(chan struct{}),
69-// clearLocked releases only the matching ticket after its worker has consumed
70-// a tick. It cannot clear a replacement timer.
71-func (o timerOwnership) clearLocked(ticket uint64) bool {
72- if *o.generation != ticket {
73- return false
74 }50 }
75- *o.timer, *o.cancel = nil, nil51+ l.token = token
76- return true52+ return token
77 }53 }
78 54
79-// completeLocked records a completed no-worker timer path (such as a disabled55+func (l *timerLane) clearLocked(token *timerToken) bool {
80-// clock) without overwriting a replacement lane's completion edge.56+ if token == nil || l.generation != token.generation || l.token != token {
81-func (o timerOwnership) completeLocked(ticket uint64) bool {
82- if *o.generation != ticket {
83 return false57 return false
84 }58 }
85- done := make(chan struct{})59+ l.token = nil
86- close(done)
87- *o.done = done
88 return true60 return true
89 }61 }
90 62
91-// runTimerWorker owns the completion edge for an armed timer. A nil timer63+// timerSupervisor owns worker accounting. Workers are registered only while
92-// channel intentionally does not start a worker; callers apply their64+// coordinator ownership is held and terminal teardown rejects registrations
93-// domain-specific disabled-clock behavior synchronously.65+// before Wait begins, so Add cannot race the terminal Wait.
94-func runTimerWorker(timerC <-chan time.Time, cancel <-chan struct{}, done chan struct{}, fire func()) {66+type timerSupervisor struct{ workers sync.WaitGroup }
67+
68+func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69+ s.workers.Add(1)
95 go func() {70 go func() {
96- defer close(done)71+ defer s.workers.Done()
72+ defer close(token.done)
97 select {73 select {
98 case <-timerC:74 case <-timerC:
99 fire()75 fire()
100- case <-cancel:76+ case <-token.cancel:
101 }77 }
102 }()78 }()
103 }79 }
104 80
105-func stopTimer(timer ports.Timer) {81+func (s *timerSupervisor) wait() { s.workers.Wait() }
106- if timer != nil {
107- timer.Stop()
108- }
109-}
110-
111-// stopAndJoinTimerWorker prevents a cancelled timer callback from escaping a
112-// lifecycle boundary and touching transports or test-owned timer mocks later.
113-// Callers must not hold coordinator locks here. self is supplied only by a
114-// worker that consumed its own tick; waiting for that worker would deadlock.
115-func stopAndJoinTimerWorker(worker timerWorker, self <-chan struct{}) {
116- stopTimer(worker.timer)
117- if worker.done != nil && worker.done != self {
118- <-worker.done
119- }
120-}
121 82
122-func stopAndJoinTimerWorkers(workers []timerWorker) {83+// stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
123- for _, worker := range workers {84+// replacement transitions may cancel/stop stale workers but callback stacks
124- stopAndJoinTimerWorker(worker, nil)85+// never wait for a worker to finish.
86+func stopDetachedTimer(token *timerToken) {
87+ if token != nil && token.timer != nil {
88+ token.timer.Stop()
125 }89 }
126 }90 }
M internal/usecase/daemon/render_timer_test.go +129 -39
@@ -6,49 +6,139 @@ import (
66
77 "github.com/stretchr/testify/require"
88
9- "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/domain"
1010 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1111 )
1212
13-func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {
14- var timer ports.Timer
15- var cancel, done chan struct{}
16- var generation uint64
17- owner := timerOwner(&timer, &cancel, &done, &generation)
18-
19- ticket, stopped := owner.replaceLocked()
20- require.Nil(t, stopped.timer)
21- first := portsmocks.NewMockTimer(t)
22- first.EXPECT().Stop().Return(true).Once()
23- firstCancel, firstDone, ok := owner.publishLocked(ticket, first)
24- require.True(t, ok)
25-
26- // A replacement releases a nil-channel worker through cancellation, rather
27- // than relying on Timer.Stop to wake it.
28- nilTimerC := (<-chan time.Time)(nil)
29- runTimerWorker(nilTimerC, firstCancel, firstDone, func() { t.Fatal("cancelled worker fired") })
30- _, stopped = owner.replaceLocked()
31- require.Same(t, first, stopped.timer)
32- stopAndJoinTimerWorker(stopped, nil)
33-
34- _, _, ok = owner.publishLocked(ticket, portsmocks.NewMockTimer(t))
35- require.False(t, ok, "a stale generation must not reclaim a replacement lane")
36-
37- require.True(t, owner.completeLocked(generation))
38- awaitTestCompletion(t, done, "timer worker did not complete")
39-}
40-
41-func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) {
42- timerC := make(chan time.Time, 1)
43- cancel := make(chan struct{})
44- done := make(chan struct{})
45- fired := make(chan struct{}, 1)
46- runTimerWorker(timerC, cancel, done, func() { fired <- struct{}{} })
47- timerC <- time.Time{}
48- awaitTestCompletion(t, done, "timer worker did not complete")
13+func TestTimerLaneDetachRemovesCompleteToken(t *testing.T) {
14+ lane := timerLane{generation: 1}
15+ timer := portsmocks.NewMockTimer(t)
16+ token := lane.publishLocked(1, timer)
17+ require.NotNil(t, token)
18+ detached := lane.detachLocked()
19+ require.Same(t, token, detached)
20+ require.Nil(t, lane.token)
4921 select {
50- case <-fired:
22+ case <-token.cancel:
5123 default:
52- t.Fatal("timer worker did not run callback")
24+ t.Fatal("detach did not cancel token")
25+ }
26+ require.Nil(t, lane.publishLocked(0, timer), "stale generation reacquired detached lane")
27+}
28+
29+func TestRenderDeadlineCallbackCanInvalidateAgainWithoutJoiningItself(t *testing.T) {
30+ clock := newCoordinatorMockClock(t, 2)
31+ ac := &attachedClient{}
32+ wakeReturned := make(chan struct{})
33+ var rc *renderCoordinator
34+ rc = newRenderCoordinator(renderCoordinatorOptions{
35+ clock: clock.clock,
36+ wake: func(renderWake) {
37+ require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "reentrant-wake-test"}))
38+ close(wakeReturned)
39+ },
40+ })
41+ rc.attach(ac)
42+ t.Cleanup(func() {
43+ rc.beginSessionTeardown().finish()
44+ rc.waitForTimerWorkers()
45+ })
46+ require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "initial-wake-test"}))
47+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
48+ awaitTestCompletion(t, wakeReturned, "timer callback deadlocked while publishing its next invalidation")
49+}
50+
51+func TestRenderTimerCallbacksCanReplaceTheirOwnLane(t *testing.T) {
52+ cases := []struct {
53+ name string
54+ run func(*testing.T, *renderCoordinator, *coordinatorMockClock, *attachedClient)
55+ }{
56+ {
57+ name: "normal wake invalidates", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
58+ woke := make(chan struct{})
59+ rc.opts.wake = func(renderWake) {
60+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"})
61+ close(woke)
62+ }
63+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"})
64+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
65+ awaitTestCompletion(t, woke, "normal callback did not reenter invalidation")
66+ },
67+ },
68+ {
69+ name: "resize callback schedules resize", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
70+ lease := rc.attachmentLease(ac)
71+ rc.scheduleResizeForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease, func(uint64) {
72+ rc.scheduleResizeForLease(domain.Size{Cols: 81, Rows: 24}, ac, lease, func(uint64) {})
73+ })
74+ timer := awaitCoordinatorScheduledTimer(t, clock)
75+ done := captureResizeCallbackDone(t, rc)
76+ timer.ch <- time.Time{}
77+ _ = awaitTestValue(t, clock.timers, "resize callback did not schedule replacement")
78+ awaitTestCompletion(t, done, "resize callback did not complete")
79+ },
80+ },
81+ {
82+ name: "retry callback schedules retry", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
83+ lease := rc.attachmentLease(ac)
84+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
85+ require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
86+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { rc.scheduleResizeRetryForLease(epoch, ac, lease, func() {}) })
87+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
88+ _ = awaitTestValue(t, clock.timers, "retry callback did not schedule replacement")
89+ },
90+ },
91+ {
92+ name: "sync force transitions sync generation", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, _ *attachedClient) {
93+ p := &pane{}
94+ rc.noteSyncBegin(p, 1, func() { rc.noteSyncBegin(p, 2) })
95+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
96+ _ = awaitTestValue(t, clock.timers, "sync force did not schedule replacement")
97+ },
98+ },
5399 }
100+ for _, tc := range cases {
101+ t.Run(tc.name, func(t *testing.T) {
102+ clock := newCoordinatorMockClock(t, 4)
103+ rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{}
104+ rc.attach(ac)
105+ tc.run(t, rc, clock, ac)
106+ rc.beginSessionTeardown().finish()
107+ rc.waitForTimerWorkers()
108+ })
109+ }
110+}
111+
112+func TestRenderTimerScheduleRacesTerminalTeardown(t *testing.T) {
113+ for range 32 {
114+ clock := newCoordinatorMockClock(t, 2)
115+ rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{}
116+ rc.attach(ac)
117+ start := make(chan struct{})
118+ scheduled := make(chan struct{})
119+ go func() {
120+ <-start
121+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "race"})
122+ close(scheduled)
123+ }()
124+ close(start)
125+ cleanup := rc.beginSessionTeardown()
126+ awaitTestCompletion(t, scheduled, "schedule raced terminal teardown")
127+ cleanup.finish()
128+ rc.waitForTimerWorkers()
129+ rc.mu.Lock()
130+ require.True(t, rc.torndown)
131+ require.Nil(t, rc.normalLane.token)
132+ rc.mu.Unlock()
133+ }
134+}
135+
136+func TestTimerSupervisorCancelsNeverFiringWorker(t *testing.T) {
137+ var lane timerLane
138+ generation, _ := lane.replaceLocked()
139+ token := lane.publishLocked(generation, portsmocks.NewMockTimer(t))
140+ var supervisor timerSupervisor
141+ supervisor.startLocked(token, nil, func() { t.Fatal("cancelled worker fired") })
142+ lane.detachLocked()
143+ supervisor.wait()
54144 }
@@ -6,49 +6,139 @@ import (
6 6
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9- "github.com/bnema/vev/internal/ports"9+ "github.com/bnema/vev/internal/domain"
10 portsmocks "github.com/bnema/vev/internal/ports/mocks"10 portsmocks "github.com/bnema/vev/internal/ports/mocks"
11 )11 )
12 12
13-func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {13+func TestTimerLaneDetachRemovesCompleteToken(t *testing.T) {
14- var timer ports.Timer14+ lane := timerLane{generation: 1}
15- var cancel, done chan struct{}15+ timer := portsmocks.NewMockTimer(t)
16- var generation uint6416+ token := lane.publishLocked(1, timer)
17- owner := timerOwner(&timer, &cancel, &done, &generation)17+ require.NotNil(t, token)
18-18+ detached := lane.detachLocked()
19- ticket, stopped := owner.replaceLocked()19+ require.Same(t, token, detached)
20- require.Nil(t, stopped.timer)20+ require.Nil(t, lane.token)
21- first := portsmocks.NewMockTimer(t)
22- first.EXPECT().Stop().Return(true).Once()
23- firstCancel, firstDone, ok := owner.publishLocked(ticket, first)
24- require.True(t, ok)
25-
26- // A replacement releases a nil-channel worker through cancellation, rather
27- // than relying on Timer.Stop to wake it.
28- nilTimerC := (<-chan time.Time)(nil)
29- runTimerWorker(nilTimerC, firstCancel, firstDone, func() { t.Fatal("cancelled worker fired") })
30- _, stopped = owner.replaceLocked()
31- require.Same(t, first, stopped.timer)
32- stopAndJoinTimerWorker(stopped, nil)
33-
34- _, _, ok = owner.publishLocked(ticket, portsmocks.NewMockTimer(t))
35- require.False(t, ok, "a stale generation must not reclaim a replacement lane")
36-
37- require.True(t, owner.completeLocked(generation))
38- awaitTestCompletion(t, done, "timer worker did not complete")
39-}
40-
41-func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) {
42- timerC := make(chan time.Time, 1)
43- cancel := make(chan struct{})
44- done := make(chan struct{})
45- fired := make(chan struct{}, 1)
46- runTimerWorker(timerC, cancel, done, func() { fired <- struct{}{} })
47- timerC <- time.Time{}
48- awaitTestCompletion(t, done, "timer worker did not complete")
49 select {21 select {
50- case <-fired:22+ case <-token.cancel:
51 default:23 default:
52- t.Fatal("timer worker did not run callback")24+ t.Fatal("detach did not cancel token")
25+ }
26+ require.Nil(t, lane.publishLocked(0, timer), "stale generation reacquired detached lane")
27+}
28+
29+func TestRenderDeadlineCallbackCanInvalidateAgainWithoutJoiningItself(t *testing.T) {
30+ clock := newCoordinatorMockClock(t, 2)
31+ ac := &attachedClient{}
32+ wakeReturned := make(chan struct{})
33+ var rc *renderCoordinator
34+ rc = newRenderCoordinator(renderCoordinatorOptions{
35+ clock: clock.clock,
36+ wake: func(renderWake) {
37+ require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "reentrant-wake-test"}))
38+ close(wakeReturned)
39+ },
40+ })
41+ rc.attach(ac)
42+ t.Cleanup(func() {
43+ rc.beginSessionTeardown().finish()
44+ rc.waitForTimerWorkers()
45+ })
46+ require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "initial-wake-test"}))
47+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
48+ awaitTestCompletion(t, wakeReturned, "timer callback deadlocked while publishing its next invalidation")
49+}
50+
51+func TestRenderTimerCallbacksCanReplaceTheirOwnLane(t *testing.T) {
52+ cases := []struct {
53+ name string
54+ run func(*testing.T, *renderCoordinator, *coordinatorMockClock, *attachedClient)
55+ }{
56+ {
57+ name: "normal wake invalidates", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
58+ woke := make(chan struct{})
59+ rc.opts.wake = func(renderWake) {
60+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"})
61+ close(woke)
62+ }
63+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"})
64+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
65+ awaitTestCompletion(t, woke, "normal callback did not reenter invalidation")
66+ },
67+ },
68+ {
69+ name: "resize callback schedules resize", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
70+ lease := rc.attachmentLease(ac)
71+ rc.scheduleResizeForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease, func(uint64) {
72+ rc.scheduleResizeForLease(domain.Size{Cols: 81, Rows: 24}, ac, lease, func(uint64) {})
73+ })
74+ timer := awaitCoordinatorScheduledTimer(t, clock)
75+ done := captureResizeCallbackDone(t, rc)
76+ timer.ch <- time.Time{}
77+ _ = awaitTestValue(t, clock.timers, "resize callback did not schedule replacement")
78+ awaitTestCompletion(t, done, "resize callback did not complete")
79+ },
80+ },
81+ {
82+ name: "retry callback schedules retry", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
83+ lease := rc.attachmentLease(ac)
84+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
85+ require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
86+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { rc.scheduleResizeRetryForLease(epoch, ac, lease, func() {}) })
87+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
88+ _ = awaitTestValue(t, clock.timers, "retry callback did not schedule replacement")
89+ },
90+ },
91+ {
92+ name: "sync force transitions sync generation", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, _ *attachedClient) {
93+ p := &pane{}
94+ rc.noteSyncBegin(p, 1, func() { rc.noteSyncBegin(p, 2) })
95+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
96+ _ = awaitTestValue(t, clock.timers, "sync force did not schedule replacement")
97+ },
98+ },
53 }99 }
100+ for _, tc := range cases {
101+ t.Run(tc.name, func(t *testing.T) {
102+ clock := newCoordinatorMockClock(t, 4)
103+ rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{}
104+ rc.attach(ac)
105+ tc.run(t, rc, clock, ac)
106+ rc.beginSessionTeardown().finish()
107+ rc.waitForTimerWorkers()
108+ })
109+ }
110+}
111+
112+func TestRenderTimerScheduleRacesTerminalTeardown(t *testing.T) {
113+ for range 32 {
114+ clock := newCoordinatorMockClock(t, 2)
115+ rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{}
116+ rc.attach(ac)
117+ start := make(chan struct{})
118+ scheduled := make(chan struct{})
119+ go func() {
120+ <-start
121+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "race"})
122+ close(scheduled)
123+ }()
124+ close(start)
125+ cleanup := rc.beginSessionTeardown()
126+ awaitTestCompletion(t, scheduled, "schedule raced terminal teardown")
127+ cleanup.finish()
128+ rc.waitForTimerWorkers()
129+ rc.mu.Lock()
130+ require.True(t, rc.torndown)
131+ require.Nil(t, rc.normalLane.token)
132+ rc.mu.Unlock()
133+ }
134+}
135+
136+func TestTimerSupervisorCancelsNeverFiringWorker(t *testing.T) {
137+ var lane timerLane
138+ generation, _ := lane.replaceLocked()
139+ token := lane.publishLocked(generation, portsmocks.NewMockTimer(t))
140+ var supervisor timerSupervisor
141+ supervisor.startLocked(token, nil, func() { t.Fatal("cancelled worker fired") })
142+ lane.detachLocked()
143+ supervisor.wait()
54 }144 }
M internal/usecase/daemon/resume_test.go +1 -1
@@ -167,7 +167,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
167167 timer := awaitLatestCoordinatorTimer(t, clock)
168168 rc := sess.renderCoordinator()
169169 rc.mu.Lock()
170- workerDone := rc.normalWorkerDone
170+ workerDone := rc.normalLane.token.done
171171 rc.mu.Unlock()
172172 require.NotNil(t, workerDone)
173173 timer.ch <- time.Time{}
@@ -167,7 +167,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
167 timer := awaitLatestCoordinatorTimer(t, clock)167 timer := awaitLatestCoordinatorTimer(t, clock)
168 rc := sess.renderCoordinator()168 rc := sess.renderCoordinator()
169 rc.mu.Lock()169 rc.mu.Lock()
170- workerDone := rc.normalWorkerDone170+ workerDone := rc.normalLane.token.done
171 rc.mu.Unlock()171 rc.mu.Unlock()
172 require.NotNil(t, workerDone)172 require.NotNil(t, workerDone)
173 timer.ch <- time.Time{}173 timer.ch <- time.Time{}
M internal/usecase/daemon/session.go +5 -1
@@ -749,7 +749,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
749749 sess.client = nil
750750 sess.mu.Unlock()
751751 if rc := sess.renderCoordinator(); rc != nil {
752- rc.noteSessionTeardown()
752+ // Terminal teardown has two phases: this session owner first prevents any
753+ // new worker registration and detaches all tokens, then stops and waits
754+ // outside coordinator callbacks and session locks.
755+ rc.beginSessionTeardown().finish()
756+ rc.waitForTimerWorkers()
753757 }
754758 if ac != nil {
755759 d.unregisterPreview(ac)
@@ -749,7 +749,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
749 sess.client = nil749 sess.client = nil
750 sess.mu.Unlock()750 sess.mu.Unlock()
751 if rc := sess.renderCoordinator(); rc != nil {751 if rc := sess.renderCoordinator(); rc != nil {
752- rc.noteSessionTeardown()752+ // Terminal teardown has two phases: this session owner first prevents any
753+ // new worker registration and detaches all tokens, then stops and waits
754+ // outside coordinator callbacks and session locks.
755+ rc.beginSessionTeardown().finish()
756+ rc.waitForTimerWorkers()
753 }757 }
754 if ac != nil {758 if ac != nil {
755 d.unregisterPreview(ac)759 d.unregisterPreview(ac)
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -103,7 +103,7 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
103103 sess.installRenderCoordinator(rc)
104104 rc.attach(ac)
105105 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
106- t.Cleanup(rc.noteSessionTeardown)
106+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
107107
108108 markSnapshotDirty(sess)
109109 require.True(t, d.scheduleSnapshot(sess))
@@ -103,7 +103,7 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
103 sess.installRenderCoordinator(rc)103 sess.installRenderCoordinator(rc)
104 rc.attach(ac)104 rc.attach(ac)
105 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))105 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
106- t.Cleanup(rc.noteSessionTeardown)106+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
107 107
108 markSnapshotDirty(sess)108 markSnapshotDirty(sess)
109 require.True(t, d.scheduleSnapshot(sess))109 require.True(t, d.scheduleSnapshot(sess))
M internal/usecase/daemon/transactional_resize_test.go +2 -1
@@ -344,10 +344,11 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
344344 for len(timers) < 3 {
345345 timers = append(timers, <-clock.timers)
346346 }
347+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
347348 for _, timer := range timers {
348349 timer.ch <- time.Time{}
349350 }
350- awaitResizeCallback(t, sess.renderCoordinator())
351+ awaitTestCompletion(t, done, "latest resize callback did not complete")
351352 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
352353 snapshot := sess.renderCoordinator().resizeSnapshot()
353354 require.Equal(t, uint64(3), snapshot.epoch)
@@ -344,10 +344,11 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
344 for len(timers) < 3 {344 for len(timers) < 3 {
345 timers = append(timers, <-clock.timers)345 timers = append(timers, <-clock.timers)
346 }346 }
347+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
347 for _, timer := range timers {348 for _, timer := range timers {
348 timer.ch <- time.Time{}349 timer.ch <- time.Time{}
349 }350 }
350- awaitResizeCallback(t, sess.renderCoordinator())351+ awaitTestCompletion(t, done, "latest resize callback did not complete")
351 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())352 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
352 snapshot := sess.renderCoordinator().resizeSnapshot()353 snapshot := sess.renderCoordinator().resizeSnapshot()
353 require.Equal(t, uint64(3), snapshot.epoch)354 require.Equal(t, uint64(3), snapshot.epoch)
M internal/usecase/daemon/transport_observability_test.go +6 -6
@@ -178,13 +178,13 @@ func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
178178 name: "successful consume",
179179 finish: func(rc *renderCoordinator, ready *bool) {
180180 *ready = true
181- rc.fire(rc.generation, false, false)
181+ rc.fire(rc.normalLane.generation, false, false)
182182 },
183183 },
184184 {
185185 name: "teardown",
186186 finish: func(rc *renderCoordinator, ready *bool) {
187- rc.noteSessionTeardown()
187+ rc.beginSessionTeardown().finish()
188188 },
189189 },
190190 }
@@ -198,7 +198,7 @@ func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
198198 })
199199 rc.attach(&attachedClient{})
200200 rc.invalidate(renderInvalidation{class: invalidateOutput})
201- rc.fire(rc.generation, false, true)
201+ rc.fire(rc.normalLane.generation, false, true)
202202 tt.finish(rc, &ready)
203203 // A late notification after consuming or tearing down must not emit a
204204 // second end for the already-cleared blocked interval.
@@ -247,7 +247,7 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
247247
248248 // Enter the ACK-blocked state and wait until Start has reached the injected
249249 // observer. End is the only deliberately blocked call in this test.
250- rc.fire(rc.generation, false, true)
250+ rc.fire(rc.normalLane.generation, false, true)
251251 awaitDaemonObserver(t, observer.startObserved, "ACK-blocked start observer")
252252
253253 ready.Store(true)
@@ -295,7 +295,7 @@ func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing
295295 rc.attach(&attachedClient{})
296296 rc.mu.Lock()
297297 rc.pending = true
298- gen := rc.generation
298+ gen := rc.normalLane.generation
299299 rc.mu.Unlock()
300300
301301 fired := make(chan struct{})
@@ -357,7 +357,7 @@ func TestTransportObservabilityACKBlockedStartPublishesBeforeEnd(t *testing.T) {
357357 })
358358 rc.mu.Lock()
359359 rc.pending = true
360- gen := rc.generation
360+ gen := rc.normalLane.generation
361361 rc.mu.Unlock()
362362
363363 fireDone := make(chan struct{})
@@ -178,13 +178,13 @@ func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
178 name: "successful consume",178 name: "successful consume",
179 finish: func(rc *renderCoordinator, ready *bool) {179 finish: func(rc *renderCoordinator, ready *bool) {
180 *ready = true180 *ready = true
181- rc.fire(rc.generation, false, false)181+ rc.fire(rc.normalLane.generation, false, false)
182 },182 },
183 },183 },
184 {184 {
185 name: "teardown",185 name: "teardown",
186 finish: func(rc *renderCoordinator, ready *bool) {186 finish: func(rc *renderCoordinator, ready *bool) {
187- rc.noteSessionTeardown()187+ rc.beginSessionTeardown().finish()
188 },188 },
189 },189 },
190 }190 }
@@ -198,7 +198,7 @@ func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
198 })198 })
199 rc.attach(&attachedClient{})199 rc.attach(&attachedClient{})
200 rc.invalidate(renderInvalidation{class: invalidateOutput})200 rc.invalidate(renderInvalidation{class: invalidateOutput})
201- rc.fire(rc.generation, false, true)201+ rc.fire(rc.normalLane.generation, false, true)
202 tt.finish(rc, &ready)202 tt.finish(rc, &ready)
203 // A late notification after consuming or tearing down must not emit a203 // A late notification after consuming or tearing down must not emit a
204 // second end for the already-cleared blocked interval.204 // second end for the already-cleared blocked interval.
@@ -247,7 +247,7 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
247 247
248 // Enter the ACK-blocked state and wait until Start has reached the injected248 // Enter the ACK-blocked state and wait until Start has reached the injected
249 // observer. End is the only deliberately blocked call in this test.249 // observer. End is the only deliberately blocked call in this test.
250- rc.fire(rc.generation, false, true)250+ rc.fire(rc.normalLane.generation, false, true)
251 awaitDaemonObserver(t, observer.startObserved, "ACK-blocked start observer")251 awaitDaemonObserver(t, observer.startObserved, "ACK-blocked start observer")
252 252
253 ready.Store(true)253 ready.Store(true)
@@ -295,7 +295,7 @@ func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing
295 rc.attach(&attachedClient{})295 rc.attach(&attachedClient{})
296 rc.mu.Lock()296 rc.mu.Lock()
297 rc.pending = true297 rc.pending = true
298- gen := rc.generation298+ gen := rc.normalLane.generation
299 rc.mu.Unlock()299 rc.mu.Unlock()
300 300
301 fired := make(chan struct{})301 fired := make(chan struct{})
@@ -357,7 +357,7 @@ func TestTransportObservabilityACKBlockedStartPublishesBeforeEnd(t *testing.T) {
357 })357 })
358 rc.mu.Lock()358 rc.mu.Lock()
359 rc.pending = true359 rc.pending = true
360- gen := rc.generation360+ gen := rc.normalLane.generation
361 rc.mu.Unlock()361 rc.mu.Unlock()
362 362
363 fireDone := make(chan struct{})363 fireDone := make(chan struct{})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close