fix(daemon): stabilize transactional resize completion
- commit
- 2f744a4caeeadccda766d52aee2267aa1d70a9e5
- author
- brice · · 2026-07-12 09:36 UTC
- parent
- bf46590
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +63 -10
M internal/usecase/daemon/floating.go +14 -0
| @@ -306,6 +306,20 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 306 | 306 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | |
| 307 | 307 | // occurs under tb.mu; the pane is fully initialized before publication. | |
| 308 | 308 | func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) { | |
| 309 | + | // Launch workers are allowed to outlive the action that queued them, but | |
| 310 | + | // never session teardown. Check both cancellation and daemon ownership | |
| 311 | + | // immediately before the irreversible external Open. | |
| 312 | + | select { | |
| 313 | + | case <-spec.parentCtx.Done(): | |
| 314 | + | return | |
| 315 | + | default: | |
| 316 | + | } | |
| 317 | + | d.mu.Lock() | |
| 318 | + | live := !d.closing && d.sessions[sess.id] == sess | |
| 319 | + | d.mu.Unlock() | |
| 320 | + | if !live { | |
| 321 | + | return | |
| 322 | + | } | |
| 309 | 323 | pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size) | |
| 310 | 324 | if err != nil { | |
| 311 | 325 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | |
| @@ -306,6 +306,20 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 306 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | 306 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | ||
| 307 | // occurs under tb.mu; the pane is fully initialized before publication. | 307 | // occurs under tb.mu; the pane is fully initialized before publication. | ||
| 308 | func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) { | 308 | func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) { | ||
| 309 | + | // Launch workers are allowed to outlive the action that queued them, but | |||
| 310 | + | // never session teardown. Check both cancellation and daemon ownership | |||
| 311 | + | // immediately before the irreversible external Open. | |||
| 312 | + | select { | |||
| 313 | + | case <-spec.parentCtx.Done(): | |||
| 314 | + | return | |||
| 315 | + | default: | |||
| 316 | + | } | |||
| 317 | + | d.mu.Lock() | |||
| 318 | + | live := !d.closing && d.sessions[sess.id] == sess | |||
| 319 | + | d.mu.Unlock() | |||
| 320 | + | if !live { | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 309 | pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size) | 323 | pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size) | ||
| 310 | if err != nil { | 324 | if err != nil { | ||
| 311 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | 325 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | ||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 258 | 258 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 259 | 259 | } | |
| 260 | 260 | ||
| 261 | - | func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) { | |
| 261 | + | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |
| 262 | 262 | p1, releasePTY1 := newBlockingPTY(t) | |
| 263 | 263 | p2, releasePTY2 := newBlockingPTY(t) | |
| 264 | 264 | defer releasePTY1() | |
| @@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 258 | awaitFrame(t, sends1, ports.MsgOutput) | 258 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| 259 | } | 259 | } | ||
| 260 | 260 | ||||
| 261 | - | func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) { | 261 | + | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { |
| 262 | p1, releasePTY1 := newBlockingPTY(t) | 262 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 263 | p2, releasePTY2 := newBlockingPTY(t) | 263 | p2, releasePTY2 := newBlockingPTY(t) | ||
| 264 | defer releasePTY1() | 264 | defer releasePTY1() | ||
M internal/usecase/daemon/render_coordinator.go +19 -1
| @@ -164,7 +164,11 @@ type renderCoordinator struct { | |||
| 164 | 164 | // resize burst coalescing and is cancelled by the same lifecycle changes. | |
| 165 | 165 | resizeTimer ports.Timer | |
| 166 | 166 | resizeCancel chan struct{} | |
| 167 | - | resizeGen uint64 | |
| 167 | + | // resizeDone closes after the latest timer callback has either been | |
| 168 | + | // rejected or completed its transaction. It provides a completion edge | |
| 169 | + | // without polling scheduler state. | |
| 170 | + | resizeDone chan struct{} | |
| 171 | + | resizeGen uint64 | |
| 168 | 172 | // retry is a separate, latest-committed-epoch retry lane. It is cancelled | |
| 169 | 173 | // with resize requests and attachment lifecycle changes; callbacks also | |
| 170 | 174 | // validate their epoch before touching a pane. | |
| @@ -783,13 +787,16 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli | |||
| 783 | 787 | c.resizeGen++ | |
| 784 | 788 | gen := c.resizeGen | |
| 785 | 789 | cancel := make(chan struct{}) | |
| 790 | + | done := make(chan struct{}) | |
| 786 | 791 | c.resizeCancel = cancel | |
| 792 | + | c.resizeDone = done | |
| 787 | 793 | clock := c.opts.clock | |
| 788 | 794 | c.resizeTimer = nil | |
| 789 | 795 | c.mu.Unlock() | |
| 790 | 796 | stopTimer(old) | |
| 791 | 797 | if clock == nil { | |
| 792 | 798 | run(epoch) | |
| 799 | + | close(done) | |
| 793 | 800 | return epoch | |
| 794 | 801 | } | |
| 795 | 802 | timer := clock.NewTimer(minOutputRenderDeadline) | |
| @@ -797,17 +804,20 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli | |||
| 797 | 804 | if timerC == nil { | |
| 798 | 805 | stopTimer(timer) | |
| 799 | 806 | run(epoch) | |
| 807 | + | close(done) | |
| 800 | 808 | return epoch | |
| 801 | 809 | } | |
| 802 | 810 | c.mu.Lock() | |
| 803 | 811 | if c.resizeGen != gen || c.torndown { | |
| 804 | 812 | c.mu.Unlock() | |
| 805 | 813 | stopTimer(timer) | |
| 814 | + | close(done) | |
| 806 | 815 | return epoch | |
| 807 | 816 | } | |
| 808 | 817 | c.resizeTimer = timer | |
| 809 | 818 | c.mu.Unlock() | |
| 810 | 819 | go func() { | |
| 820 | + | defer close(done) | |
| 811 | 821 | select { | |
| 812 | 822 | case <-timerC: | |
| 813 | 823 | case <-cancel: | |
| @@ -912,6 +922,14 @@ func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) b | |||
| 912 | 922 | return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch | |
| 913 | 923 | } | |
| 914 | 924 | ||
| 925 | + | // resizeCallbackDone returns the completion edge for the latest scheduled | |
| 926 | + | // resize callback. A nil result means no resize deadline is pending. | |
| 927 | + | func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} { | |
| 928 | + | c.mu.Lock() | |
| 929 | + | defer c.mu.Unlock() | |
| 930 | + | return c.resizeDone | |
| 931 | + | } | |
| 932 | + | ||
| 915 | 933 | // resizeSnapshot returns a locked copy of the latest resize metadata. | |
| 916 | 934 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | |
| 917 | 935 | c.mu.Lock() | |
| @@ -164,7 +164,11 @@ type renderCoordinator struct { | |||||
| 164 | // resize burst coalescing and is cancelled by the same lifecycle changes. | 164 | // resize burst coalescing and is cancelled by the same lifecycle changes. | ||
| 165 | resizeTimer ports.Timer | 165 | resizeTimer ports.Timer | ||
| 166 | resizeCancel chan struct{} | 166 | resizeCancel chan struct{} | ||
| 167 | - | resizeGen uint64 | 167 | + | // resizeDone closes after the latest timer callback has either been |
| 168 | + | // rejected or completed its transaction. It provides a completion edge | |||
| 169 | + | // without polling scheduler state. | |||
| 170 | + | resizeDone chan struct{} | |||
| 171 | + | resizeGen uint64 | |||
| 168 | // retry is a separate, latest-committed-epoch retry lane. It is cancelled | 172 | // retry is a separate, latest-committed-epoch retry lane. It is cancelled | ||
| 169 | // with resize requests and attachment lifecycle changes; callbacks also | 173 | // with resize requests and attachment lifecycle changes; callbacks also | ||
| 170 | // validate their epoch before touching a pane. | 174 | // validate their epoch before touching a pane. | ||
| @@ -783,13 +787,16 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli | |||||
| 783 | c.resizeGen++ | 787 | c.resizeGen++ | ||
| 784 | gen := c.resizeGen | 788 | gen := c.resizeGen | ||
| 785 | cancel := make(chan struct{}) | 789 | cancel := make(chan struct{}) | ||
| 790 | + | done := make(chan struct{}) | |||
| 786 | c.resizeCancel = cancel | 791 | c.resizeCancel = cancel | ||
| 792 | + | c.resizeDone = done | |||
| 787 | clock := c.opts.clock | 793 | clock := c.opts.clock | ||
| 788 | c.resizeTimer = nil | 794 | c.resizeTimer = nil | ||
| 789 | c.mu.Unlock() | 795 | c.mu.Unlock() | ||
| 790 | stopTimer(old) | 796 | stopTimer(old) | ||
| 791 | if clock == nil { | 797 | if clock == nil { | ||
| 792 | run(epoch) | 798 | run(epoch) | ||
| 799 | + | close(done) | |||
| 793 | return epoch | 800 | return epoch | ||
| 794 | } | 801 | } | ||
| 795 | timer := clock.NewTimer(minOutputRenderDeadline) | 802 | timer := clock.NewTimer(minOutputRenderDeadline) | ||
| @@ -797,17 +804,20 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli | |||||
| 797 | if timerC == nil { | 804 | if timerC == nil { | ||
| 798 | stopTimer(timer) | 805 | stopTimer(timer) | ||
| 799 | run(epoch) | 806 | run(epoch) | ||
| 807 | + | close(done) | |||
| 800 | return epoch | 808 | return epoch | ||
| 801 | } | 809 | } | ||
| 802 | c.mu.Lock() | 810 | c.mu.Lock() | ||
| 803 | if c.resizeGen != gen || c.torndown { | 811 | if c.resizeGen != gen || c.torndown { | ||
| 804 | c.mu.Unlock() | 812 | c.mu.Unlock() | ||
| 805 | stopTimer(timer) | 813 | stopTimer(timer) | ||
| 814 | + | close(done) | |||
| 806 | return epoch | 815 | return epoch | ||
| 807 | } | 816 | } | ||
| 808 | c.resizeTimer = timer | 817 | c.resizeTimer = timer | ||
| 809 | c.mu.Unlock() | 818 | c.mu.Unlock() | ||
| 810 | go func() { | 819 | go func() { | ||
| 820 | + | defer close(done) | |||
| 811 | select { | 821 | select { | ||
| 812 | case <-timerC: | 822 | case <-timerC: | ||
| 813 | case <-cancel: | 823 | case <-cancel: | ||
| @@ -912,6 +922,14 @@ func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) b | |||||
| 912 | return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch | 922 | return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch | ||
| 913 | } | 923 | } | ||
| 914 | 924 | ||||
| 925 | + | // resizeCallbackDone returns the completion edge for the latest scheduled | |||
| 926 | + | // resize callback. A nil result means no resize deadline is pending. | |||
| 927 | + | func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} { | |||
| 928 | + | c.mu.Lock() | |||
| 929 | + | defer c.mu.Unlock() | |||
| 930 | + | return c.resizeDone | |||
| 931 | + | } | |||
| 932 | + | ||||
| 915 | // resizeSnapshot returns a locked copy of the latest resize metadata. | 933 | // resizeSnapshot returns a locked copy of the latest resize metadata. | ||
| 916 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | 934 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | ||
| 917 | c.mu.Lock() | 935 | c.mu.Lock() | ||
M internal/usecase/daemon/render_coordinator_publication_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin | |||
| 16 | 16 | rc.attach(old) | |
| 17 | 17 | rc.noteReplace(old, replacement) | |
| 18 | 18 | ||
| 19 | - | // This models a PR #71 resize callback that passed its sendMu generation | |
| 19 | + | // This models a stale resize callback that passed its sendMu generation | |
| 20 | 20 | // check immediately before a replacement was published. | |
| 21 | 21 | rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"}) | |
| 22 | 22 | require.Empty(t, invalidations) | |
| @@ -16,7 +16,7 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin | |||||
| 16 | rc.attach(old) | 16 | rc.attach(old) | ||
| 17 | rc.noteReplace(old, replacement) | 17 | rc.noteReplace(old, replacement) | ||
| 18 | 18 | ||||
| 19 | - | // This models a PR #71 resize callback that passed its sendMu generation | 19 | + | // This models a stale resize callback that passed its sendMu generation |
| 20 | // check immediately before a replacement was published. | 20 | // check immediately before a replacement was published. | ||
| 21 | rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"}) | 21 | rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"}) | ||
| 22 | require.Empty(t, invalidations) | 22 | require.Empty(t, invalidations) | ||
M internal/usecase/daemon/render_coordinator_test.go +18 -3
| @@ -198,6 +198,15 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) { | |||
| 198 | 198 | } | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | + | func awaitResizeCallback(t *testing.T, rc *renderCoordinator) { | |
| 202 | + | t.Helper() | |
| 203 | + | done := rc.resizeCallbackDone() | |
| 204 | + | if done == nil { | |
| 205 | + | t.Fatal("coordinator did not publish a resize callback completion") | |
| 206 | + | } | |
| 207 | + | <-done | |
| 208 | + | } | |
| 209 | + | ||
| 201 | 210 | func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer { | |
| 202 | 211 | t.Helper() | |
| 203 | 212 | select { | |
| @@ -1023,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||
| 1023 | 1032 | h.rc.attach(staleOwner) | |
| 1024 | 1033 | require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner)) | |
| 1025 | 1034 | ||
| 1026 | - | // This models a retained PR #71 callback which captured the old | |
| 1035 | + | // This models a stale callback which captured the old | |
| 1027 | 1036 | // attachment before lifecycle ownership changed. | |
| 1028 | 1037 | staleCallback := func() uint64 { | |
| 1029 | 1038 | return h.rc.recordResizeRequest(sz(80, 20), staleOwner) | |
| @@ -1164,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1164 | 1173 | name: "retained resize dispatch", | |
| 1165 | 1174 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1166 | 1175 | clk := newCoordinatorMockClock(t, 2) | |
| 1167 | - | d.clock = clk.clock | |
| 1176 | + | // The coordinator owns deadline time; install the deterministic | |
| 1177 | + | // clock on the already-attached coordinator rather than changing | |
| 1178 | + | // Daemon's construction-time clock afterwards. | |
| 1179 | + | sess.renderCoordinator().opts.clock = clk.clock | |
| 1168 | 1180 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26}) | |
| 1169 | 1181 | awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{} | |
| 1182 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 1170 | 1183 | }, | |
| 1171 | 1184 | }, | |
| 1172 | 1185 | { | |
| @@ -1232,7 +1245,7 @@ func TestProducerInvalidationInventory(t *testing.T) { | |||
| 1232 | 1245 | } | |
| 1233 | 1246 | } | |
| 1234 | 1247 | ||
| 1235 | - | // --- retained PR #71 resize dispatch ---------------------------------------------- | |
| 1248 | + | // --- coordinator resize dispatch ---------------------------------------------- | |
| 1236 | 1249 | ||
| 1237 | 1250 | func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) { | |
| 1238 | 1251 | p, release := newBlockingPTY(t) | |
| @@ -1481,6 +1494,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||
| 1481 | 1494 | requireNoCoordinatorOutputFrame(t, sends) | |
| 1482 | 1495 | ||
| 1483 | 1496 | timer.ch <- time.Time{} | |
| 1497 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 1484 | 1498 | inv := awaitInvalidation(t, invs) | |
| 1485 | 1499 | require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation") | |
| 1486 | 1500 | requireNoInvalidation(t, invs) | |
| @@ -1502,6 +1516,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||
| 1502 | 1516 | requireNoCoordinatorOutputFrame(t, sends) | |
| 1503 | 1517 | ||
| 1504 | 1518 | latest.ch <- time.Time{} | |
| 1519 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 1505 | 1520 | inv := awaitInvalidation(t, invs) | |
| 1506 | 1521 | require.True(t, inv.reset) | |
| 1507 | 1522 | requireNoInvalidation(t, invs) | |
| @@ -198,6 +198,15 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) { | |||||
| 198 | } | 198 | } | ||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | + | func awaitResizeCallback(t *testing.T, rc *renderCoordinator) { | |||
| 202 | + | t.Helper() | |||
| 203 | + | done := rc.resizeCallbackDone() | |||
| 204 | + | if done == nil { | |||
| 205 | + | t.Fatal("coordinator did not publish a resize callback completion") | |||
| 206 | + | } | |||
| 207 | + | <-done | |||
| 208 | + | } | |||
| 209 | + | ||||
| 201 | func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer { | 210 | func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer { | ||
| 202 | t.Helper() | 211 | t.Helper() | ||
| 203 | select { | 212 | select { | ||
| @@ -1023,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||||
| 1023 | h.rc.attach(staleOwner) | 1032 | h.rc.attach(staleOwner) | ||
| 1024 | require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner)) | 1033 | require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner)) | ||
| 1025 | 1034 | ||||
| 1026 | - | // This models a retained PR #71 callback which captured the old | 1035 | + | // This models a stale callback which captured the old |
| 1027 | // attachment before lifecycle ownership changed. | 1036 | // attachment before lifecycle ownership changed. | ||
| 1028 | staleCallback := func() uint64 { | 1037 | staleCallback := func() uint64 { | ||
| 1029 | return h.rc.recordResizeRequest(sz(80, 20), staleOwner) | 1038 | return h.rc.recordResizeRequest(sz(80, 20), staleOwner) | ||
| @@ -1164,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1164 | name: "retained resize dispatch", | 1173 | name: "retained resize dispatch", | ||
| 1165 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | 1174 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | ||
| 1166 | clk := newCoordinatorMockClock(t, 2) | 1175 | clk := newCoordinatorMockClock(t, 2) | ||
| 1167 | - | d.clock = clk.clock | 1176 | + | // The coordinator owns deadline time; install the deterministic |
| 1177 | + | // clock on the already-attached coordinator rather than changing | |||
| 1178 | + | // Daemon's construction-time clock afterwards. | |||
| 1179 | + | sess.renderCoordinator().opts.clock = clk.clock | |||
| 1168 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26}) | 1180 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26}) | ||
| 1169 | awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{} | 1181 | awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{} | ||
| 1182 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |||
| 1170 | }, | 1183 | }, | ||
| 1171 | }, | 1184 | }, | ||
| 1172 | { | 1185 | { | ||
| @@ -1232,7 +1245,7 @@ func TestProducerInvalidationInventory(t *testing.T) { | |||||
| 1232 | } | 1245 | } | ||
| 1233 | } | 1246 | } | ||
| 1234 | 1247 | ||||
| 1235 | - | // --- retained PR #71 resize dispatch ---------------------------------------------- | 1248 | + | // --- coordinator resize dispatch ---------------------------------------------- |
| 1236 | 1249 | ||||
| 1237 | func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) { | 1250 | func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) { | ||
| 1238 | p, release := newBlockingPTY(t) | 1251 | p, release := newBlockingPTY(t) | ||
| @@ -1481,6 +1494,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||||
| 1481 | requireNoCoordinatorOutputFrame(t, sends) | 1494 | requireNoCoordinatorOutputFrame(t, sends) | ||
| 1482 | 1495 | ||||
| 1483 | timer.ch <- time.Time{} | 1496 | timer.ch <- time.Time{} | ||
| 1497 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |||
| 1484 | inv := awaitInvalidation(t, invs) | 1498 | inv := awaitInvalidation(t, invs) | ||
| 1485 | require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation") | 1499 | require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation") | ||
| 1486 | requireNoInvalidation(t, invs) | 1500 | requireNoInvalidation(t, invs) | ||
| @@ -1502,6 +1516,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||||
| 1502 | requireNoCoordinatorOutputFrame(t, sends) | 1516 | requireNoCoordinatorOutputFrame(t, sends) | ||
| 1503 | 1517 | ||||
| 1504 | latest.ch <- time.Time{} | 1518 | latest.ch <- time.Time{} | ||
| 1519 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |||
| 1505 | inv := awaitInvalidation(t, invs) | 1520 | inv := awaitInvalidation(t, invs) | ||
| 1506 | require.True(t, inv.reset) | 1521 | require.True(t, inv.reset) | ||
| 1507 | requireNoInvalidation(t, invs) | 1522 | requireNoInvalidation(t, invs) | ||
M internal/usecase/daemon/status_test.go +1 -1
| @@ -1332,7 +1332,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |||
| 1332 | 1332 | }, 2*time.Second, time.Millisecond) | |
| 1333 | 1333 | ||
| 1334 | 1334 | // The queued create, switch, and resize transitions may collapse into one | |
| 1335 | - | // latest-state wake. Advance every retained PR #71/coordinator deadline | |
| 1335 | + | // latest-state wake. Advance every retained coordinator deadline | |
| 1336 | 1336 | // until that coalesced output is observable. | |
| 1337 | 1337 | var resized ports.Frame | |
| 1338 | 1338 | deadline := time.NewTimer(2 * time.Second) | |
| @@ -1332,7 +1332,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |||||
| 1332 | }, 2*time.Second, time.Millisecond) | 1332 | }, 2*time.Second, time.Millisecond) | ||
| 1333 | 1333 | ||||
| 1334 | // The queued create, switch, and resize transitions may collapse into one | 1334 | // The queued create, switch, and resize transitions may collapse into one | ||
| 1335 | - | // latest-state wake. Advance every retained PR #71/coordinator deadline | 1335 | + | // latest-state wake. Advance every retained coordinator deadline |
| 1336 | // until that coalesced output is observable. | 1336 | // until that coalesced output is observable. | ||
| 1337 | var resized ports.Frame | 1337 | var resized ports.Frame | ||
| 1338 | deadline := time.NewTimer(2 * time.Second) | 1338 | deadline := time.NewTimer(2 * time.Second) | ||
M internal/usecase/daemon/transactional_resize.go +8 -0
| @@ -43,6 +43,14 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |||
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | + | } else if tb.tree.Root.Kind == layout.Leaf { | |
| 47 | + | // A single pane remains a valid PTY target even when a tiny client | |
| 48 | + | // is below the interactive layout minimum. Keeping it live avoids | |
| 49 | + | // stale screen dimensions (and lost scrollback) until the client | |
| 50 | + | // grows back into a solvable layout. | |
| 51 | + | if p := tb.panes[tb.tree.Root.Leaf]; p != nil { | |
| 52 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area}) | |
| 53 | + | } | |
| 46 | 54 | } | |
| 47 | 55 | } | |
| 48 | 56 | // Hidden retained popups enter this same primitive when shown; only a | |
| @@ -43,6 +43,14 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |||||
| 43 | } | 43 | } | ||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | + | } else if tb.tree.Root.Kind == layout.Leaf { | |||
| 47 | + | // A single pane remains a valid PTY target even when a tiny client | |||
| 48 | + | // is below the interactive layout minimum. Keeping it live avoids | |||
| 49 | + | // stale screen dimensions (and lost scrollback) until the client | |||
| 50 | + | // grows back into a solvable layout. | |||
| 51 | + | if p := tb.panes[tb.tree.Root.Leaf]; p != nil { | |||
| 52 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area}) | |||
| 53 | + | } | |||
| 46 | } | 54 | } | ||
| 47 | } | 55 | } | ||
| 48 | // Hidden retained popups enter this same primitive when shown; only a | 56 | // Hidden retained popups enter this same primitive when shown; only a | ||
M internal/usecase/daemon/transactional_resize_test.go +1 -3
| @@ -185,9 +185,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi | |||
| 185 | 185 | for _, timer := range timers { | |
| 186 | 186 | timer.ch <- time.Time{} | |
| 187 | 187 | } | |
| 188 | - | require.Eventually(t, func() bool { | |
| 189 | - | return len(pty.requested()) == 1 | |
| 190 | - | }, time.Second, time.Millisecond, "the newest timer callback must finish") | |
| 188 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 191 | 189 | require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested()) | |
| 192 | 190 | snapshot := sess.renderCoordinator().resizeSnapshot() | |
| 193 | 191 | require.Equal(t, uint64(3), snapshot.epoch) | |
| @@ -185,9 +185,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi | |||||
| 185 | for _, timer := range timers { | 185 | for _, timer := range timers { | ||
| 186 | timer.ch <- time.Time{} | 186 | timer.ch <- time.Time{} | ||
| 187 | } | 187 | } | ||
| 188 | - | require.Eventually(t, func() bool { | 188 | + | awaitResizeCallback(t, sess.renderCoordinator()) |
| 189 | - | return len(pty.requested()) == 1 | |||
| 190 | - | }, time.Second, time.Millisecond, "the newest timer callback must finish") | |||
| 191 | require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested()) | 189 | require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested()) | ||
| 192 | snapshot := sess.renderCoordinator().resizeSnapshot() | 190 | snapshot := sess.renderCoordinator().resizeSnapshot() | ||
| 193 | require.Equal(t, uint64(3), snapshot.epoch) | 191 | require.Equal(t, uint64(3), snapshot.epoch) | ||