detached ea71bd4

fix(daemon): stabilize render coordinator lifecycle

commit
ea71bd49101d60292661bdd8d91740ed3d31e875
author
brice · · 2026-07-11 16:02 UTC
parent
014aa3c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +226 -13
M internal/usecase/daemon/copymode_test.go +38 -3
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "runtime"
89 "strings"
910 "sync"
1011 "testing"
@@ -385,11 +386,45 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
385386 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386387 p.EXPECT().Pid().Return(4242).Maybe()
387388
388- d := newTestDaemon(t, newFactory(t, p), stubClock{})
389+ // Rendering is coordinator-driven: advance a controllable clock through the
390+ // retained resize timer and its resulting coordinator wake rather than
391+ // relying on wall-clock debounce delivery.
392+ clk := &signalClock{timers: make(chan *signalTimer, 16)}
393+ d := newTestDaemon(t, newFactory(t, p), clk)
389394 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
395+ advanceRender := func() {
396+ for range 4096 {
397+ select {
398+ case timer := <-clk.timers:
399+ timer.ch <- time.Time{}
400+ return
401+ default:
402+ runtime.Gosched()
403+ }
404+ }
405+ t.Fatal("coordinator did not arm a controllable render timer")
406+ }
407+ awaitControlledOutput := func() ports.Frame {
408+ for range 4096 {
409+ select {
410+ case frame := <-sends:
411+ if frame.Type == ports.MsgOutput {
412+ return frame
413+ }
414+ t.Fatalf("unexpected frame type %d while advancing render clock", frame.Type)
415+ case timer := <-clk.timers:
416+ timer.ch <- time.Time{}
417+ default:
418+ runtime.Gosched()
419+ }
420+ }
421+ t.Fatal("controllable timers did not produce an output frame")
422+ return ports.Frame{}
423+ }
390424 var hg sync.WaitGroup
391425 hg.Go(func() { d.handleConn(tr) })
392426 awaitFrame(t, sends, ports.MsgWelcome)
427+ advanceRender() // initial coordinator invalidation
393428 awaitFrame(t, sends, ports.MsgOutput)
394429
395430 for i := range 12 {
@@ -420,9 +455,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
420455 ac := sess.client
421456 require.NotNil(t, ac)
422457 d.handleInput(sess, ac, []byte("\x1b "))
423- awaitFrame(t, sends, ports.MsgOutput)
458+ awaitControlledOutput()
424459 d.handleInput(sess, ac, []byte("VIS\r"))
425- awaitFrame(t, sends, ports.MsgOutput)
460+ awaitControlledOutput()
426461 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
427462
428463 var payload string
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "runtime"
8 "strings"9 "strings"
9 "sync"10 "sync"
10 "testing"11 "testing"
@@ -385,11 +386,45 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
385 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()386 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386 p.EXPECT().Pid().Return(4242).Maybe()387 p.EXPECT().Pid().Return(4242).Maybe()
387 388
388- d := newTestDaemon(t, newFactory(t, p), stubClock{})389+ // Rendering is coordinator-driven: advance a controllable clock through the
390+ // retained resize timer and its resulting coordinator wake rather than
391+ // relying on wall-clock debounce delivery.
392+ clk := &signalClock{timers: make(chan *signalTimer, 16)}
393+ d := newTestDaemon(t, newFactory(t, p), clk)
389 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))394 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
395+ advanceRender := func() {
396+ for range 4096 {
397+ select {
398+ case timer := <-clk.timers:
399+ timer.ch <- time.Time{}
400+ return
401+ default:
402+ runtime.Gosched()
403+ }
404+ }
405+ t.Fatal("coordinator did not arm a controllable render timer")
406+ }
407+ awaitControlledOutput := func() ports.Frame {
408+ for range 4096 {
409+ select {
410+ case frame := <-sends:
411+ if frame.Type == ports.MsgOutput {
412+ return frame
413+ }
414+ t.Fatalf("unexpected frame type %d while advancing render clock", frame.Type)
415+ case timer := <-clk.timers:
416+ timer.ch <- time.Time{}
417+ default:
418+ runtime.Gosched()
419+ }
420+ }
421+ t.Fatal("controllable timers did not produce an output frame")
422+ return ports.Frame{}
423+ }
390 var hg sync.WaitGroup424 var hg sync.WaitGroup
391 hg.Go(func() { d.handleConn(tr) })425 hg.Go(func() { d.handleConn(tr) })
392 awaitFrame(t, sends, ports.MsgWelcome)426 awaitFrame(t, sends, ports.MsgWelcome)
427+ advanceRender() // initial coordinator invalidation
393 awaitFrame(t, sends, ports.MsgOutput)428 awaitFrame(t, sends, ports.MsgOutput)
394 429
395 for i := range 12 {430 for i := range 12 {
@@ -420,9 +455,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
420 ac := sess.client455 ac := sess.client
421 require.NotNil(t, ac)456 require.NotNil(t, ac)
422 d.handleInput(sess, ac, []byte("\x1b "))457 d.handleInput(sess, ac, []byte("\x1b "))
423- awaitFrame(t, sends, ports.MsgOutput)458+ awaitControlledOutput()
424 d.handleInput(sess, ac, []byte("VIS\r"))459 d.handleInput(sess, ac, []byte("VIS\r"))
425- awaitFrame(t, sends, ports.MsgOutput)460+ awaitControlledOutput()
426 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})461 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
427 462
428 var payload string463 var payload string
M internal/usecase/daemon/render.go +4 -1
@@ -182,7 +182,6 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
182182 return
183183 }
184184
185- ac.initOverlays()
186185 ac.sendMu.Lock()
187186 if ac.currentSession() != sess {
188187 ac.sendMu.Unlock()
@@ -192,6 +191,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
192191 ac.sendMu.Unlock()
193192 return
194193 }
194+ // Composition owns attachment sendMu; initialize its lazy overlay state
195+ // under that same ownership so concurrent fallback paints cannot observe a
196+ // partially published runtime.
197+ ac.initOverlays()
195198 if ac.resizePaintPending {
196199 ac.resizePaint.stop()
197200 ac.resizePaintPending = false
@@ -182,7 +182,6 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
182 return182 return
183 }183 }
184 184
185- ac.initOverlays()
186 ac.sendMu.Lock()185 ac.sendMu.Lock()
187 if ac.currentSession() != sess {186 if ac.currentSession() != sess {
188 ac.sendMu.Unlock()187 ac.sendMu.Unlock()
@@ -192,6 +191,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
192 ac.sendMu.Unlock()191 ac.sendMu.Unlock()
193 return192 return
194 }193 }
194+ // Composition owns attachment sendMu; initialize its lazy overlay state
195+ // under that same ownership so concurrent fallback paints cannot observe a
196+ // partially published runtime.
197+ ac.initOverlays()
195 if ac.resizePaintPending {198 if ac.resizePaintPending {
196 ac.resizePaint.stop()199 ac.resizePaint.stop()
197 ac.resizePaintPending = false200 ac.resizePaintPending = false
M internal/usecase/daemon/render_coordinator.go +74 -8
@@ -115,10 +115,19 @@ type renderCoordinator struct {
115115 resize resizeRequestMetadata
116116 metrics renderCoordinatorBurstMetrics
117117 // generation invalidates callbacks from superseded deadline/watchdog timers.
118- generation uint64
119- armed bool
120- syncGeneration uint64
121- syncWatchdog func()
118+ // Each worker also owns an explicit cancellation channel: fake timers may
119+ // expose a nil or never-firing C channel, so Stop alone cannot release a
120+ // worker waiting in select.
121+ generation uint64
122+ armed bool
123+ normalTimer ports.Timer
124+ normalCancel chan struct{}
125+ normalWorkerDone chan struct{}
126+ syncGeneration uint64
127+ syncWatchdog func()
128+ syncTimer ports.Timer
129+ syncCancel chan struct{}
130+ syncWorkerDone chan struct{}
122131 }
123132
124133 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -127,6 +136,30 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
127136 return &renderCoordinator{opts: opts}
128137 }
129138
139+// cancelNormalTimerLocked and cancelSyncTimerLocked release timer workers even
140+// when a test clock supplies an inert channel. Caller holds c.mu.
141+func (c *renderCoordinator) cancelNormalTimerLocked() {
142+ if c.normalTimer != nil {
143+ c.normalTimer.Stop()
144+ c.normalTimer = nil
145+ }
146+ if c.normalCancel != nil {
147+ close(c.normalCancel)
148+ c.normalCancel = nil
149+ }
150+}
151+
152+func (c *renderCoordinator) cancelSyncTimerLocked() {
153+ if c.syncTimer != nil {
154+ c.syncTimer.Stop()
155+ c.syncTimer = nil
156+ }
157+ if c.syncCancel != nil {
158+ close(c.syncCancel)
159+ c.syncCancel = nil
160+ }
161+}
162+
130163 // invalidate publishes one producer state transition. Consecutive
131164 // invalidations coalesce under a single armed deadline; the latest state and
132165 // the stickiest reset win.
@@ -149,6 +182,7 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
149182 arm := !wasPending || (!wasUrgent && c.pendingUrgent)
150183 if arm {
151184 c.generation++
185+ c.cancelNormalTimerLocked()
152186 }
153187 gen := c.generation
154188 delay := minOutputRenderDeadline
@@ -157,12 +191,23 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
157191 }
158192 clock := c.opts.clock
159193 c.armed = c.armed || arm
160- c.mu.Unlock()
161194 if !arm || clock == nil {
195+ c.mu.Unlock()
162196 return
163197 }
164198 timer := clock.NewTimer(delay)
165- go func() { <-timer.C(); c.fire(gen, false) }()
199+ cancel := make(chan struct{})
200+ done := make(chan struct{})
201+ c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done
202+ c.mu.Unlock()
203+ go func() {
204+ defer close(done)
205+ select {
206+ case <-timer.C():
207+ c.fire(gen, false)
208+ case <-cancel:
209+ }
210+ }()
166211 // Give fake-clock callbacks a chance to block on C before the test
167212 // advances it; real clocks are unaffected.
168213 runtime.Gosched()
@@ -186,25 +231,36 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
186231 c.mu.Unlock()
187232 return
188233 }
234+ c.cancelSyncTimerLocked()
189235 c.syncGeneration = gen
190236 c.syncWatchdog = nil
191237 if len(force) != 0 {
192238 c.syncWatchdog = force[0]
193239 }
194240 clock := c.opts.clock
195- c.mu.Unlock()
196241 if clock == nil {
242+ c.mu.Unlock()
197243 return
198244 }
199245 timer := clock.NewTimer(maxSyncUpdateDuration)
246+ cancel := make(chan struct{})
247+ done := make(chan struct{})
248+ c.syncTimer, c.syncCancel, c.syncWorkerDone = timer, cancel, done
249+ c.mu.Unlock()
200250 go func() {
201- <-timer.C()
251+ defer close(done)
252+ select {
253+ case <-timer.C():
254+ case <-cancel:
255+ return
256+ }
202257 c.mu.Lock()
203258 valid := !c.torndown && c.syncGeneration == gen
204259 force := c.syncWatchdog
205260 if valid {
206261 c.syncGeneration = 0
207262 c.syncWatchdog = nil
263+ c.cancelSyncTimerLocked()
208264 }
209265 c.mu.Unlock()
210266 if valid {
@@ -226,6 +282,7 @@ func (c *renderCoordinator) noteSyncEnd(gen uint64) {
226282 }
227283 c.syncGeneration = 0
228284 c.syncWatchdog = nil
285+ c.cancelSyncTimerLocked()
229286 c.mu.Unlock()
230287 c.fireCurrent(false)
231288 }
@@ -257,6 +314,9 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
257314 c.attachment = nil
258315 c.pending = false
259316 c.generation++
317+ c.armed = false
318+ c.cancelNormalTimerLocked()
319+ c.cancelSyncTimerLocked()
260320 }
261321 c.mu.Unlock()
262322 }
@@ -269,6 +329,9 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
269329 c.attachment = replacement
270330 c.pending = false
271331 c.generation++
332+ c.armed = false
333+ c.cancelNormalTimerLocked()
334+ c.cancelSyncTimerLocked()
272335 }
273336 c.mu.Unlock()
274337 }
@@ -283,6 +346,9 @@ func (c *renderCoordinator) noteSessionTeardown() {
283346 c.attachment = nil
284347 c.pending = false
285348 c.generation++
349+ c.armed = false
350+ c.cancelNormalTimerLocked()
351+ c.cancelSyncTimerLocked()
286352 c.mu.Unlock()
287353 }
288354
@@ -115,10 +115,19 @@ type renderCoordinator struct {
115 resize resizeRequestMetadata115 resize resizeRequestMetadata
116 metrics renderCoordinatorBurstMetrics116 metrics renderCoordinatorBurstMetrics
117 // generation invalidates callbacks from superseded deadline/watchdog timers.117 // generation invalidates callbacks from superseded deadline/watchdog timers.
118- generation uint64118+ // Each worker also owns an explicit cancellation channel: fake timers may
119- armed bool119+ // expose a nil or never-firing C channel, so Stop alone cannot release a
120- syncGeneration uint64120+ // worker waiting in select.
121- syncWatchdog func()121+ generation uint64
122+ armed bool
123+ normalTimer ports.Timer
124+ normalCancel chan struct{}
125+ normalWorkerDone chan struct{}
126+ syncGeneration uint64
127+ syncWatchdog func()
128+ syncTimer ports.Timer
129+ syncCancel chan struct{}
130+ syncWorkerDone chan struct{}
122 }131 }
123 132
124 // newRenderCoordinator constructs a coordinator bound to opts. It starts no133 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -127,6 +136,30 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
127 return &renderCoordinator{opts: opts}136 return &renderCoordinator{opts: opts}
128 }137 }
129 138
139+// cancelNormalTimerLocked and cancelSyncTimerLocked release timer workers even
140+// when a test clock supplies an inert channel. Caller holds c.mu.
141+func (c *renderCoordinator) cancelNormalTimerLocked() {
142+ if c.normalTimer != nil {
143+ c.normalTimer.Stop()
144+ c.normalTimer = nil
145+ }
146+ if c.normalCancel != nil {
147+ close(c.normalCancel)
148+ c.normalCancel = nil
149+ }
150+}
151+
152+func (c *renderCoordinator) cancelSyncTimerLocked() {
153+ if c.syncTimer != nil {
154+ c.syncTimer.Stop()
155+ c.syncTimer = nil
156+ }
157+ if c.syncCancel != nil {
158+ close(c.syncCancel)
159+ c.syncCancel = nil
160+ }
161+}
162+
130 // invalidate publishes one producer state transition. Consecutive163 // invalidate publishes one producer state transition. Consecutive
131 // invalidations coalesce under a single armed deadline; the latest state and164 // invalidations coalesce under a single armed deadline; the latest state and
132 // the stickiest reset win.165 // the stickiest reset win.
@@ -149,6 +182,7 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
149 arm := !wasPending || (!wasUrgent && c.pendingUrgent)182 arm := !wasPending || (!wasUrgent && c.pendingUrgent)
150 if arm {183 if arm {
151 c.generation++184 c.generation++
185+ c.cancelNormalTimerLocked()
152 }186 }
153 gen := c.generation187 gen := c.generation
154 delay := minOutputRenderDeadline188 delay := minOutputRenderDeadline
@@ -157,12 +191,23 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
157 }191 }
158 clock := c.opts.clock192 clock := c.opts.clock
159 c.armed = c.armed || arm193 c.armed = c.armed || arm
160- c.mu.Unlock()
161 if !arm || clock == nil {194 if !arm || clock == nil {
195+ c.mu.Unlock()
162 return196 return
163 }197 }
164 timer := clock.NewTimer(delay)198 timer := clock.NewTimer(delay)
165- go func() { <-timer.C(); c.fire(gen, false) }()199+ cancel := make(chan struct{})
200+ done := make(chan struct{})
201+ c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done
202+ c.mu.Unlock()
203+ go func() {
204+ defer close(done)
205+ select {
206+ case <-timer.C():
207+ c.fire(gen, false)
208+ case <-cancel:
209+ }
210+ }()
166 // Give fake-clock callbacks a chance to block on C before the test211 // Give fake-clock callbacks a chance to block on C before the test
167 // advances it; real clocks are unaffected.212 // advances it; real clocks are unaffected.
168 runtime.Gosched()213 runtime.Gosched()
@@ -186,25 +231,36 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
186 c.mu.Unlock()231 c.mu.Unlock()
187 return232 return
188 }233 }
234+ c.cancelSyncTimerLocked()
189 c.syncGeneration = gen235 c.syncGeneration = gen
190 c.syncWatchdog = nil236 c.syncWatchdog = nil
191 if len(force) != 0 {237 if len(force) != 0 {
192 c.syncWatchdog = force[0]238 c.syncWatchdog = force[0]
193 }239 }
194 clock := c.opts.clock240 clock := c.opts.clock
195- c.mu.Unlock()
196 if clock == nil {241 if clock == nil {
242+ c.mu.Unlock()
197 return243 return
198 }244 }
199 timer := clock.NewTimer(maxSyncUpdateDuration)245 timer := clock.NewTimer(maxSyncUpdateDuration)
246+ cancel := make(chan struct{})
247+ done := make(chan struct{})
248+ c.syncTimer, c.syncCancel, c.syncWorkerDone = timer, cancel, done
249+ c.mu.Unlock()
200 go func() {250 go func() {
201- <-timer.C()251+ defer close(done)
252+ select {
253+ case <-timer.C():
254+ case <-cancel:
255+ return
256+ }
202 c.mu.Lock()257 c.mu.Lock()
203 valid := !c.torndown && c.syncGeneration == gen258 valid := !c.torndown && c.syncGeneration == gen
204 force := c.syncWatchdog259 force := c.syncWatchdog
205 if valid {260 if valid {
206 c.syncGeneration = 0261 c.syncGeneration = 0
207 c.syncWatchdog = nil262 c.syncWatchdog = nil
263+ c.cancelSyncTimerLocked()
208 }264 }
209 c.mu.Unlock()265 c.mu.Unlock()
210 if valid {266 if valid {
@@ -226,6 +282,7 @@ func (c *renderCoordinator) noteSyncEnd(gen uint64) {
226 }282 }
227 c.syncGeneration = 0283 c.syncGeneration = 0
228 c.syncWatchdog = nil284 c.syncWatchdog = nil
285+ c.cancelSyncTimerLocked()
229 c.mu.Unlock()286 c.mu.Unlock()
230 c.fireCurrent(false)287 c.fireCurrent(false)
231 }288 }
@@ -257,6 +314,9 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
257 c.attachment = nil314 c.attachment = nil
258 c.pending = false315 c.pending = false
259 c.generation++316 c.generation++
317+ c.armed = false
318+ c.cancelNormalTimerLocked()
319+ c.cancelSyncTimerLocked()
260 }320 }
261 c.mu.Unlock()321 c.mu.Unlock()
262 }322 }
@@ -269,6 +329,9 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
269 c.attachment = replacement329 c.attachment = replacement
270 c.pending = false330 c.pending = false
271 c.generation++331 c.generation++
332+ c.armed = false
333+ c.cancelNormalTimerLocked()
334+ c.cancelSyncTimerLocked()
272 }335 }
273 c.mu.Unlock()336 c.mu.Unlock()
274 }337 }
@@ -283,6 +346,9 @@ func (c *renderCoordinator) noteSessionTeardown() {
283 c.attachment = nil346 c.attachment = nil
284 c.pending = false347 c.pending = false
285 c.generation++348 c.generation++
349+ c.armed = false
350+ c.cancelNormalTimerLocked()
351+ c.cancelSyncTimerLocked()
286 c.mu.Unlock()352 c.mu.Unlock()
287 }353 }
288 354
M internal/usecase/daemon/render_coordinator_test.go +108 -0
@@ -4,6 +4,7 @@ import (
44 "os"
55 "path/filepath"
66 "runtime"
7+ "sync"
78 "sync/atomic"
89 "testing"
910 "time"
@@ -638,6 +639,113 @@ func TestProducerInvalidationInventory(t *testing.T) {
638639
639640 // --- retained PR #71 resize dispatch ----------------------------------------------
640641
642+func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
643+ p, release := newBlockingPTY(t)
644+ defer release()
645+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
646+ // Exercise the original seam: two fallback paints reach lazy initialization
647+ // together before either can compose.
648+ ac.overlays = nil
649+ ac.overlayOnce = sync.Once{}
650+ start := make(chan struct{})
651+ var wg sync.WaitGroup
652+ wg.Add(2)
653+ for range 2 {
654+ go func() {
655+ defer wg.Done()
656+ <-start
657+ d.paint(sess, ac, true)
658+ }()
659+ }
660+ close(start)
661+ wg.Wait()
662+ require.NotNil(t, ac.overlays)
663+}
664+
665+func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
666+ p, release := newBlockingPTY(t)
667+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
668+ tb := sess.activeTab()
669+ require.NotNil(t, tb)
670+ d.startPaneGoroutines(sess, tb, tb.focusedPane())
671+ release()
672+ select {
673+ case <-waitGroupDone(&d.sessWg):
674+ case <-time.After(time.Second):
675+ t.Fatal("one reader must balance exactly one WaitGroup count")
676+ }
677+}
678+
679+// inertTimer deliberately has a nil C channel. It verifies coordinator
680+// lifecycle paths do not strand workers behind fake clocks that never fire.
681+type inertTimer struct{ stopped atomic.Bool }
682+
683+func (t *inertTimer) C() <-chan time.Time { return nil }
684+func (t *inertTimer) Reset(time.Duration) bool { return false }
685+func (t *inertTimer) Stop() bool { t.stopped.Store(true); return true }
686+
687+type inertClock struct{ timers chan *inertTimer }
688+
689+func (c *inertClock) Now() time.Time { return time.Time{} }
690+func (c *inertClock) NewTimer(time.Duration) ports.Timer {
691+ t := &inertTimer{}
692+ c.timers <- t
693+ return t
694+}
695+
696+func requireWorkerExit(t *testing.T, done <-chan struct{}) {
697+ t.Helper()
698+ for range 4096 {
699+ select {
700+ case <-done:
701+ return
702+ default:
703+ runtime.Gosched()
704+ }
705+ }
706+ t.Fatal("cancelled coordinator timer worker did not exit")
707+}
708+
709+func TestRenderCoordinatorStopsInertTimerWorkers(t *testing.T) {
710+ lifecycle := []struct {
711+ name string
712+ end func(*renderCoordinator, *attachedClient)
713+ }{
714+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
715+ {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
716+ {"teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},
717+ }
718+ for _, tc := range lifecycle {
719+ t.Run(tc.name, func(t *testing.T) {
720+ clk := &inertClock{timers: make(chan *inertTimer, 2)}
721+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk})
722+ ac := &attachedClient{}
723+ rc.attach(ac)
724+ rc.invalidate(renderInvalidation{class: invalidateOutput})
725+ normal := <-clk.timers
726+ rc.mu.Lock()
727+ normalDone := rc.normalWorkerDone
728+ rc.mu.Unlock()
729+ tc.end(rc, ac)
730+ require.True(t, normal.stopped.Load(), "normal timer must be stopped")
731+ requireWorkerExit(t, normalDone)
732+
733+ // Use a fresh coordinator because detach/teardown intentionally makes
734+ // an attachment ineligible for a new synchronized batch.
735+ rc = newRenderCoordinator(renderCoordinatorOptions{clock: clk})
736+ rc.attach(ac)
737+ rc.noteSyncBegin(1)
738+ syncTimer := <-clk.timers
739+ rc.mu.Lock()
740+ syncDone := rc.syncWorkerDone
741+ rc.mu.Unlock()
742+ tc.end(rc, ac)
743+ require.True(t, syncTimer.stopped.Load(), "sync watchdog must be stopped")
744+ requireWorkerExit(t, syncDone)
745+ })
746+ }
747+}
748+
641749 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
642750 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *signalClock, chan renderInvalidation) {
643751 t.Helper()
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6 "runtime"6 "runtime"
7+ "sync"
7 "sync/atomic"8 "sync/atomic"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -638,6 +639,113 @@ func TestProducerInvalidationInventory(t *testing.T) {
638 639
639 // --- retained PR #71 resize dispatch ----------------------------------------------640 // --- retained PR #71 resize dispatch ----------------------------------------------
640 641
642+func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
643+ p, release := newBlockingPTY(t)
644+ defer release()
645+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
646+ // Exercise the original seam: two fallback paints reach lazy initialization
647+ // together before either can compose.
648+ ac.overlays = nil
649+ ac.overlayOnce = sync.Once{}
650+ start := make(chan struct{})
651+ var wg sync.WaitGroup
652+ wg.Add(2)
653+ for range 2 {
654+ go func() {
655+ defer wg.Done()
656+ <-start
657+ d.paint(sess, ac, true)
658+ }()
659+ }
660+ close(start)
661+ wg.Wait()
662+ require.NotNil(t, ac.overlays)
663+}
664+
665+func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
666+ p, release := newBlockingPTY(t)
667+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
668+ tb := sess.activeTab()
669+ require.NotNil(t, tb)
670+ d.startPaneGoroutines(sess, tb, tb.focusedPane())
671+ release()
672+ select {
673+ case <-waitGroupDone(&d.sessWg):
674+ case <-time.After(time.Second):
675+ t.Fatal("one reader must balance exactly one WaitGroup count")
676+ }
677+}
678+
679+// inertTimer deliberately has a nil C channel. It verifies coordinator
680+// lifecycle paths do not strand workers behind fake clocks that never fire.
681+type inertTimer struct{ stopped atomic.Bool }
682+
683+func (t *inertTimer) C() <-chan time.Time { return nil }
684+func (t *inertTimer) Reset(time.Duration) bool { return false }
685+func (t *inertTimer) Stop() bool { t.stopped.Store(true); return true }
686+
687+type inertClock struct{ timers chan *inertTimer }
688+
689+func (c *inertClock) Now() time.Time { return time.Time{} }
690+func (c *inertClock) NewTimer(time.Duration) ports.Timer {
691+ t := &inertTimer{}
692+ c.timers <- t
693+ return t
694+}
695+
696+func requireWorkerExit(t *testing.T, done <-chan struct{}) {
697+ t.Helper()
698+ for range 4096 {
699+ select {
700+ case <-done:
701+ return
702+ default:
703+ runtime.Gosched()
704+ }
705+ }
706+ t.Fatal("cancelled coordinator timer worker did not exit")
707+}
708+
709+func TestRenderCoordinatorStopsInertTimerWorkers(t *testing.T) {
710+ lifecycle := []struct {
711+ name string
712+ end func(*renderCoordinator, *attachedClient)
713+ }{
714+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
715+ {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
716+ {"teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},
717+ }
718+ for _, tc := range lifecycle {
719+ t.Run(tc.name, func(t *testing.T) {
720+ clk := &inertClock{timers: make(chan *inertTimer, 2)}
721+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk})
722+ ac := &attachedClient{}
723+ rc.attach(ac)
724+ rc.invalidate(renderInvalidation{class: invalidateOutput})
725+ normal := <-clk.timers
726+ rc.mu.Lock()
727+ normalDone := rc.normalWorkerDone
728+ rc.mu.Unlock()
729+ tc.end(rc, ac)
730+ require.True(t, normal.stopped.Load(), "normal timer must be stopped")
731+ requireWorkerExit(t, normalDone)
732+
733+ // Use a fresh coordinator because detach/teardown intentionally makes
734+ // an attachment ineligible for a new synchronized batch.
735+ rc = newRenderCoordinator(renderCoordinatorOptions{clock: clk})
736+ rc.attach(ac)
737+ rc.noteSyncBegin(1)
738+ syncTimer := <-clk.timers
739+ rc.mu.Lock()
740+ syncDone := rc.syncWorkerDone
741+ rc.mu.Unlock()
742+ tc.end(rc, ac)
743+ require.True(t, syncTimer.stopped.Load(), "sync watchdog must be stopped")
744+ requireWorkerExit(t, syncDone)
745+ })
746+ }
747+}
748+
641 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {749 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
642 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *signalClock, chan renderInvalidation) {750 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *signalClock, chan renderInvalidation) {
643 t.Helper()751 t.Helper()
M internal/usecase/daemon/session.go +2 -1
@@ -397,7 +397,8 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
397397 sess.mu.Unlock()
398398 d.log.Info("pane created", "session", name, "pane", p.id)
399399 }
400- d.sessWg.Add(2)
400+ // Scheduler ownership was removed; this launch creates exactly one reader.
401+ d.sessWg.Add(1)
401402 go d.ptyReader(sess, tb, p)
402403 }
403404
@@ -397,7 +397,8 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
397 sess.mu.Unlock()397 sess.mu.Unlock()
398 d.log.Info("pane created", "session", name, "pane", p.id)398 d.log.Info("pane created", "session", name, "pane", p.id)
399 }399 }
400- d.sessWg.Add(2)400+ // Scheduler ownership was removed; this launch creates exactly one reader.
401+ d.sessWg.Add(1)
401 go d.ptyReader(sess, tb, p)402 go d.ptyReader(sess, tb, p)
402 }403 }
403 404

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