detached 5a197b5

fix(daemon): preserve coordinator sync lifecycle

commit
5a197b56d9db0ce191ed9b90480dd419db0f7630
author
brice · · 2026-07-11 15:33 UTC
parent
37d1a50
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +51 -16
M internal/usecase/daemon/client.go +3 -0
@@ -637,6 +637,9 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
637637 }
638638 if sess.detachIfCurrent(ac) {
639639 ac.cancelResizePaint()
640+ if rc := sess.renderCoordinator(); rc != nil {
641+ rc.noteDetach(ac)
642+ }
640643 d.unregisterPreview(ac)
641644 if d.parkAttachment(sess, ac) {
642645 _ = ac.closeCapturedTransport(failed)
@@ -637,6 +637,9 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
637 }637 }
638 if sess.detachIfCurrent(ac) {638 if sess.detachIfCurrent(ac) {
639 ac.cancelResizePaint()639 ac.cancelResizePaint()
640+ if rc := sess.renderCoordinator(); rc != nil {
641+ rc.noteDetach(ac)
642+ }
640 d.unregisterPreview(ac)643 d.unregisterPreview(ac)
641 if d.parkAttachment(sess, ac) {644 if d.parkAttachment(sess, ac) {
642 _ = ac.closeCapturedTransport(failed)645 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/render.go +20 -6
@@ -46,6 +46,15 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
4646 p.screen.Write(data)
4747 p.refreshTerminalTitleLocked()
4848 isSyncing := p.screen.SyncUpdateActive()
49+ var syncGen uint64
50+ if wasSyncing != isSyncing {
51+ if isSyncing {
52+ syncGen = sess.syncGen.Add(1)
53+ p.syncGen = syncGen
54+ } else {
55+ syncGen = p.syncGen
56+ }
57+ }
4958 p.mu.Unlock()
5059 markSnapshotDirty(sess)
5160 select {
@@ -67,17 +76,22 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
6776 }
6877 if rc := sess.renderCoordinator(); rc != nil {
6978 if wasSyncing != isSyncing {
70- p.mu.Lock()
71- p.syncGen++
72- gen := p.syncGen
73- p.mu.Unlock()
79+ gen := syncGen
7480 if isSyncing {
75- rc.noteSyncBegin(gen)
81+ rc.noteSyncBegin(gen, func() {
82+ p.mu.Lock()
83+ if p.syncGen == gen && p.screen.SyncUpdateActive() {
84+ p.screen.ForceSyncEnd()
85+ }
86+ p.mu.Unlock()
87+ })
88+ // The accumulated synchronized batch is the pending render.
89+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
7690 } else {
7791 rc.noteSyncEnd(gen)
7892 }
7993 }
80- if !isSyncing {
94+ if !isSyncing && wasSyncing == isSyncing {
8195 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
8296 }
8397 }
@@ -46,6 +46,15 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
46 p.screen.Write(data)46 p.screen.Write(data)
47 p.refreshTerminalTitleLocked()47 p.refreshTerminalTitleLocked()
48 isSyncing := p.screen.SyncUpdateActive()48 isSyncing := p.screen.SyncUpdateActive()
49+ var syncGen uint64
50+ if wasSyncing != isSyncing {
51+ if isSyncing {
52+ syncGen = sess.syncGen.Add(1)
53+ p.syncGen = syncGen
54+ } else {
55+ syncGen = p.syncGen
56+ }
57+ }
49 p.mu.Unlock()58 p.mu.Unlock()
50 markSnapshotDirty(sess)59 markSnapshotDirty(sess)
51 select {60 select {
@@ -67,17 +76,22 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
67 }76 }
68 if rc := sess.renderCoordinator(); rc != nil {77 if rc := sess.renderCoordinator(); rc != nil {
69 if wasSyncing != isSyncing {78 if wasSyncing != isSyncing {
70- p.mu.Lock()79+ gen := syncGen
71- p.syncGen++
72- gen := p.syncGen
73- p.mu.Unlock()
74 if isSyncing {80 if isSyncing {
75- rc.noteSyncBegin(gen)81+ rc.noteSyncBegin(gen, func() {
82+ p.mu.Lock()
83+ if p.syncGen == gen && p.screen.SyncUpdateActive() {
84+ p.screen.ForceSyncEnd()
85+ }
86+ p.mu.Unlock()
87+ })
88+ // The accumulated synchronized batch is the pending render.
89+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
76 } else {90 } else {
77 rc.noteSyncEnd(gen)91 rc.noteSyncEnd(gen)
78 }92 }
79 }93 }
80- if !isSyncing {94+ if !isSyncing && wasSyncing == isSyncing {
81 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})95 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
82 }96 }
83 }97 }
M internal/usecase/daemon/render_coordinator.go +18 -8
@@ -118,6 +118,7 @@ type renderCoordinator struct {
118118 generation uint64
119119 armed bool
120120 syncGeneration uint64
121+ syncWatchdog func()
121122 }
122123
123124 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -177,14 +178,19 @@ func (c *renderCoordinator) notifyAck() {
177178 }
178179
179180 // noteSyncBegin records that a synchronized-output batch opened for gen and
180-// arms the completion watchdog.
181-func (c *renderCoordinator) noteSyncBegin(gen uint64) {
181+// arms the completion watchdog. force is supplied by the pane owner so the
182+// coordinator can end a wedged VT batch before it composes the pending state.
183+func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
182184 c.mu.Lock()
183185 if c.torndown {
184186 c.mu.Unlock()
185187 return
186188 }
187189 c.syncGeneration = gen
190+ c.syncWatchdog = nil
191+ if len(force) != 0 {
192+ c.syncWatchdog = force[0]
193+ }
188194 clock := c.opts.clock
189195 c.mu.Unlock()
190196 if clock == nil {
@@ -195,8 +201,16 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64) {
195201 <-timer.C()
196202 c.mu.Lock()
197203 valid := !c.torndown && c.syncGeneration == gen
204+ force := c.syncWatchdog
205+ if valid {
206+ c.syncGeneration = 0
207+ c.syncWatchdog = nil
208+ }
198209 c.mu.Unlock()
199210 if valid {
211+ if force != nil {
212+ force()
213+ }
200214 c.fireCurrent(true)
201215 }
202216 }()
@@ -211,6 +225,7 @@ func (c *renderCoordinator) noteSyncEnd(gen uint64) {
211225 return
212226 }
213227 c.syncGeneration = 0
228+ c.syncWatchdog = nil
214229 c.mu.Unlock()
215230 c.fireCurrent(false)
216231 }
@@ -293,11 +308,6 @@ func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
293308 return c.resize
294309 }
295310
296-// burstMetrics returns an observational snapshot for benchmark reporting.
297-func (c *renderCoordinator) burstMetrics() (invalidations, wakes, coalesced uint64) {
298- return c.metrics.invalidations.Load(), c.metrics.wakes.Load(), c.metrics.coalesced.Load()
299-}
300-
301311 // renderCoordinator returns the coordinator installed for this session.
302312 func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
303313
@@ -332,7 +342,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
332342 c.mu.Unlock()
333343 return
334344 }
335- if !watchdog && c.opts.syncActive != nil && c.opts.syncActive() {
345+ if !watchdog && c.syncGeneration != 0 {
336346 c.mu.Unlock()
337347 return
338348 }
@@ -118,6 +118,7 @@ type renderCoordinator struct {
118 generation uint64118 generation uint64
119 armed bool119 armed bool
120 syncGeneration uint64120 syncGeneration uint64
121+ syncWatchdog func()
121 }122 }
122 123
123 // newRenderCoordinator constructs a coordinator bound to opts. It starts no124 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -177,14 +178,19 @@ func (c *renderCoordinator) notifyAck() {
177 }178 }
178 179
179 // noteSyncBegin records that a synchronized-output batch opened for gen and180 // noteSyncBegin records that a synchronized-output batch opened for gen and
180-// arms the completion watchdog.181+// arms the completion watchdog. force is supplied by the pane owner so the
181-func (c *renderCoordinator) noteSyncBegin(gen uint64) {182+// coordinator can end a wedged VT batch before it composes the pending state.
183+func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
182 c.mu.Lock()184 c.mu.Lock()
183 if c.torndown {185 if c.torndown {
184 c.mu.Unlock()186 c.mu.Unlock()
185 return187 return
186 }188 }
187 c.syncGeneration = gen189 c.syncGeneration = gen
190+ c.syncWatchdog = nil
191+ if len(force) != 0 {
192+ c.syncWatchdog = force[0]
193+ }
188 clock := c.opts.clock194 clock := c.opts.clock
189 c.mu.Unlock()195 c.mu.Unlock()
190 if clock == nil {196 if clock == nil {
@@ -195,8 +201,16 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64) {
195 <-timer.C()201 <-timer.C()
196 c.mu.Lock()202 c.mu.Lock()
197 valid := !c.torndown && c.syncGeneration == gen203 valid := !c.torndown && c.syncGeneration == gen
204+ force := c.syncWatchdog
205+ if valid {
206+ c.syncGeneration = 0
207+ c.syncWatchdog = nil
208+ }
198 c.mu.Unlock()209 c.mu.Unlock()
199 if valid {210 if valid {
211+ if force != nil {
212+ force()
213+ }
200 c.fireCurrent(true)214 c.fireCurrent(true)
201 }215 }
202 }()216 }()
@@ -211,6 +225,7 @@ func (c *renderCoordinator) noteSyncEnd(gen uint64) {
211 return225 return
212 }226 }
213 c.syncGeneration = 0227 c.syncGeneration = 0
228+ c.syncWatchdog = nil
214 c.mu.Unlock()229 c.mu.Unlock()
215 c.fireCurrent(false)230 c.fireCurrent(false)
216 }231 }
@@ -293,11 +308,6 @@ func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
293 return c.resize308 return c.resize
294 }309 }
295 310
296-// burstMetrics returns an observational snapshot for benchmark reporting.
297-func (c *renderCoordinator) burstMetrics() (invalidations, wakes, coalesced uint64) {
298- return c.metrics.invalidations.Load(), c.metrics.wakes.Load(), c.metrics.coalesced.Load()
299-}
300-
301 // renderCoordinator returns the coordinator installed for this session.311 // renderCoordinator returns the coordinator installed for this session.
302 func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }312 func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
303 313
@@ -332,7 +342,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
332 c.mu.Unlock()342 c.mu.Unlock()
333 return343 return
334 }344 }
335- if !watchdog && c.opts.syncActive != nil && c.opts.syncActive() {345+ if !watchdog && c.syncGeneration != 0 {
336 c.mu.Unlock()346 c.mu.Unlock()
337 return347 return
338 }348 }
M internal/usecase/daemon/render_coordinator_test.go +4 -2
@@ -255,15 +255,17 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
255255 requireNoWake(t, h.wakes)
256256 })
257257
258- t.Run("watchdog forces a flush for a wedged batch", func(t *testing.T) {
258+ t.Run("watchdog ends a wedged batch before it flushes", func(t *testing.T) {
259259 h := newCoordinatorHarness(t)
260260 h.syncActive.Store(true)
261- h.rc.noteSyncBegin(7)
261+ forced := make(chan struct{}, 1)
262+ h.rc.noteSyncBegin(7, func() { forced <- struct{}{} })
262263 watchdogs := h.armedTimers(t)
263264 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
264265 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
265266
266267 watchdogs[0].ch <- time.Time{}
268+ <-forced
267269 w := awaitWake(t, h.wakes)
268270 require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
269271 requireNoWake(t, h.wakes)
@@ -255,15 +255,17 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
255 requireNoWake(t, h.wakes)255 requireNoWake(t, h.wakes)
256 })256 })
257 257
258- t.Run("watchdog forces a flush for a wedged batch", func(t *testing.T) {258+ t.Run("watchdog ends a wedged batch before it flushes", func(t *testing.T) {
259 h := newCoordinatorHarness(t)259 h := newCoordinatorHarness(t)
260 h.syncActive.Store(true)260 h.syncActive.Store(true)
261- h.rc.noteSyncBegin(7)261+ forced := make(chan struct{}, 1)
262+ h.rc.noteSyncBegin(7, func() { forced <- struct{}{} })
262 watchdogs := h.armedTimers(t)263 watchdogs := h.armedTimers(t)
263 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")264 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
264 h.rc.invalidate(renderInvalidation{class: invalidateOutput})265 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
265 266
266 watchdogs[0].ch <- time.Time{}267 watchdogs[0].ch <- time.Time{}
268+ <-forced
267 w := awaitWake(t, h.wakes)269 w := awaitWake(t, h.wakes)
268 require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")270 require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
269 requireNoWake(t, h.wakes)271 requireNoWake(t, h.wakes)
M internal/usecase/daemon/session.go +6 -0
@@ -44,6 +44,9 @@ type session struct {
4444 mruAt atomic.Uint64
4545 snapDirty atomic.Bool
4646 snapEligible atomic.Bool
47+ // syncGen makes synchronized-output watchdog generations unique across all
48+ // panes in this session.
49+ syncGen atomic.Uint64
4750 // coordinator fans in this session's producer render invalidations.
4851 coordinator atomic.Pointer[renderCoordinator]
4952 // clipFiles records clipboard-image-transfer temp file paths (see
@@ -706,6 +709,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
706709 ac := sess.client
707710 sess.client = nil
708711 sess.mu.Unlock()
712+ if rc := sess.renderCoordinator(); rc != nil {
713+ rc.noteSessionTeardown()
714+ }
709715 if ac != nil {
710716 ac.cancelResizePaint()
711717 d.unregisterPreview(ac)
@@ -44,6 +44,9 @@ type session struct {
44 mruAt atomic.Uint6444 mruAt atomic.Uint64
45 snapDirty atomic.Bool45 snapDirty atomic.Bool
46 snapEligible atomic.Bool46 snapEligible atomic.Bool
47+ // syncGen makes synchronized-output watchdog generations unique across all
48+ // panes in this session.
49+ syncGen atomic.Uint64
47 // coordinator fans in this session's producer render invalidations.50 // coordinator fans in this session's producer render invalidations.
48 coordinator atomic.Pointer[renderCoordinator]51 coordinator atomic.Pointer[renderCoordinator]
49 // clipFiles records clipboard-image-transfer temp file paths (see52 // clipFiles records clipboard-image-transfer temp file paths (see
@@ -706,6 +709,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
706 ac := sess.client709 ac := sess.client
707 sess.client = nil710 sess.client = nil
708 sess.mu.Unlock()711 sess.mu.Unlock()
712+ if rc := sess.renderCoordinator(); rc != nil {
713+ rc.noteSessionTeardown()
714+ }
709 if ac != nil {715 if ac != nil {
710 ac.cancelResizePaint()716 ac.cancelResizePaint()
711 d.unregisterPreview(ac)717 d.unregisterPreview(ac)

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