detached ac3765a

fix(daemon): stabilize render coordinator lifecycle

commit
ac3765a5c97a0845c152dc6d9f882225bc4b378f
author
brice · · 2026-07-11 16:12 UTC
parent
ea71bd4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +41 -15
M internal/usecase/daemon/client.go +1 -1
@@ -441,7 +441,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
441441 d.resizeForFirstPaint(sess, ac, clientSize)
442442 }
443443 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
444- d.invalidateRender(sess, ac, true, "client.go")
444+ d.invalidateRenderNow(sess, ac, true, "client.go")
445445 d.activateTab(sess, tb)
446446 }
447447
@@ -441,7 +441,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
441 d.resizeForFirstPaint(sess, ac, clientSize)441 d.resizeForFirstPaint(sess, ac, clientSize)
442 }442 }
443 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)443 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
444- d.invalidateRender(sess, ac, true, "client.go")444+ d.invalidateRenderNow(sess, ac, true, "client.go")
445 d.activateTab(sess, tb)445 d.activateTab(sess, tb)
446 }446 }
447 447
M internal/usecase/daemon/output_state_sync_test.go +2 -2
@@ -388,10 +388,10 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
388388 require.Equal(t, uint64(1), firstOutput.NewStateNum)
389389 for range 100 {
390390 pane.screen.Write([]byte("x"))
391- d.paint(sess, ac, false)
391+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
392392 }
393393 pane.screen.Write([]byte("LATEST"))
394- d.paint(sess, ac, false)
394+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
395395 requireNoOutputFrame(t, tr.sends)
396396 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
397397
@@ -388,10 +388,10 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
388 require.Equal(t, uint64(1), firstOutput.NewStateNum)388 require.Equal(t, uint64(1), firstOutput.NewStateNum)
389 for range 100 {389 for range 100 {
390 pane.screen.Write([]byte("x"))390 pane.screen.Write([]byte("x"))
391- d.paint(sess, ac, false)391+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
392 }392 }
393 pane.screen.Write([]byte("LATEST"))393 pane.screen.Write([]byte("LATEST"))
394- d.paint(sess, ac, false)394+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
395 requireNoOutputFrame(t, tr.sends)395 requireNoOutputFrame(t, tr.sends)
396 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")396 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
397 397
M internal/usecase/daemon/render_coordinator.go +17 -0
@@ -402,6 +402,22 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
402402 }
403403 }
404404
405+// invalidateRenderNow publishes through the coordinator but immediately
406+// flushes the wake when the client can accept state. Attach uses this path so
407+// the required first full frame never depends on a debounce timer.
408+func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
409+ if sess != nil {
410+ if rc := sess.renderCoordinator(); rc != nil {
411+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
412+ rc.fireCurrent(false)
413+ return
414+ }
415+ }
416+ if ac != nil {
417+ d.paint(sess, ac, reset)
418+ }
419+}
420+
405421 func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
406422 c.mu.Lock()
407423 if c.torndown || !c.pending || (!watchdog && gen != c.generation) {
@@ -419,6 +435,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
419435 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
420436 c.metrics.wakes.Add(1)
421437 c.metrics.coalesced.Add(uint64(w.coalesced))
438+ c.cancelNormalTimerLocked()
422439 c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false
423440 wake, preview := c.opts.wake, c.previewWake
424441 c.mu.Unlock()
@@ -402,6 +402,22 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
402 }402 }
403 }403 }
404 404
405+// invalidateRenderNow publishes through the coordinator but immediately
406+// flushes the wake when the client can accept state. Attach uses this path so
407+// the required first full frame never depends on a debounce timer.
408+func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
409+ if sess != nil {
410+ if rc := sess.renderCoordinator(); rc != nil {
411+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
412+ rc.fireCurrent(false)
413+ return
414+ }
415+ }
416+ if ac != nil {
417+ d.paint(sess, ac, reset)
418+ }
419+}
420+
405 func (c *renderCoordinator) fire(gen uint64, watchdog bool) {421 func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
406 c.mu.Lock()422 c.mu.Lock()
407 if c.torndown || !c.pending || (!watchdog && gen != c.generation) {423 if c.torndown || !c.pending || (!watchdog && gen != c.generation) {
@@ -419,6 +435,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
419 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}435 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
420 c.metrics.wakes.Add(1)436 c.metrics.wakes.Add(1)
421 c.metrics.coalesced.Add(uint64(w.coalesced))437 c.metrics.coalesced.Add(uint64(w.coalesced))
438+ c.cancelNormalTimerLocked()
422 c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false439 c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false
423 wake, preview := c.opts.wake, c.previewWake440 wake, preview := c.opts.wake, c.previewWake
424 c.mu.Unlock()441 c.mu.Unlock()
M internal/usecase/daemon/status_test.go +21 -12
@@ -1309,10 +1309,10 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
13091309 }
13101310 }
13111311
1312-func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1312+func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
13131313 p1, releasePTY1 := newBlockingPTY(t)
13141314 p2, releasePTY2 := newBlockingPTY(t)
1315- clock := &signalClock{timers: make(chan *signalTimer, 1)}
1315+ clock := &signalClock{timers: make(chan *signalTimer, 64)}
13161316 d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock)
13171317 tr, sends, releaseConn := newConn(t,
13181318 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
@@ -1326,16 +1326,25 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
13261326 hg.Go(func() { d.handleConn(tr) })
13271327 awaitFrame(t, sends, ports.MsgWelcome)
13281328 first := awaitFrame(t, sends, ports.MsgOutput)
1329- palette := awaitFrame(t, sends, ports.MsgOutput)
1330- created := awaitFrame(t, sends, ports.MsgOutput)
1331- switched := awaitFrame(t, sends, ports.MsgOutput)
1332- // The resize frame is intentionally idle-coalesced; fire its fake-clock
1333- // deadline rather than relying on the old synchronous behavior.
1334- (<-clock.timers).ch <- time.Time{}
1335- resized := awaitFrame(t, sends, ports.MsgOutput)
1336-
1337- _ = palette
1338- for _, f := range []ports.Frame{first, created, switched, resized} {
1329+ require.Eventually(t, func() bool {
1330+ sessions := listSessions(t, d)
1331+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
1332+ }, 2*time.Second, time.Millisecond)
1333+
1334+ // The queued create, switch, and resize transitions may collapse into one
1335+ // latest-state wake. Advance every retained PR #71/coordinator deadline
1336+ // until that coalesced output is observable.
1337+ var resized ports.Frame
1338+ for resized.Type != ports.MsgOutput {
1339+ select {
1340+ case f := <-sends:
1341+ resized = f
1342+ case timer := <-clock.timers:
1343+ timer.ch <- time.Time{}
1344+ }
1345+ }
1346+
1347+ for _, f := range []ports.Frame{first, resized} {
13391348 out, err := ports.UnmarshalOutput(f.Payload)
13401349 require.NoError(t, err)
13411350 require.Contains(t, string(out.Data), "work")
@@ -1309,10 +1309,10 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1309 }1309 }
1310 }1310 }
1311 1311
1312-func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {1312+func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1313 p1, releasePTY1 := newBlockingPTY(t)1313 p1, releasePTY1 := newBlockingPTY(t)
1314 p2, releasePTY2 := newBlockingPTY(t)1314 p2, releasePTY2 := newBlockingPTY(t)
1315- clock := &signalClock{timers: make(chan *signalTimer, 1)}1315+ clock := &signalClock{timers: make(chan *signalTimer, 64)}
1316 d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock)1316 d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock)
1317 tr, sends, releaseConn := newConn(t,1317 tr, sends, releaseConn := newConn(t,
1318 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),1318 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
@@ -1326,16 +1326,25 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1326 hg.Go(func() { d.handleConn(tr) })1326 hg.Go(func() { d.handleConn(tr) })
1327 awaitFrame(t, sends, ports.MsgWelcome)1327 awaitFrame(t, sends, ports.MsgWelcome)
1328 first := awaitFrame(t, sends, ports.MsgOutput)1328 first := awaitFrame(t, sends, ports.MsgOutput)
1329- palette := awaitFrame(t, sends, ports.MsgOutput)1329+ require.Eventually(t, func() bool {
1330- created := awaitFrame(t, sends, ports.MsgOutput)1330+ sessions := listSessions(t, d)
1331- switched := awaitFrame(t, sends, ports.MsgOutput)1331+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
1332- // The resize frame is intentionally idle-coalesced; fire its fake-clock1332+ }, 2*time.Second, time.Millisecond)
1333- // deadline rather than relying on the old synchronous behavior.1333+
1334- (<-clock.timers).ch <- time.Time{}1334+ // The queued create, switch, and resize transitions may collapse into one
1335- resized := awaitFrame(t, sends, ports.MsgOutput)1335+ // latest-state wake. Advance every retained PR #71/coordinator deadline
1336-1336+ // until that coalesced output is observable.
1337- _ = palette1337+ var resized ports.Frame
1338- for _, f := range []ports.Frame{first, created, switched, resized} {1338+ for resized.Type != ports.MsgOutput {
1339+ select {
1340+ case f := <-sends:
1341+ resized = f
1342+ case timer := <-clock.timers:
1343+ timer.ch <- time.Time{}
1344+ }
1345+ }
1346+
1347+ for _, f := range []ports.Frame{first, resized} {
1339 out, err := ports.UnmarshalOutput(f.Payload)1348 out, err := ports.UnmarshalOutput(f.Payload)
1340 require.NoError(t, err)1349 require.NoError(t, err)
1341 require.Contains(t, string(out.Data), "work")1350 require.Contains(t, string(out.Data), "work")

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