detached 7f0463c

fix(daemon): republish synchronized completion

commit
7f0463cabce389ea4aaf4fb58a45e5b3e323ebb4
author
brice · · 2026-07-11 21:56 UTC
committer
brice · · 2026-07-11 22:09 UTC
parent
fcb1644
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +30 -9
M internal/usecase/daemon/render.go +5 -3
@@ -186,9 +186,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
186186 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
187187 }
188188 } else if syncEnded && renderable {
189- // State removal happened under pane.mu; only the external wake is
190- // deferred until after unlock to preserve lock order.
191- rc.fireCurrent(false)
189+ // State removal happened under pane.mu. Publish a new urgent
190+ // completion after unlock: detach/replace may have cleared the
191+ // pending batch work, so fireCurrent alone could not wake a
192+ // headless picker preview or replacement attachment.
193+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
192194 }
193195 }
194196 if completeSyncRead && renderable {
@@ -186,9 +186,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
186 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})186 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
187 }187 }
188 } else if syncEnded && renderable {188 } else if syncEnded && renderable {
189- // State removal happened under pane.mu; only the external wake is189+ // State removal happened under pane.mu. Publish a new urgent
190- // deferred until after unlock to preserve lock order.190+ // completion after unlock: detach/replace may have cleared the
191- rc.fireCurrent(false)191+ // pending batch work, so fireCurrent alone could not wake a
192+ // headless picker preview or replacement attachment.
193+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
192 }194 }
193 }195 }
194 if completeSyncRead && renderable {196 if completeSyncRead && renderable {
M internal/usecase/daemon/render_coordinator.go +4 -1
@@ -272,7 +272,10 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
272272 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
273273 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
274274 c.coalesced++
275- arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview)
275+ // A deadline may have expired while synchronized output still gated the
276+ // pending work. Completion republishes urgently and must reserve a fresh
277+ // deadline rather than leaving that already-fired timer as the only arm.
278+ arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
276279 var old ports.Timer
277280 if arm {
278281 c.generation++
@@ -272,7 +272,10 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
272 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent272 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
273 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0273 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
274 c.coalesced++274 c.coalesced++
275- arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview)275+ // A deadline may have expired while synchronized output still gated the
276+ // pending work. Completion republishes urgently and must reserve a fresh
277+ // deadline rather than leaving that already-fired timer as the only arm.
278+ arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
276 var old ports.Timer279 var old ports.Timer
277 if arm {280 if arm {
278 c.generation++281 c.generation++
M internal/usecase/daemon/render_coordinator_test.go +18 -3
@@ -657,9 +657,14 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
657657 require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
658658
659659 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
660- for _, timer := range h.armedTimers(t) {
661- timer.ch <- time.Time{}
662- }
660+ timers := h.armedTimers(t)
661+ require.Len(t, timers, 1)
662+ h.rc.mu.Lock()
663+ normalWorkerDone := h.rc.normalWorkerDone
664+ h.rc.mu.Unlock()
665+ require.NotNil(t, normalWorkerDone)
666+ timers[0].ch <- time.Time{}
667+ requireWorkerExit(t, normalWorkerDone)
663668 requireNoWake(t, h.wakes)
664669
665670 h.syncActive.Store(false)
@@ -1354,7 +1359,12 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
13541359 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})
13551360 timers := h.armedTimers(t)
13561361 require.Len(t, timers, 1)
1362+ h.rc.mu.Lock()
1363+ normalWorkerDone := h.rc.normalWorkerDone
1364+ h.rc.mu.Unlock()
1365+ require.NotNil(t, normalWorkerDone)
13571366 timers[0].ch <- time.Time{}
1367+ requireWorkerExit(t, normalWorkerDone)
13581368 requireNoWake(t, h.previews)
13591369
13601370 h.rc.mu.Lock()
@@ -1386,7 +1396,12 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
13861396 h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
13871397 timers := h.armedTimers(t)
13881398 require.Len(t, timers, 1)
1399+ h.rc.mu.Lock()
1400+ normalWorkerDone := h.rc.normalWorkerDone
1401+ h.rc.mu.Unlock()
1402+ require.NotNil(t, normalWorkerDone)
13891403 timers[0].ch <- time.Time{}
1404+ requireWorkerExit(t, normalWorkerDone)
13901405 requireNoWake(t, h.wakes)
13911406
13921407 h.rc.mu.Lock()
@@ -657,9 +657,14 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
657 require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)657 require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
658 658
659 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})659 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
660- for _, timer := range h.armedTimers(t) {660+ timers := h.armedTimers(t)
661- timer.ch <- time.Time{}661+ require.Len(t, timers, 1)
662- }662+ h.rc.mu.Lock()
663+ normalWorkerDone := h.rc.normalWorkerDone
664+ h.rc.mu.Unlock()
665+ require.NotNil(t, normalWorkerDone)
666+ timers[0].ch <- time.Time{}
667+ requireWorkerExit(t, normalWorkerDone)
663 requireNoWake(t, h.wakes)668 requireNoWake(t, h.wakes)
664 669
665 h.syncActive.Store(false)670 h.syncActive.Store(false)
@@ -1354,7 +1359,12 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1354 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})1359 h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})
1355 timers := h.armedTimers(t)1360 timers := h.armedTimers(t)
1356 require.Len(t, timers, 1)1361 require.Len(t, timers, 1)
1362+ h.rc.mu.Lock()
1363+ normalWorkerDone := h.rc.normalWorkerDone
1364+ h.rc.mu.Unlock()
1365+ require.NotNil(t, normalWorkerDone)
1357 timers[0].ch <- time.Time{}1366 timers[0].ch <- time.Time{}
1367+ requireWorkerExit(t, normalWorkerDone)
1358 requireNoWake(t, h.previews)1368 requireNoWake(t, h.previews)
1359 1369
1360 h.rc.mu.Lock()1370 h.rc.mu.Lock()
@@ -1386,7 +1396,12 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1386 h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})1396 h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
1387 timers := h.armedTimers(t)1397 timers := h.armedTimers(t)
1388 require.Len(t, timers, 1)1398 require.Len(t, timers, 1)
1399+ h.rc.mu.Lock()
1400+ normalWorkerDone := h.rc.normalWorkerDone
1401+ h.rc.mu.Unlock()
1402+ require.NotNil(t, normalWorkerDone)
1389 timers[0].ch <- time.Time{}1403 timers[0].ch <- time.Time{}
1404+ requireWorkerExit(t, normalWorkerDone)
1390 requireNoWake(t, h.wakes)1405 requireNoWake(t, h.wakes)
1391 1406
1392 h.rc.mu.Lock()1407 h.rc.mu.Lock()
M internal/usecase/daemon/render_test.go +3 -2
@@ -194,6 +194,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
194194
195195 inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
196196 awaitPTYReadProcessed(t, inactiveProcessed)
197+ fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)
197198 frame := awaitOutputFrameWithoutSleep(t, sends)
198199 output, err := ports.UnmarshalOutput(frame.Payload)
199200 require.NoError(t, err)
@@ -277,7 +278,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
277278 fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)
278279 wake := <-previews
279280 require.True(t, wake.urgent)
280- require.Equal(t, 1, wake.coalesced)
281+ require.Equal(t, 2, wake.coalesced, "completion retains coordinator coalescing across the cleared attachment work")
281282 select {
282283 case duplicate := <-previews:
283284 t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
@@ -320,7 +321,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
320321 wake := <-wakes
321322 require.True(t, wake.urgent)
322323 require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
323- require.Equal(t, 2, wake.coalesced, "completion must coalesce with the replacement reset")
324+ require.Equal(t, 3, wake.coalesced, "completion must retain the replacement reset and coordinator coalescing")
324325 require.Same(t, replacement, wake.attachment)
325326 select {
326327 case duplicate := <-wakes:
@@ -194,6 +194,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
194 194
195 inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}195 inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
196 awaitPTYReadProcessed(t, inactiveProcessed)196 awaitPTYReadProcessed(t, inactiveProcessed)
197+ fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)
197 frame := awaitOutputFrameWithoutSleep(t, sends)198 frame := awaitOutputFrameWithoutSleep(t, sends)
198 output, err := ports.UnmarshalOutput(frame.Payload)199 output, err := ports.UnmarshalOutput(frame.Payload)
199 require.NoError(t, err)200 require.NoError(t, err)
@@ -277,7 +278,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
277 fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)278 fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)
278 wake := <-previews279 wake := <-previews
279 require.True(t, wake.urgent)280 require.True(t, wake.urgent)
280- require.Equal(t, 1, wake.coalesced)281+ require.Equal(t, 2, wake.coalesced, "completion retains coordinator coalescing across the cleared attachment work")
281 select {282 select {
282 case duplicate := <-previews:283 case duplicate := <-previews:
283 t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)284 t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
@@ -320,7 +321,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
320 wake := <-wakes321 wake := <-wakes
321 require.True(t, wake.urgent)322 require.True(t, wake.urgent)
322 require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")323 require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
323- require.Equal(t, 2, wake.coalesced, "completion must coalesce with the replacement reset")324+ require.Equal(t, 3, wake.coalesced, "completion must retain the replacement reset and coordinator coalescing")
324 require.Same(t, replacement, wake.attachment)325 require.Same(t, replacement, wake.attachment)
325 select {326 select {
326 case duplicate := <-wakes:327 case duplicate := <-wakes:

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 2

Tags 13

↑↓ moveenter openesc close