detached 0d938b0

fix(daemon): coordinate synchronized pane batches

commit
0d938b0a6abbaf752186ca0f3f26ef5849c91fa7
author
brice · · 2026-07-11 18:44 UTC
parent
6ad62fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +132 -61
M internal/usecase/daemon/pane_actions.go +3 -0
@@ -257,6 +257,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
257257 ac.overlays.clearCopyModeForPane(p)
258258 }
259259
260+ if rc := sess.renderCoordinator(); rc != nil {
261+ rc.noteSyncPaneRemoved(p)
262+ }
260263 if p.cancel != nil {
261264 p.cancel()
262265 }
@@ -257,6 +257,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
257 ac.overlays.clearCopyModeForPane(p)257 ac.overlays.clearCopyModeForPane(p)
258 }258 }
259 259
260+ if rc := sess.renderCoordinator(); rc != nil {
261+ rc.noteSyncPaneRemoved(p)
262+ }
260 if p.cancel != nil {263 if p.cancel != nil {
261 p.cancel()264 p.cancel()
262 }265 }
M internal/usecase/daemon/render.go +2 -2
@@ -161,7 +161,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
161161 if wasSyncing != isSyncing {
162162 gen := syncGen
163163 if isSyncing {
164- rc.noteSyncBegin(gen, func() {
164+ rc.noteSyncBegin(p, gen, func() {
165165 p.mu.Lock()
166166 if p.syncGen == gen && p.screen.SyncUpdateActive() {
167167 p.screen.ForceSyncEnd()
@@ -171,7 +171,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
171171 // The accumulated synchronized batch is the pending render.
172172 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
173173 } else {
174- rc.noteSyncEnd(gen)
174+ rc.noteSyncEnd(p, gen)
175175 }
176176 }
177177 if completeSyncRead {
@@ -161,7 +161,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
161 if wasSyncing != isSyncing {161 if wasSyncing != isSyncing {
162 gen := syncGen162 gen := syncGen
163 if isSyncing {163 if isSyncing {
164- rc.noteSyncBegin(gen, func() {164+ rc.noteSyncBegin(p, gen, func() {
165 p.mu.Lock()165 p.mu.Lock()
166 if p.syncGen == gen && p.screen.SyncUpdateActive() {166 if p.syncGen == gen && p.screen.SyncUpdateActive() {
167 p.screen.ForceSyncEnd()167 p.screen.ForceSyncEnd()
@@ -171,7 +171,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
171 // The accumulated synchronized batch is the pending render.171 // The accumulated synchronized batch is the pending render.
172 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})172 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
173 } else {173 } else {
174- rc.noteSyncEnd(gen)174+ rc.noteSyncEnd(p, gen)
175 }175 }
176 }176 }
177 if completeSyncRead {177 if completeSyncRead {
M internal/usecase/daemon/render_coordinator.go +75 -42
@@ -141,11 +141,9 @@ type renderCoordinator struct {
141141 normalTimer ports.Timer
142142 normalCancel chan struct{}
143143 normalWorkerDone chan struct{}
144- syncGeneration uint64
145- syncWatchdog func()
146- syncTimer ports.Timer
147- syncCancel chan struct{}
148- syncWorkerDone chan struct{}
144+ // syncBatches is keyed by the stable pane owner. Each pane owns an
145+ // independent watchdog; composition remains gated until every batch ends.
146+ syncBatches map[*pane]*syncBatch
149147 }
150148
151149 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -167,14 +165,29 @@ func (c *renderCoordinator) cancelNormalTimerLocked() {
167165 }
168166 }
169167
170-func (c *renderCoordinator) cancelSyncTimerLocked() {
171- if c.syncTimer != nil {
172- c.syncTimer.Stop()
173- c.syncTimer = nil
168+type syncBatch struct {
169+ generation uint64
170+ force func()
171+ timer ports.Timer
172+ cancel chan struct{}
173+ done chan struct{}
174+}
175+
176+func cancelSyncBatchLocked(batch *syncBatch) {
177+ if batch.timer != nil {
178+ batch.timer.Stop()
179+ batch.timer = nil
174180 }
175- if c.syncCancel != nil {
176- close(c.syncCancel)
177- c.syncCancel = nil
181+ if batch.cancel != nil {
182+ close(batch.cancel)
183+ batch.cancel = nil
184+ }
185+}
186+
187+func (c *renderCoordinator) cancelSyncTimersLocked() {
188+ for pane, batch := range c.syncBatches {
189+ cancelSyncBatchLocked(batch)
190+ delete(c.syncBatches, pane)
178191 }
179192 }
180193
@@ -254,30 +267,34 @@ func (c *renderCoordinator) notifyAck() {
254267 c.fire(gen, false)
255268 }
256269
257-// noteSyncBegin records that a synchronized-output batch opened for gen and
258-// arms the completion watchdog. force is supplied by the pane owner so the
259-// coordinator can end a wedged VT batch before it composes the pending state.
260-func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
270+// noteSyncBegin records a synchronized-output batch for its stable pane and
271+// arms that pane's watchdog. Overlapping pane batches are independent.
272+func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {
261273 c.mu.Lock()
262274 if c.torndown {
263275 c.mu.Unlock()
264276 return
265277 }
266- c.cancelSyncTimerLocked()
267- c.syncGeneration = gen
268- c.syncWatchdog = nil
278+ if c.syncBatches == nil {
279+ c.syncBatches = make(map[*pane]*syncBatch)
280+ }
281+ if old := c.syncBatches[p]; old != nil {
282+ cancelSyncBatchLocked(old)
283+ }
284+ batch := &syncBatch{generation: gen}
269285 if len(force) != 0 {
270- c.syncWatchdog = force[0]
286+ batch.force = force[0]
271287 }
288+ c.syncBatches[p] = batch
272289 clock := c.opts.clock
273290 if clock == nil {
274291 c.mu.Unlock()
275292 return
276293 }
277- timer := clock.NewTimer(maxSyncUpdateDuration)
278- cancel := make(chan struct{})
279- done := make(chan struct{})
280- c.syncTimer, c.syncCancel, c.syncWorkerDone = timer, cancel, done
294+ batch.timer = clock.NewTimer(maxSyncUpdateDuration)
295+ batch.cancel = make(chan struct{})
296+ batch.done = make(chan struct{})
297+ timer, cancel, done := batch.timer, batch.cancel, batch.done
281298 c.mu.Unlock()
282299 go func() {
283300 defer close(done)
@@ -287,38 +304,54 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
287304 return
288305 }
289306 c.mu.Lock()
290- valid := !c.torndown && c.syncGeneration == gen
291- force := c.syncWatchdog
307+ current := c.syncBatches[p]
308+ valid := !c.torndown && current == batch && current.generation == gen
292309 if valid {
293- c.syncGeneration = 0
294- c.syncWatchdog = nil
295- c.cancelSyncTimerLocked()
310+ delete(c.syncBatches, p)
311+ cancelSyncBatchLocked(batch)
312+ if len(c.syncBatches) == 0 && c.pending {
313+ c.pendingUrgent = true
314+ }
296315 }
297316 c.mu.Unlock()
298317 if valid {
299- if force != nil {
300- force()
318+ if batch.force != nil {
319+ batch.force()
301320 }
321+ // Reevaluate the aggregate gate only after forcing this pane.
302322 c.fireCurrent(true)
303323 }
304324 }()
305325 }
306326
307-// noteSyncEnd records that the batch for gen completed, flushing pending
308-// state in one wake.
309-func (c *renderCoordinator) noteSyncEnd(gen uint64) {
327+// noteSyncEnd records completion for exactly one pane batch. It only flushes
328+// after the aggregate gate opens.
329+func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {
310330 c.mu.Lock()
311- if c.syncGeneration != gen {
331+ batch := c.syncBatches[p]
332+ if batch == nil || batch.generation != gen {
312333 c.mu.Unlock()
313334 return
314335 }
315- c.syncGeneration = 0
316- c.syncWatchdog = nil
317- c.cancelSyncTimerLocked()
336+ delete(c.syncBatches, p)
337+ cancelSyncBatchLocked(batch)
338+ if len(c.syncBatches) == 0 && c.pending {
339+ c.pendingUrgent = true
340+ }
318341 c.mu.Unlock()
319342 c.fireCurrent(false)
320343 }
321344
345+// noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
346+func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
347+ c.mu.Lock()
348+ if batch := c.syncBatches[p]; batch != nil {
349+ delete(c.syncBatches, p)
350+ cancelSyncBatchLocked(batch)
351+ }
352+ c.mu.Unlock()
353+}
354+
322355 // subscribePreview installs fn as the preview observer for coalesced wakes.
323356 func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
324357 c.mu.Lock()
@@ -372,7 +405,7 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
372405 c.generation++
373406 c.armed = false
374407 c.cancelNormalTimerLocked()
375- c.cancelSyncTimerLocked()
408+ c.cancelSyncTimersLocked()
376409 }
377410 c.mu.Unlock()
378411 }
@@ -388,7 +421,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
388421 c.generation++
389422 c.armed = false
390423 c.cancelNormalTimerLocked()
391- c.cancelSyncTimerLocked()
424+ c.cancelSyncTimersLocked()
392425 }
393426 c.mu.Unlock()
394427 }
@@ -408,7 +441,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
408441 c.generation++
409442 c.armed = false
410443 c.cancelNormalTimerLocked()
411- c.cancelSyncTimerLocked()
444+ c.cancelSyncTimersLocked()
412445 c.mu.Unlock()
413446 }
414447
@@ -494,7 +527,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
494527 c.mu.Unlock()
495528 return
496529 }
497- if !watchdog && c.syncGeneration != 0 {
530+ if len(c.syncBatches) != 0 {
498531 c.mu.Unlock()
499532 return
500533 }
@@ -141,11 +141,9 @@ type renderCoordinator struct {
141 normalTimer ports.Timer141 normalTimer ports.Timer
142 normalCancel chan struct{}142 normalCancel chan struct{}
143 normalWorkerDone chan struct{}143 normalWorkerDone chan struct{}
144- syncGeneration uint64144+ // syncBatches is keyed by the stable pane owner. Each pane owns an
145- syncWatchdog func()145+ // independent watchdog; composition remains gated until every batch ends.
146- syncTimer ports.Timer146+ syncBatches map[*pane]*syncBatch
147- syncCancel chan struct{}
148- syncWorkerDone chan struct{}
149 }147 }
150 148
151 // newRenderCoordinator constructs a coordinator bound to opts. It starts no149 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -167,14 +165,29 @@ func (c *renderCoordinator) cancelNormalTimerLocked() {
167 }165 }
168 }166 }
169 167
170-func (c *renderCoordinator) cancelSyncTimerLocked() {168+type syncBatch struct {
171- if c.syncTimer != nil {169+ generation uint64
172- c.syncTimer.Stop()170+ force func()
173- c.syncTimer = nil171+ timer ports.Timer
172+ cancel chan struct{}
173+ done chan struct{}
174+}
175+
176+func cancelSyncBatchLocked(batch *syncBatch) {
177+ if batch.timer != nil {
178+ batch.timer.Stop()
179+ batch.timer = nil
174 }180 }
175- if c.syncCancel != nil {181+ if batch.cancel != nil {
176- close(c.syncCancel)182+ close(batch.cancel)
177- c.syncCancel = nil183+ batch.cancel = nil
184+ }
185+}
186+
187+func (c *renderCoordinator) cancelSyncTimersLocked() {
188+ for pane, batch := range c.syncBatches {
189+ cancelSyncBatchLocked(batch)
190+ delete(c.syncBatches, pane)
178 }191 }
179 }192 }
180 193
@@ -254,30 +267,34 @@ func (c *renderCoordinator) notifyAck() {
254 c.fire(gen, false)267 c.fire(gen, false)
255 }268 }
256 269
257-// noteSyncBegin records that a synchronized-output batch opened for gen and270+// noteSyncBegin records a synchronized-output batch for its stable pane and
258-// arms the completion watchdog. force is supplied by the pane owner so the271+// arms that pane's watchdog. Overlapping pane batches are independent.
259-// coordinator can end a wedged VT batch before it composes the pending state.272+func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {
260-func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
261 c.mu.Lock()273 c.mu.Lock()
262 if c.torndown {274 if c.torndown {
263 c.mu.Unlock()275 c.mu.Unlock()
264 return276 return
265 }277 }
266- c.cancelSyncTimerLocked()278+ if c.syncBatches == nil {
267- c.syncGeneration = gen279+ c.syncBatches = make(map[*pane]*syncBatch)
268- c.syncWatchdog = nil280+ }
281+ if old := c.syncBatches[p]; old != nil {
282+ cancelSyncBatchLocked(old)
283+ }
284+ batch := &syncBatch{generation: gen}
269 if len(force) != 0 {285 if len(force) != 0 {
270- c.syncWatchdog = force[0]286+ batch.force = force[0]
271 }287 }
288+ c.syncBatches[p] = batch
272 clock := c.opts.clock289 clock := c.opts.clock
273 if clock == nil {290 if clock == nil {
274 c.mu.Unlock()291 c.mu.Unlock()
275 return292 return
276 }293 }
277- timer := clock.NewTimer(maxSyncUpdateDuration)294+ batch.timer = clock.NewTimer(maxSyncUpdateDuration)
278- cancel := make(chan struct{})295+ batch.cancel = make(chan struct{})
279- done := make(chan struct{})296+ batch.done = make(chan struct{})
280- c.syncTimer, c.syncCancel, c.syncWorkerDone = timer, cancel, done297+ timer, cancel, done := batch.timer, batch.cancel, batch.done
281 c.mu.Unlock()298 c.mu.Unlock()
282 go func() {299 go func() {
283 defer close(done)300 defer close(done)
@@ -287,38 +304,54 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) {
287 return304 return
288 }305 }
289 c.mu.Lock()306 c.mu.Lock()
290- valid := !c.torndown && c.syncGeneration == gen307+ current := c.syncBatches[p]
291- force := c.syncWatchdog308+ valid := !c.torndown && current == batch && current.generation == gen
292 if valid {309 if valid {
293- c.syncGeneration = 0310+ delete(c.syncBatches, p)
294- c.syncWatchdog = nil311+ cancelSyncBatchLocked(batch)
295- c.cancelSyncTimerLocked()312+ if len(c.syncBatches) == 0 && c.pending {
313+ c.pendingUrgent = true
314+ }
296 }315 }
297 c.mu.Unlock()316 c.mu.Unlock()
298 if valid {317 if valid {
299- if force != nil {318+ if batch.force != nil {
300- force()319+ batch.force()
301 }320 }
321+ // Reevaluate the aggregate gate only after forcing this pane.
302 c.fireCurrent(true)322 c.fireCurrent(true)
303 }323 }
304 }()324 }()
305 }325 }
306 326
307-// noteSyncEnd records that the batch for gen completed, flushing pending327+// noteSyncEnd records completion for exactly one pane batch. It only flushes
308-// state in one wake.328+// after the aggregate gate opens.
309-func (c *renderCoordinator) noteSyncEnd(gen uint64) {329+func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {
310 c.mu.Lock()330 c.mu.Lock()
311- if c.syncGeneration != gen {331+ batch := c.syncBatches[p]
332+ if batch == nil || batch.generation != gen {
312 c.mu.Unlock()333 c.mu.Unlock()
313 return334 return
314 }335 }
315- c.syncGeneration = 0336+ delete(c.syncBatches, p)
316- c.syncWatchdog = nil337+ cancelSyncBatchLocked(batch)
317- c.cancelSyncTimerLocked()338+ if len(c.syncBatches) == 0 && c.pending {
339+ c.pendingUrgent = true
340+ }
318 c.mu.Unlock()341 c.mu.Unlock()
319 c.fireCurrent(false)342 c.fireCurrent(false)
320 }343 }
321 344
345+// noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
346+func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
347+ c.mu.Lock()
348+ if batch := c.syncBatches[p]; batch != nil {
349+ delete(c.syncBatches, p)
350+ cancelSyncBatchLocked(batch)
351+ }
352+ c.mu.Unlock()
353+}
354+
322 // subscribePreview installs fn as the preview observer for coalesced wakes.355 // subscribePreview installs fn as the preview observer for coalesced wakes.
323 func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {356 func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
324 c.mu.Lock()357 c.mu.Lock()
@@ -372,7 +405,7 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
372 c.generation++405 c.generation++
373 c.armed = false406 c.armed = false
374 c.cancelNormalTimerLocked()407 c.cancelNormalTimerLocked()
375- c.cancelSyncTimerLocked()408+ c.cancelSyncTimersLocked()
376 }409 }
377 c.mu.Unlock()410 c.mu.Unlock()
378 }411 }
@@ -388,7 +421,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
388 c.generation++421 c.generation++
389 c.armed = false422 c.armed = false
390 c.cancelNormalTimerLocked()423 c.cancelNormalTimerLocked()
391- c.cancelSyncTimerLocked()424+ c.cancelSyncTimersLocked()
392 }425 }
393 c.mu.Unlock()426 c.mu.Unlock()
394 }427 }
@@ -408,7 +441,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
408 c.generation++441 c.generation++
409 c.armed = false442 c.armed = false
410 c.cancelNormalTimerLocked()443 c.cancelNormalTimerLocked()
411- c.cancelSyncTimerLocked()444+ c.cancelSyncTimersLocked()
412 c.mu.Unlock()445 c.mu.Unlock()
413 }446 }
414 447
@@ -494,7 +527,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
494 c.mu.Unlock()527 c.mu.Unlock()
495 return528 return
496 }529 }
497- if !watchdog && c.syncGeneration != 0 {530+ if len(c.syncBatches) != 0 {
498 c.mu.Unlock()531 c.mu.Unlock()
499 return532 return
500 }533 }
M internal/usecase/daemon/render_coordinator_test.go +43 -16
@@ -342,17 +342,17 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
342342 t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {
343343 h := newCoordinatorHarness(t)
344344 firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
345+ firstPane, secondPane := &pane{}, &pane{}
345346
346- // Distinct generations model distinct panes. The coordinator must retain
347- // both batches rather than letting the later begin replace the first.
348- h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} })
347+ // Distinct stable pane owners must remain independent.
348+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
349349 firstWatchdog := h.armedTimers(t)[0]
350- h.rc.noteSyncBegin(22, func() { secondForced <- struct{}{} })
350+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
351351 secondWatchdog := h.armedTimers(t)[0]
352352 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
353353
354354 // Completing either pane leaves the aggregate gate closed.
355- h.rc.noteSyncEnd(11)
355+ h.rc.noteSyncEnd(firstPane, 11)
356356 requireNoWake(t, h.wakes)
357357 // A stale callback for the completed pane must not force it or wake.
358358 firstWatchdog.ch <- time.Time{}
@@ -369,23 +369,50 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
369369 <-secondForced
370370 w := awaitWake(t, h.wakes)
371371 require.True(t, w.watchdog)
372+ require.True(t, w.urgent, "aggregate completion must wake urgently")
372373 requireNoWake(t, h.wakes)
373374 })
374375
375376 t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {
376377 h := newCoordinatorHarness(t)
377378 firstForced := make(chan struct{}, 1)
378- h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} })
379+ firstPane, secondPane := &pane{}, &pane{}
380+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
379381 firstWatchdog := h.armedTimers(t)[0]
380- h.rc.noteSyncBegin(22)
382+ h.rc.noteSyncBegin(secondPane, 22)
381383 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
382384
383- h.rc.noteSyncEnd(22)
385+ h.rc.noteSyncEnd(secondPane, 22)
384386 requireNoWake(t, h.wakes)
385387 firstWatchdog.ch <- time.Time{}
386388 <-firstForced
387- require.True(t, awaitWake(t, h.wakes).watchdog)
389+ w := awaitWake(t, h.wakes)
390+ require.True(t, w.watchdog)
391+ require.True(t, w.urgent, "final pane completion must wake urgently")
392+ requireNoWake(t, h.wakes)
393+ })
394+
395+ t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) {
396+ h := newCoordinatorHarness(t)
397+ firstPane, secondPane := &pane{}, &pane{}
398+ firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
399+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
400+ firstWatchdog := h.armedTimers(t)[0]
401+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
402+ secondWatchdog := h.armedTimers(t)[0]
403+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
404+
405+ h.rc.noteSyncPaneRemoved(firstPane)
406+ firstWatchdog.ch <- time.Time{}
407+ select {
408+ case <-firstForced:
409+ t.Fatal("removed pane watchdog forced output")
410+ default:
411+ }
388412 requireNoWake(t, h.wakes)
413+ secondWatchdog.ch <- time.Time{}
414+ <-secondForced
415+ require.True(t, awaitWake(t, h.wakes).watchdog)
389416 })
390417 }
391418
@@ -393,7 +420,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
393420 t.Run("completion flushes pending state in one wake", func(t *testing.T) {
394421 h := newCoordinatorHarness(t)
395422 h.syncActive.Store(true)
396- h.rc.noteSyncBegin(1)
423+ h.rc.noteSyncBegin(nil, 1)
397424 watchdogs := h.armedTimers(t)
398425 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
399426 require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
@@ -405,7 +432,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
405432 requireNoWake(t, h.wakes)
406433
407434 h.syncActive.Store(false)
408- h.rc.noteSyncEnd(1)
435+ h.rc.noteSyncEnd(nil, 1)
409436 w := awaitWake(t, h.wakes)
410437 require.True(t, w.reset)
411438 requireNoWake(t, h.wakes)
@@ -415,7 +442,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
415442 h := newCoordinatorHarness(t)
416443 h.syncActive.Store(true)
417444 forced := make(chan struct{}, 1)
418- h.rc.noteSyncBegin(7, func() { forced <- struct{}{} })
445+ h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} })
419446 watchdogs := h.armedTimers(t)
420447 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
421448 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -430,11 +457,11 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
430457 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
431458 h := newCoordinatorHarness(t)
432459 h.syncActive.Store(true)
433- h.rc.noteSyncBegin(1)
460+ h.rc.noteSyncBegin(nil, 1)
434461 watchdogs := h.armedTimers(t)
435462 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
436463 h.syncActive.Store(false)
437- h.rc.noteSyncEnd(1)
464+ h.rc.noteSyncEnd(nil, 1)
438465
439466 watchdogs[0].ch <- time.Time{}
440467 requireNoWake(t, h.wakes)
@@ -968,10 +995,10 @@ func TestRenderCoordinatorStopsInertSyncTimerWorkers(t *testing.T) {
968995 rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
969996 ac := &attachedClient{}
970997 rc.attach(ac)
971- rc.noteSyncBegin(1)
998+ rc.noteSyncBegin(nil, 1)
972999 syncTimer := <-clk.timers
9731000 rc.mu.Lock()
974- syncDone := rc.syncWorkerDone
1001+ syncDone := rc.syncBatches[nil].done
9751002 rc.mu.Unlock()
9761003 tc.end(rc, ac)
9771004 syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
@@ -342,17 +342,17 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
342 t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {342 t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {
343 h := newCoordinatorHarness(t)343 h := newCoordinatorHarness(t)
344 firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)344 firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
345+ firstPane, secondPane := &pane{}, &pane{}
345 346
346- // Distinct generations model distinct panes. The coordinator must retain347+ // Distinct stable pane owners must remain independent.
347- // both batches rather than letting the later begin replace the first.348+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
348- h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} })
349 firstWatchdog := h.armedTimers(t)[0]349 firstWatchdog := h.armedTimers(t)[0]
350- h.rc.noteSyncBegin(22, func() { secondForced <- struct{}{} })350+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
351 secondWatchdog := h.armedTimers(t)[0]351 secondWatchdog := h.armedTimers(t)[0]
352 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})352 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
353 353
354 // Completing either pane leaves the aggregate gate closed.354 // Completing either pane leaves the aggregate gate closed.
355- h.rc.noteSyncEnd(11)355+ h.rc.noteSyncEnd(firstPane, 11)
356 requireNoWake(t, h.wakes)356 requireNoWake(t, h.wakes)
357 // A stale callback for the completed pane must not force it or wake.357 // A stale callback for the completed pane must not force it or wake.
358 firstWatchdog.ch <- time.Time{}358 firstWatchdog.ch <- time.Time{}
@@ -369,23 +369,50 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
369 <-secondForced369 <-secondForced
370 w := awaitWake(t, h.wakes)370 w := awaitWake(t, h.wakes)
371 require.True(t, w.watchdog)371 require.True(t, w.watchdog)
372+ require.True(t, w.urgent, "aggregate completion must wake urgently")
372 requireNoWake(t, h.wakes)373 requireNoWake(t, h.wakes)
373 })374 })
374 375
375 t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {376 t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {
376 h := newCoordinatorHarness(t)377 h := newCoordinatorHarness(t)
377 firstForced := make(chan struct{}, 1)378 firstForced := make(chan struct{}, 1)
378- h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} })379+ firstPane, secondPane := &pane{}, &pane{}
380+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
379 firstWatchdog := h.armedTimers(t)[0]381 firstWatchdog := h.armedTimers(t)[0]
380- h.rc.noteSyncBegin(22)382+ h.rc.noteSyncBegin(secondPane, 22)
381 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})383 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
382 384
383- h.rc.noteSyncEnd(22)385+ h.rc.noteSyncEnd(secondPane, 22)
384 requireNoWake(t, h.wakes)386 requireNoWake(t, h.wakes)
385 firstWatchdog.ch <- time.Time{}387 firstWatchdog.ch <- time.Time{}
386 <-firstForced388 <-firstForced
387- require.True(t, awaitWake(t, h.wakes).watchdog)389+ w := awaitWake(t, h.wakes)
390+ require.True(t, w.watchdog)
391+ require.True(t, w.urgent, "final pane completion must wake urgently")
392+ requireNoWake(t, h.wakes)
393+ })
394+
395+ t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) {
396+ h := newCoordinatorHarness(t)
397+ firstPane, secondPane := &pane{}, &pane{}
398+ firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
399+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
400+ firstWatchdog := h.armedTimers(t)[0]
401+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
402+ secondWatchdog := h.armedTimers(t)[0]
403+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
404+
405+ h.rc.noteSyncPaneRemoved(firstPane)
406+ firstWatchdog.ch <- time.Time{}
407+ select {
408+ case <-firstForced:
409+ t.Fatal("removed pane watchdog forced output")
410+ default:
411+ }
388 requireNoWake(t, h.wakes)412 requireNoWake(t, h.wakes)
413+ secondWatchdog.ch <- time.Time{}
414+ <-secondForced
415+ require.True(t, awaitWake(t, h.wakes).watchdog)
389 })416 })
390 }417 }
391 418
@@ -393,7 +420,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
393 t.Run("completion flushes pending state in one wake", func(t *testing.T) {420 t.Run("completion flushes pending state in one wake", func(t *testing.T) {
394 h := newCoordinatorHarness(t)421 h := newCoordinatorHarness(t)
395 h.syncActive.Store(true)422 h.syncActive.Store(true)
396- h.rc.noteSyncBegin(1)423+ h.rc.noteSyncBegin(nil, 1)
397 watchdogs := h.armedTimers(t)424 watchdogs := h.armedTimers(t)
398 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")425 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
399 require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)426 require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
@@ -405,7 +432,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
405 requireNoWake(t, h.wakes)432 requireNoWake(t, h.wakes)
406 433
407 h.syncActive.Store(false)434 h.syncActive.Store(false)
408- h.rc.noteSyncEnd(1)435+ h.rc.noteSyncEnd(nil, 1)
409 w := awaitWake(t, h.wakes)436 w := awaitWake(t, h.wakes)
410 require.True(t, w.reset)437 require.True(t, w.reset)
411 requireNoWake(t, h.wakes)438 requireNoWake(t, h.wakes)
@@ -415,7 +442,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
415 h := newCoordinatorHarness(t)442 h := newCoordinatorHarness(t)
416 h.syncActive.Store(true)443 h.syncActive.Store(true)
417 forced := make(chan struct{}, 1)444 forced := make(chan struct{}, 1)
418- h.rc.noteSyncBegin(7, func() { forced <- struct{}{} })445+ h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} })
419 watchdogs := h.armedTimers(t)446 watchdogs := h.armedTimers(t)
420 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")447 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
421 h.rc.invalidate(renderInvalidation{class: invalidateOutput})448 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -430,11 +457,11 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
430 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {457 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
431 h := newCoordinatorHarness(t)458 h := newCoordinatorHarness(t)
432 h.syncActive.Store(true)459 h.syncActive.Store(true)
433- h.rc.noteSyncBegin(1)460+ h.rc.noteSyncBegin(nil, 1)
434 watchdogs := h.armedTimers(t)461 watchdogs := h.armedTimers(t)
435 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")462 require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
436 h.syncActive.Store(false)463 h.syncActive.Store(false)
437- h.rc.noteSyncEnd(1)464+ h.rc.noteSyncEnd(nil, 1)
438 465
439 watchdogs[0].ch <- time.Time{}466 watchdogs[0].ch <- time.Time{}
440 requireNoWake(t, h.wakes)467 requireNoWake(t, h.wakes)
@@ -968,10 +995,10 @@ func TestRenderCoordinatorStopsInertSyncTimerWorkers(t *testing.T) {
968 rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})995 rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
969 ac := &attachedClient{}996 ac := &attachedClient{}
970 rc.attach(ac)997 rc.attach(ac)
971- rc.noteSyncBegin(1)998+ rc.noteSyncBegin(nil, 1)
972 syncTimer := <-clk.timers999 syncTimer := <-clk.timers
973 rc.mu.Lock()1000 rc.mu.Lock()
974- syncDone := rc.syncWorkerDone1001+ syncDone := rc.syncBatches[nil].done
975 rc.mu.Unlock()1002 rc.mu.Unlock()
976 tc.end(rc, ac)1003 tc.end(rc, ac)
977 syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)1004 syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
M internal/usecase/daemon/session.go +8 -0
@@ -620,6 +620,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
620620 tb.cancel()
621621 }
622622 d.teardownFloating(tb, ac)
623+ if rc := sess.renderCoordinator(); rc != nil {
624+ tb.mu.Lock()
625+ panes := tb.panesSnapshot()
626+ tb.mu.Unlock()
627+ for _, p := range panes {
628+ rc.noteSyncPaneRemoved(p)
629+ }
630+ }
623631 tb.closeAllPanes()
624632 if wasActive {
625633 d.activateTab(sess, destination)
@@ -620,6 +620,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
620 tb.cancel()620 tb.cancel()
621 }621 }
622 d.teardownFloating(tb, ac)622 d.teardownFloating(tb, ac)
623+ if rc := sess.renderCoordinator(); rc != nil {
624+ tb.mu.Lock()
625+ panes := tb.panesSnapshot()
626+ tb.mu.Unlock()
627+ for _, p := range panes {
628+ rc.noteSyncPaneRemoved(p)
629+ }
630+ }
623 tb.closeAllPanes()631 tb.closeAllPanes()
624 if wasActive {632 if wasActive {
625 d.activateTab(sess, destination)633 d.activateTab(sess, destination)
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -219,7 +219,7 @@ func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *tes
219219 sctx, cancel := context.WithCancel(context.Background())
220220 defer cancel()
221221 tb := newTestTabWithContext(p, sctx, cancel)
222- sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
222+ sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel}
223223 invs := make(chan renderInvalidation, 1)
224224 clock := newCoordinatorMockClock(t, 2)
225225 sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
@@ -219,7 +219,7 @@ func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *tes
219 sctx, cancel := context.WithCancel(context.Background())219 sctx, cancel := context.WithCancel(context.Background())
220 defer cancel()220 defer cancel()
221 tb := newTestTabWithContext(p, sctx, cancel)221 tb := newTestTabWithContext(p, sctx, cancel)
222- sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}222+ sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel}
223 invs := make(chan renderInvalidation, 1)223 invs := make(chan renderInvalidation, 1)
224 clock := newCoordinatorMockClock(t, 2)224 clock := newCoordinatorMockClock(t, 2)
225 sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{225 sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{

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