fix(daemon): stabilize render coordinator lifecycle
- commit
- ac3765a5c97a0845c152dc6d9f882225bc4b378f
- author
- brice · · 2026-07-11 16:12 UTC
- parent
- ea71bd4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 441 | 441 | d.resizeForFirstPaint(sess, ac, clientSize) | |
| 442 | 442 | } | |
| 443 | 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 | 445 | d.activateTab(sess, tb) | |
| 446 | 446 | } | |
| 447 | 447 | ||
| @@ -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) { | |||
| 388 | 388 | require.Equal(t, uint64(1), firstOutput.NewStateNum) | |
| 389 | 389 | for range 100 { | |
| 390 | 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 | 393 | pane.screen.Write([]byte("LATEST")) | |
| 394 | - | d.paint(sess, ac, false) | |
| 394 | + | d.invalidateRender(sess, ac, false, "output_state_sync_test.go") | |
| 395 | 395 | requireNoOutputFrame(t, tr.sends) | |
| 396 | 396 | require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged") | |
| 397 | 397 | ||
| @@ -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, | |||
| 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 | 421 | func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |
| 406 | 422 | c.mu.Lock() | |
| 407 | 423 | if c.torndown || !c.pending || (!watchdog && gen != c.generation) { | |
| @@ -419,6 +435,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||
| 419 | 435 | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog} | |
| 420 | 436 | c.metrics.wakes.Add(1) | |
| 421 | 437 | c.metrics.coalesced.Add(uint64(w.coalesced)) | |
| 438 | + | c.cancelNormalTimerLocked() | |
| 422 | 439 | c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false | |
| 423 | 440 | wake, preview := c.opts.wake, c.previewWake | |
| 424 | 441 | 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, false | 439 | c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false | ||
| 423 | wake, preview := c.opts.wake, c.previewWake | 440 | 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) { | |||
| 1309 | 1309 | } | |
| 1310 | 1310 | } | |
| 1311 | 1311 | ||
| 1312 | - | func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |
| 1312 | + | func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |
| 1313 | 1313 | p1, releasePTY1 := newBlockingPTY(t) | |
| 1314 | 1314 | p2, releasePTY2 := newBlockingPTY(t) | |
| 1315 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1315 | + | clock := &signalClock{timers: make(chan *signalTimer, 64)} | |
| 1316 | 1316 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock) | |
| 1317 | 1317 | tr, sends, releaseConn := newConn(t, | |
| 1318 | 1318 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}), | |
| @@ -1326,16 +1326,25 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||
| 1326 | 1326 | hg.Go(func() { d.handleConn(tr) }) | |
| 1327 | 1327 | awaitFrame(t, sends, ports.MsgWelcome) | |
| 1328 | 1328 | 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} { | |
| 1339 | 1348 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 1340 | 1349 | require.NoError(t, err) | |
| 1341 | 1350 | 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-clock | 1332 | + | }, 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 | - | _ = palette | 1337 | + | 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") | ||