Merge pull request #79 from bnema/fix/render-refresh-stale-state
fix(render): prevent stale frames across transitions
14 files · +502 -66
M internal/usecase/daemon/client.go +53 -20
| @@ -14,7 +14,6 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 17 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 19 | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 20 | 19 | ) | |
| @@ -41,17 +40,19 @@ type attachedClient struct { | |||
| 41 | 40 | // sendMu and must never share mutable backing storage. | |
| 42 | 41 | pipelineCache composeCacheInput | |
| 43 | 42 | pipelineScratch composeCacheInput | |
| 44 | - | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 45 | - | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | |
| 46 | - | size domain.Size | |
| 47 | - | keys *keys.Router | |
| 48 | - | sess Guarded[*session] | |
| 49 | - | mouseScan mouse.Scanner | |
| 50 | - | themeMu sync.Mutex | |
| 51 | - | theme themeui.Theme | |
| 52 | - | clientTheme themeui.Theme | |
| 53 | - | lastCursor cursorOut | |
| 54 | - | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 43 | + | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 44 | + | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 45 | + | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 46 | + | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | |
| 47 | + | size domain.Size | |
| 48 | + | keys *keys.Router | |
| 49 | + | sess Guarded[*session] | |
| 50 | + | mouseScan mouse.Scanner | |
| 51 | + | themeMu sync.Mutex | |
| 52 | + | theme themeui.Theme | |
| 53 | + | clientTheme themeui.Theme | |
| 54 | + | lastCursor cursorOut | |
| 55 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 55 | 56 | // previousSession is guarded independently. It is retained through temporary | |
| 56 | 57 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 57 | 58 | previousSession Guarded[*session] | |
| @@ -113,6 +114,30 @@ func (ac *attachedClient) clearPreviousSession() { | |||
| 113 | 114 | } | |
| 114 | 115 | } | |
| 115 | 116 | ||
| 117 | + | // pruneCaptureFrames releases snapshots for panes that have left their owner. | |
| 118 | + | // Callers must not hold daemon, session, tab, or pane locks. | |
| 119 | + | func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) { | |
| 120 | + | if ac == nil || len(panes) == 0 { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | ac.sendMu.Lock() | |
| 124 | + | for _, p := range panes { | |
| 125 | + | delete(ac.captureFrames, p) | |
| 126 | + | } | |
| 127 | + | ac.sendMu.Unlock() | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // clearCaptureFrames releases all pane snapshots when the attachment no longer | |
| 131 | + | // owns a session. | |
| 132 | + | func (ac *attachedClient) clearCaptureFrames() { | |
| 133 | + | if ac == nil { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | ac.sendMu.Lock() | |
| 137 | + | ac.captureFrames = nil | |
| 138 | + | ac.sendMu.Unlock() | |
| 139 | + | } | |
| 140 | + | ||
| 116 | 141 | func (ac *attachedClient) getTheme() themeui.Theme { | |
| 117 | 142 | ac.themeMu.Lock() | |
| 118 | 143 | defer ac.themeMu.Unlock() | |
| @@ -383,6 +408,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache | |||
| 383 | 408 | } | |
| 384 | 409 | current.sendMu.Lock() | |
| 385 | 410 | current.output.rebase() | |
| 411 | + | // Capture snapshots are attachment-owned. Discard them while the attachment | |
| 412 | + | // is exclusively held so the transfer releases panes no longer reachable | |
| 413 | + | // from the target session. | |
| 414 | + | current.captureFrames = nil | |
| 386 | 415 | current.sendMu.Unlock() | |
| 387 | 416 | d.attachCoordinator(target, old, current, true) | |
| 388 | 417 | } | |
| @@ -470,14 +499,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 470 | 499 | wsz := tb.size | |
| 471 | 500 | tb.mu.Unlock() | |
| 472 | 501 | ||
| 502 | + | outerResizeAccepted := false | |
| 473 | 503 | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 474 | - | d.resizeForFirstPaint(sess, ac, clientSize) | |
| 504 | + | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 475 | 505 | } | |
| 476 | 506 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 477 | - | // Activation can synchronously resize a retained floating pane through the | |
| 478 | - | // same transaction. Fold its invalidation into this mandatory Welcome frame. | |
| 479 | - | d.activateTab(sess, tb) | |
| 480 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 507 | + | // Activation can synchronously resize a retained floating pane. An accepted | |
| 508 | + | // synchronous outer request already includes that pane and emits the reset, | |
| 509 | + | // but activation still performs its warmup work. | |
| 510 | + | activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted) | |
| 511 | + | if !outerResizeAccepted && !activationResized { | |
| 512 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 513 | + | } | |
| 481 | 514 | } | |
| 482 | 515 | ||
| 483 | 516 | // runConnLoop is the per-connection input router: it pumps client messages | |
| @@ -613,9 +646,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 613 | 646 | } | |
| 614 | 647 | ||
| 615 | 648 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| 616 | - | // firstPaint caller emits its full frame immediately after this commit. | |
| 617 | - | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | |
| 618 | - | d.requestTransactionalResize(sess, ac, sz, true) | |
| 649 | + | // returned value reports whether the synchronous request was accepted. | |
| 650 | + | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | |
| 651 | + | return d.requestTransactionalResize(sess, ac, sz, true) | |
| 619 | 652 | } | |
| 620 | 653 | ||
| 621 | 654 | // detachOnSendError drops a client whose transport failed, leaving the session | |
| @@ -14,7 +14,6 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/usecase/keys" | 16 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 17 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 18 | "github.com/bnema/vev/internal/usecase/mouse" | 17 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 20 | ) | 19 | ) | ||
| @@ -41,17 +40,19 @@ type attachedClient struct { | |||||
| 41 | // sendMu and must never share mutable backing storage. | 40 | // sendMu and must never share mutable backing storage. | ||
| 42 | pipelineCache composeCacheInput | 41 | pipelineCache composeCacheInput | ||
| 43 | pipelineScratch composeCacheInput | 42 | pipelineScratch composeCacheInput | ||
| 44 | - | renderScratch renderCaptureScratch // only touched while sendMu is held | 43 | + | renderScratch renderCaptureScratch // only touched while sendMu is held |
| 45 | - | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | 44 | + | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so |
| 46 | - | size domain.Size | 45 | + | // snapshots cannot leak when an attachment switches tabs or sessions. |
| 47 | - | keys *keys.Router | 46 | + | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held |
| 48 | - | sess Guarded[*session] | 47 | + | size domain.Size |
| 49 | - | mouseScan mouse.Scanner | 48 | + | keys *keys.Router |
| 50 | - | themeMu sync.Mutex | 49 | + | sess Guarded[*session] |
| 51 | - | theme themeui.Theme | 50 | + | mouseScan mouse.Scanner |
| 52 | - | clientTheme themeui.Theme | 51 | + | themeMu sync.Mutex |
| 53 | - | lastCursor cursorOut | 52 | + | theme themeui.Theme |
| 54 | - | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | 53 | + | clientTheme themeui.Theme |
| 54 | + | lastCursor cursorOut | |||
| 55 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |||
| 55 | // previousSession is guarded independently. It is retained through temporary | 56 | // previousSession is guarded independently. It is retained through temporary | ||
| 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 57 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 57 | previousSession Guarded[*session] | 58 | previousSession Guarded[*session] | ||
| @@ -113,6 +114,30 @@ func (ac *attachedClient) clearPreviousSession() { | |||||
| 113 | } | 114 | } | ||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
| 117 | + | // pruneCaptureFrames releases snapshots for panes that have left their owner. | |||
| 118 | + | // Callers must not hold daemon, session, tab, or pane locks. | |||
| 119 | + | func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) { | |||
| 120 | + | if ac == nil || len(panes) == 0 { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | ac.sendMu.Lock() | |||
| 124 | + | for _, p := range panes { | |||
| 125 | + | delete(ac.captureFrames, p) | |||
| 126 | + | } | |||
| 127 | + | ac.sendMu.Unlock() | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // clearCaptureFrames releases all pane snapshots when the attachment no longer | |||
| 131 | + | // owns a session. | |||
| 132 | + | func (ac *attachedClient) clearCaptureFrames() { | |||
| 133 | + | if ac == nil { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | ac.sendMu.Lock() | |||
| 137 | + | ac.captureFrames = nil | |||
| 138 | + | ac.sendMu.Unlock() | |||
| 139 | + | } | |||
| 140 | + | ||||
| 116 | func (ac *attachedClient) getTheme() themeui.Theme { | 141 | func (ac *attachedClient) getTheme() themeui.Theme { | ||
| 117 | ac.themeMu.Lock() | 142 | ac.themeMu.Lock() | ||
| 118 | defer ac.themeMu.Unlock() | 143 | defer ac.themeMu.Unlock() | ||
| @@ -383,6 +408,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache | |||||
| 383 | } | 408 | } | ||
| 384 | current.sendMu.Lock() | 409 | current.sendMu.Lock() | ||
| 385 | current.output.rebase() | 410 | current.output.rebase() | ||
| 411 | + | // Capture snapshots are attachment-owned. Discard them while the attachment | |||
| 412 | + | // is exclusively held so the transfer releases panes no longer reachable | |||
| 413 | + | // from the target session. | |||
| 414 | + | current.captureFrames = nil | |||
| 386 | current.sendMu.Unlock() | 415 | current.sendMu.Unlock() | ||
| 387 | d.attachCoordinator(target, old, current, true) | 416 | d.attachCoordinator(target, old, current, true) | ||
| 388 | } | 417 | } | ||
| @@ -470,14 +499,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 470 | wsz := tb.size | 499 | wsz := tb.size | ||
| 471 | tb.mu.Unlock() | 500 | tb.mu.Unlock() | ||
| 472 | 501 | ||||
| 502 | + | outerResizeAccepted := false | |||
| 473 | if clientSize.Valid() && wsz != tabSize(clientSize) { | 503 | if clientSize.Valid() && wsz != tabSize(clientSize) { | ||
| 474 | - | d.resizeForFirstPaint(sess, ac, clientSize) | 504 | + | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) |
| 475 | } | 505 | } | ||
| 476 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 506 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 477 | - | // Activation can synchronously resize a retained floating pane through the | 507 | + | // Activation can synchronously resize a retained floating pane. An accepted |
| 478 | - | // same transaction. Fold its invalidation into this mandatory Welcome frame. | 508 | + | // synchronous outer request already includes that pane and emits the reset, |
| 479 | - | d.activateTab(sess, tb) | 509 | + | // but activation still performs its warmup work. |
| 480 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | 510 | + | activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted) |
| 511 | + | if !outerResizeAccepted && !activationResized { | |||
| 512 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |||
| 513 | + | } | |||
| 481 | } | 514 | } | ||
| 482 | 515 | ||||
| 483 | // runConnLoop is the per-connection input router: it pumps client messages | 516 | // runConnLoop is the per-connection input router: it pumps client messages | ||
| @@ -613,9 +646,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 613 | } | 646 | } | ||
| 614 | 647 | ||||
| 615 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | 648 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | ||
| 616 | - | // firstPaint caller emits its full frame immediately after this commit. | 649 | + | // returned value reports whether the synchronous request was accepted. |
| 617 | - | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | 650 | + | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { |
| 618 | - | d.requestTransactionalResize(sess, ac, sz, true) | 651 | + | return d.requestTransactionalResize(sess, ac, sz, true) |
| 619 | } | 652 | } | ||
| 620 | 653 | ||||
| 621 | // detachOnSendError drops a client whose transport failed, leaving the session | 654 | // detachOnSendError drops a client whose transport failed, leaving the session | ||
M internal/usecase/daemon/floating.go +18 -4
| @@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | |||
| 154 | 154 | // activateTab performs the work associated with making a tab the destination. | |
| 155 | 155 | // It is deliberately idempotent: every tab transition can use this single | |
| 156 | 156 | // hook, while inactive restored tabs remain cold until actually selected. | |
| 157 | - | func (d *Daemon) activateTab(sess *session, tb *tab) { | |
| 157 | + | func (d *Daemon) activateTab(sess *session, tb *tab) bool { | |
| 158 | + | return d.activateTabAfterResize(sess, tb, false) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // activateTabAfterResize retains tab activation's warmup work while avoiding a | |
| 162 | + | // second resize when a synchronous outer resize request was already accepted. | |
| 163 | + | func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool { | |
| 158 | 164 | if d == nil || sess == nil || tb == nil { | |
| 159 | - | return | |
| 165 | + | return false | |
| 160 | 166 | } | |
| 161 | 167 | // A headless session has no actual terminal destination. Deferring warmup | |
| 162 | 168 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| @@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||
| 166 | 172 | sess.mu.Unlock() | |
| 167 | 173 | d.exitCopyMode(ac) | |
| 168 | 174 | if ac == nil { | |
| 169 | - | return | |
| 175 | + | return false | |
| 170 | 176 | } | |
| 171 | 177 | d.ensureFloatingWarm(sess, tb) | |
| 178 | + | if outerResizeAccepted { | |
| 179 | + | return false | |
| 180 | + | } | |
| 172 | 181 | tb.mu.Lock() | |
| 173 | 182 | hasFloating := tb.floating.pane != nil | |
| 174 | 183 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 175 | 184 | tb.mu.Unlock() | |
| 176 | 185 | if hasFloating { | |
| 177 | - | d.requestTransactionalResize(sess, ac, size, true) | |
| 186 | + | return d.requestTransactionalResize(sess, ac, size, true) | |
| 178 | 187 | } | |
| 188 | + | return false | |
| 179 | 189 | } | |
| 180 | 190 | ||
| 181 | 191 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | |
| @@ -427,6 +437,9 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64 | |||
| 427 | 437 | sess.mu.Lock() | |
| 428 | 438 | ac := sess.client | |
| 429 | 439 | sess.mu.Unlock() | |
| 440 | + | if ac != nil { | |
| 441 | + | ac.pruneCaptureFrames(p) | |
| 442 | + | } | |
| 430 | 443 | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |
| 431 | 444 | if ac != nil && (visible || copyCleared) { | |
| 432 | 445 | d.invalidateRender(sess, ac, true, "floating.go") | |
| @@ -443,6 +456,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) { | |||
| 443 | 456 | tb.mu.Unlock() | |
| 444 | 457 | if ac != nil { | |
| 445 | 458 | ac.overlays.clearCopyModeForPane(p) | |
| 459 | + | ac.pruneCaptureFrames(p) | |
| 446 | 460 | } | |
| 447 | 461 | closeFloatingPane(p) | |
| 448 | 462 | } | |
| @@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | |||||
| 154 | // activateTab performs the work associated with making a tab the destination. | 154 | // activateTab performs the work associated with making a tab the destination. | ||
| 155 | // It is deliberately idempotent: every tab transition can use this single | 155 | // It is deliberately idempotent: every tab transition can use this single | ||
| 156 | // hook, while inactive restored tabs remain cold until actually selected. | 156 | // hook, while inactive restored tabs remain cold until actually selected. | ||
| 157 | - | func (d *Daemon) activateTab(sess *session, tb *tab) { | 157 | + | func (d *Daemon) activateTab(sess *session, tb *tab) bool { |
| 158 | + | return d.activateTabAfterResize(sess, tb, false) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // activateTabAfterResize retains tab activation's warmup work while avoiding a | |||
| 162 | + | // second resize when a synchronous outer resize request was already accepted. | |||
| 163 | + | func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool { | |||
| 158 | if d == nil || sess == nil || tb == nil { | 164 | if d == nil || sess == nil || tb == nil { | ||
| 159 | - | return | 165 | + | return false |
| 160 | } | 166 | } | ||
| 161 | // A headless session has no actual terminal destination. Deferring warmup | 167 | // A headless session has no actual terminal destination. Deferring warmup | ||
| 162 | // until firstPaint keeps restored tabs cold and avoids launching children | 168 | // until firstPaint keeps restored tabs cold and avoids launching children | ||
| @@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||||
| 166 | sess.mu.Unlock() | 172 | sess.mu.Unlock() | ||
| 167 | d.exitCopyMode(ac) | 173 | d.exitCopyMode(ac) | ||
| 168 | if ac == nil { | 174 | if ac == nil { | ||
| 169 | - | return | 175 | + | return false |
| 170 | } | 176 | } | ||
| 171 | d.ensureFloatingWarm(sess, tb) | 177 | d.ensureFloatingWarm(sess, tb) | ||
| 178 | + | if outerResizeAccepted { | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 172 | tb.mu.Lock() | 181 | tb.mu.Lock() | ||
| 173 | hasFloating := tb.floating.pane != nil | 182 | hasFloating := tb.floating.pane != nil | ||
| 174 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | 183 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | ||
| 175 | tb.mu.Unlock() | 184 | tb.mu.Unlock() | ||
| 176 | if hasFloating { | 185 | if hasFloating { | ||
| 177 | - | d.requestTransactionalResize(sess, ac, size, true) | 186 | + | return d.requestTransactionalResize(sess, ac, size, true) |
| 178 | } | 187 | } | ||
| 188 | + | return false | |||
| 179 | } | 189 | } | ||
| 180 | 190 | ||||
| 181 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | 191 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | ||
| @@ -427,6 +437,9 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64 | |||||
| 427 | sess.mu.Lock() | 437 | sess.mu.Lock() | ||
| 428 | ac := sess.client | 438 | ac := sess.client | ||
| 429 | sess.mu.Unlock() | 439 | sess.mu.Unlock() | ||
| 440 | + | if ac != nil { | |||
| 441 | + | ac.pruneCaptureFrames(p) | |||
| 442 | + | } | |||
| 430 | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | 443 | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | ||
| 431 | if ac != nil && (visible || copyCleared) { | 444 | if ac != nil && (visible || copyCleared) { | ||
| 432 | d.invalidateRender(sess, ac, true, "floating.go") | 445 | d.invalidateRender(sess, ac, true, "floating.go") | ||
| @@ -443,6 +456,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) { | |||||
| 443 | tb.mu.Unlock() | 456 | tb.mu.Unlock() | ||
| 444 | if ac != nil { | 457 | if ac != nil { | ||
| 445 | ac.overlays.clearCopyModeForPane(p) | 458 | ac.overlays.clearCopyModeForPane(p) | ||
| 459 | + | ac.pruneCaptureFrames(p) | |||
| 446 | } | 460 | } | ||
| 447 | closeFloatingPane(p) | 461 | closeFloatingPane(p) | ||
| 448 | } | 462 | } | ||
M internal/usecase/daemon/input_test.go +71 -0
| @@ -87,6 +87,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 87 | 87 | d.sessWg.Wait() | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |
| 91 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil) | |
| 92 | + | source := sess.tabs[0].focusedPane() | |
| 93 | + | target := sess.tabs[1].focusedPane() | |
| 94 | + | require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1") | |
| 95 | + | ||
| 96 | + | source.mu.Lock() | |
| 97 | + | source.screen.Write([]byte("source")) | |
| 98 | + | source.mu.Unlock() | |
| 99 | + | d.paint(sess, ac, true) | |
| 100 | + | first := <-sends | |
| 101 | + | firstOutput, err := ports.UnmarshalOutput(first.Payload) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows) | |
| 104 | + | terminal.Write(firstOutput.Data) | |
| 105 | + | ||
| 106 | + | // A clean pane relies on the attachment capture cache for its retained | |
| 107 | + | // snapshot. Both tabs deliberately use the tab-local default pane ID. | |
| 108 | + | target.mu.Lock() | |
| 109 | + | target.screen.ClearDamage() | |
| 110 | + | target.mu.Unlock() | |
| 111 | + | // Use the real key action: it requests the mandatory complete repaint for | |
| 112 | + | // the first target-tab frame. | |
| 113 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | |
| 114 | + | require.Equal(t, 1, activeTabIndex(sess)) | |
| 115 | + | ||
| 116 | + | second := <-sends | |
| 117 | + | secondOutput, err := ports.UnmarshalOutput(second.Payload) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | |
| 120 | + | terminal.Write(secondOutput.Data) | |
| 121 | + | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) { | |
| 125 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 126 | + | tb := sess.tabs[0] | |
| 127 | + | survivor := tb.panes["pane-1"] | |
| 128 | + | closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23}) | |
| 129 | + | tb.mu.Lock() | |
| 130 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 131 | + | tb.panes[closed.id] = closed | |
| 132 | + | tb.mu.Unlock() | |
| 133 | + | ||
| 134 | + | cachedSurvivor := capturedPaneRenderState{title: "survivor"} | |
| 135 | + | ac.captureFrames = map[*pane]capturedPaneRenderState{ | |
| 136 | + | survivor: cachedSurvivor, | |
| 137 | + | closed: {title: "closed"}, | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | require.NoError(t, d.closePane(sess, tb, closed.id, ac, false)) | |
| 141 | + | require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment") | |
| 142 | + | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) { | |
| 146 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 147 | + | closedTab, survivorTab := sess.tabs[0], sess.tabs[1] | |
| 148 | + | closed := closedTab.panes["pane-1"] | |
| 149 | + | survivor := survivorTab.panes["pane-1"] | |
| 150 | + | cachedSurvivor := capturedPaneRenderState{title: "survivor"} | |
| 151 | + | ac.captureFrames = map[*pane]capturedPaneRenderState{ | |
| 152 | + | closed: {title: "closed"}, | |
| 153 | + | survivor: cachedSurvivor, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | d.closeTab(sess, closedTab, false) | |
| 157 | + | require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment") | |
| 158 | + | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture") | |
| 159 | + | } | |
| 160 | + | ||
| 90 | 161 | func TestAltCForwardsToPTY(t *testing.T) { | |
| 91 | 162 | writes := make(chan []byte, 2) | |
| 92 | 163 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| @@ -87,6 +87,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 87 | d.sessWg.Wait() | 87 | d.sessWg.Wait() | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 91 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil) | |||
| 92 | + | source := sess.tabs[0].focusedPane() | |||
| 93 | + | target := sess.tabs[1].focusedPane() | |||
| 94 | + | require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1") | |||
| 95 | + | ||||
| 96 | + | source.mu.Lock() | |||
| 97 | + | source.screen.Write([]byte("source")) | |||
| 98 | + | source.mu.Unlock() | |||
| 99 | + | d.paint(sess, ac, true) | |||
| 100 | + | first := <-sends | |||
| 101 | + | firstOutput, err := ports.UnmarshalOutput(first.Payload) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows) | |||
| 104 | + | terminal.Write(firstOutput.Data) | |||
| 105 | + | ||||
| 106 | + | // A clean pane relies on the attachment capture cache for its retained | |||
| 107 | + | // snapshot. Both tabs deliberately use the tab-local default pane ID. | |||
| 108 | + | target.mu.Lock() | |||
| 109 | + | target.screen.ClearDamage() | |||
| 110 | + | target.mu.Unlock() | |||
| 111 | + | // Use the real key action: it requests the mandatory complete repaint for | |||
| 112 | + | // the first target-tab frame. | |||
| 113 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | |||
| 114 | + | require.Equal(t, 1, activeTabIndex(sess)) | |||
| 115 | + | ||||
| 116 | + | second := <-sends | |||
| 117 | + | secondOutput, err := ports.UnmarshalOutput(second.Payload) | |||
| 118 | + | require.NoError(t, err) | |||
| 119 | + | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | |||
| 120 | + | terminal.Write(secondOutput.Data) | |||
| 121 | + | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) { | |||
| 125 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 126 | + | tb := sess.tabs[0] | |||
| 127 | + | survivor := tb.panes["pane-1"] | |||
| 128 | + | closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23}) | |||
| 129 | + | tb.mu.Lock() | |||
| 130 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 131 | + | tb.panes[closed.id] = closed | |||
| 132 | + | tb.mu.Unlock() | |||
| 133 | + | ||||
| 134 | + | cachedSurvivor := capturedPaneRenderState{title: "survivor"} | |||
| 135 | + | ac.captureFrames = map[*pane]capturedPaneRenderState{ | |||
| 136 | + | survivor: cachedSurvivor, | |||
| 137 | + | closed: {title: "closed"}, | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | require.NoError(t, d.closePane(sess, tb, closed.id, ac, false)) | |||
| 141 | + | require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment") | |||
| 142 | + | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture") | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) { | |||
| 146 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 147 | + | closedTab, survivorTab := sess.tabs[0], sess.tabs[1] | |||
| 148 | + | closed := closedTab.panes["pane-1"] | |||
| 149 | + | survivor := survivorTab.panes["pane-1"] | |||
| 150 | + | cachedSurvivor := capturedPaneRenderState{title: "survivor"} | |||
| 151 | + | ac.captureFrames = map[*pane]capturedPaneRenderState{ | |||
| 152 | + | closed: {title: "closed"}, | |||
| 153 | + | survivor: cachedSurvivor, | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | d.closeTab(sess, closedTab, false) | |||
| 157 | + | require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment") | |||
| 158 | + | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 90 | func TestAltCForwardsToPTY(t *testing.T) { | 161 | func TestAltCForwardsToPTY(t *testing.T) { | ||
| 91 | writes := make(chan []byte, 2) | 162 | writes := make(chan []byte, 2) | ||
| 92 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 163 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
M internal/usecase/daemon/pane_actions.go +1 -0
| @@ -255,6 +255,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 255 | 255 | ||
| 256 | 256 | if ac != nil { | |
| 257 | 257 | ac.overlays.clearCopyModeForPane(p) | |
| 258 | + | ac.pruneCaptureFrames(p) | |
| 258 | 259 | } | |
| 259 | 260 | ||
| 260 | 261 | if rc := sess.renderCoordinator(); rc != nil { | |
| @@ -255,6 +255,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 255 | 255 | ||||
| 256 | if ac != nil { | 256 | if ac != nil { | ||
| 257 | ac.overlays.clearCopyModeForPane(p) | 257 | ac.overlays.clearCopyModeForPane(p) | ||
| 258 | + | ac.pruneCaptureFrames(p) | |||
| 258 | } | 259 | } | ||
| 259 | 260 | ||||
| 260 | if rc := sess.renderCoordinator(); rc != nil { | 261 | if rc := sess.renderCoordinator(); rc != nil { | ||
M internal/usecase/daemon/picker_test.go +43 -0
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -14,6 +15,7 @@ import ( | |||
| 14 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | + | "github.com/bnema/vev/pkg/vt" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | // --- test doubles ----------------------------------------------------------- | |
| @@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 216 | 218 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 217 | 219 | } | |
| 218 | 220 | ||
| 221 | + | func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |
| 222 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 223 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 224 | + | defer releasePTY1() | |
| 225 | + | defer releasePTY2() | |
| 226 | + | d, source, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 227 | + | source.id, source.name = "source", "source" | |
| 228 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 229 | + | d.sessions[source.id] = source | |
| 230 | + | target := &session{ | |
| 231 | + | id: "target", name: "target", ctx: source.ctx, cancel: func() {}, | |
| 232 | + | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | |
| 233 | + | } | |
| 234 | + | d.sessions[target.id] = target | |
| 235 | + | ||
| 236 | + | sourcePane := source.tabs[0].focusedPane() | |
| 237 | + | targetPane := target.tabs[0].focusedPane() | |
| 238 | + | require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1") | |
| 239 | + | require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1") | |
| 240 | + | sourcePane.screen.Write([]byte("SOURCE")) | |
| 241 | + | client := vt.NewScreen(80, 25) | |
| 242 | + | d.firstPaint(source, ac, ac.size) | |
| 243 | + | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 244 | + | ac.ackOutputState(sourceOutput.NewStateNum) | |
| 245 | + | ||
| 246 | + | targetPane.screen.Write([]byte("TARGET")) | |
| 247 | + | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | |
| 248 | + | require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage") | |
| 249 | + | ac.previousSession.Set(target) | |
| 250 | + | ||
| 251 | + | // Exercise the user-facing previous-session route, which delegates to the | |
| 252 | + | // real switchToTarget hand-off and immediately emits its required reset. | |
| 253 | + | d.backSession(source, ac) | |
| 254 | + | firstReset := awaitFrame(t, sends, ports.MsgOutput) | |
| 255 | + | out := mustApplyOutput(t, client, firstReset) | |
| 256 | + | require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair") | |
| 257 | + | frame := strings.Join(frameRows(client.Frame), "\n") | |
| 258 | + | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | |
| 259 | + | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |
| 260 | + | } | |
| 261 | + | ||
| 219 | 262 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| 220 | 263 | p1, releasePTY1 := newBlockingPTY(t) | |
| 221 | 264 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -14,6 +15,7 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 15 | "github.com/bnema/vev/internal/usecase/layout" | 16 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 16 | "github.com/bnema/vev/internal/usecase/picker" | 17 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 18 | + | "github.com/bnema/vev/pkg/vt" | |||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | // --- test doubles ----------------------------------------------------------- | 21 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||||
| 216 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | 218 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | ||
| 217 | } | 219 | } | ||
| 218 | 220 | ||||
| 221 | + | func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 222 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 223 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 224 | + | defer releasePTY1() | |||
| 225 | + | defer releasePTY2() | |||
| 226 | + | d, source, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 227 | + | source.id, source.name = "source", "source" | |||
| 228 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 229 | + | d.sessions[source.id] = source | |||
| 230 | + | target := &session{ | |||
| 231 | + | id: "target", name: "target", ctx: source.ctx, cancel: func() {}, | |||
| 232 | + | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | |||
| 233 | + | } | |||
| 234 | + | d.sessions[target.id] = target | |||
| 235 | + | ||||
| 236 | + | sourcePane := source.tabs[0].focusedPane() | |||
| 237 | + | targetPane := target.tabs[0].focusedPane() | |||
| 238 | + | require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1") | |||
| 239 | + | require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1") | |||
| 240 | + | sourcePane.screen.Write([]byte("SOURCE")) | |||
| 241 | + | client := vt.NewScreen(80, 25) | |||
| 242 | + | d.firstPaint(source, ac, ac.size) | |||
| 243 | + | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 244 | + | ac.ackOutputState(sourceOutput.NewStateNum) | |||
| 245 | + | ||||
| 246 | + | targetPane.screen.Write([]byte("TARGET")) | |||
| 247 | + | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | |||
| 248 | + | require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage") | |||
| 249 | + | ac.previousSession.Set(target) | |||
| 250 | + | ||||
| 251 | + | // Exercise the user-facing previous-session route, which delegates to the | |||
| 252 | + | // real switchToTarget hand-off and immediately emits its required reset. | |||
| 253 | + | d.backSession(source, ac) | |||
| 254 | + | firstReset := awaitFrame(t, sends, ports.MsgOutput) | |||
| 255 | + | out := mustApplyOutput(t, client, firstReset) | |||
| 256 | + | require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair") | |||
| 257 | + | frame := strings.Join(frameRows(client.Frame), "\n") | |||
| 258 | + | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | |||
| 259 | + | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |||
| 260 | + | } | |||
| 261 | + | ||||
| 219 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | 262 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | ||
| 220 | p1, releasePTY1 := newBlockingPTY(t) | 263 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 221 | p2, releasePTY2 := newBlockingPTY(t) | 264 | p2, releasePTY2 := newBlockingPTY(t) | ||
M internal/usecase/daemon/render_capture.go +13 -12
| @@ -132,7 +132,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt | |||
| 132 | 132 | width := min(max(visible.Width, 0), p.screen.Frame.Width) | |
| 133 | 133 | height := min(max(visible.Height, 0), p.screen.Frame.Height) | |
| 134 | 134 | needsFrame := len(damage.Damage) > 0 | |
| 135 | - | if out.frame.Width != width || out.frame.Height != height { | |
| 135 | + | frameChanged := out.frame.Width != width || out.frame.Height != height | |
| 136 | + | if frameChanged { | |
| 136 | 137 | out.frame = renderer.NewFrame(width, height) | |
| 137 | 138 | needsFrame = true | |
| 138 | 139 | } | |
| @@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt | |||
| 144 | 145 | } | |
| 145 | 146 | } | |
| 146 | 147 | ||
| 147 | - | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | |
| 148 | + | if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | |
| 149 | + | // A cache entry with a new frame has no terminal-shadow equivalent. | |
| 150 | + | // Redraw it even when the pane has no VT damage, such as an untouched | |
| 151 | + | // pane first made visible after a tab or session switch. | |
| 148 | 152 | out.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 149 | 153 | } else { | |
| 150 | 154 | out.damage = append(out.damage[:0], damage.Damage...) | |
| @@ -221,6 +225,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 221 | 225 | } | |
| 222 | 226 | state.panes = scratch.panes[:0] | |
| 223 | 227 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | |
| 228 | + | if ac.captureFrames == nil { | |
| 229 | + | ac.captureFrames = make(map[*pane]capturedPaneRenderState) | |
| 230 | + | } | |
| 224 | 231 | for _, placement := range layoutSnap.placements { | |
| 225 | 232 | p := tb.panes[placement.ID] | |
| 226 | 233 | if p == nil { | |
| @@ -231,10 +238,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 231 | 238 | visible = domain.Rect{} | |
| 232 | 239 | } | |
| 233 | 240 | p.mu.Lock() | |
| 234 | - | if ac.captureFrames == nil { | |
| 235 | - | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |
| 236 | - | } | |
| 237 | - | captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id]) | |
| 241 | + | captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p]) | |
| 238 | 242 | if mode == damageCaptureConsume { | |
| 239 | 243 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 240 | 244 | } | |
| @@ -248,7 +252,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 248 | 252 | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | |
| 249 | 253 | } | |
| 250 | 254 | captured.rawDamage, captured.damage = captured.damage, translated | |
| 251 | - | ac.captureFrames[captured.id] = captured | |
| 255 | + | ac.captureFrames[p] = captured | |
| 252 | 256 | state.panes = append(state.panes, captured) | |
| 253 | 257 | } | |
| 254 | 258 | scratch.panes = state.panes | |
| @@ -256,10 +260,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 256 | 260 | p := tb.floating.pane | |
| 257 | 261 | p.mu.Lock() | |
| 258 | 262 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | |
| 259 | - | if ac.captureFrames == nil { | |
| 260 | - | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |
| 261 | - | } | |
| 262 | - | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id]) | |
| 263 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p]) | |
| 263 | 264 | if mode == damageCaptureConsume { | |
| 264 | 265 | seen := false | |
| 265 | 266 | for _, receipt := range state.receipts { | |
| @@ -272,7 +273,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 272 | 273 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 273 | 274 | } | |
| 274 | 275 | } | |
| 275 | - | ac.captureFrames[captured.id] = captured | |
| 276 | + | ac.captureFrames[p] = captured | |
| 276 | 277 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 277 | 278 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 278 | 279 | p.mu.Unlock() | |
| @@ -132,7 +132,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt | |||||
| 132 | width := min(max(visible.Width, 0), p.screen.Frame.Width) | 132 | width := min(max(visible.Width, 0), p.screen.Frame.Width) | ||
| 133 | height := min(max(visible.Height, 0), p.screen.Frame.Height) | 133 | height := min(max(visible.Height, 0), p.screen.Frame.Height) | ||
| 134 | needsFrame := len(damage.Damage) > 0 | 134 | needsFrame := len(damage.Damage) > 0 | ||
| 135 | - | if out.frame.Width != width || out.frame.Height != height { | 135 | + | frameChanged := out.frame.Width != width || out.frame.Height != height |
| 136 | + | if frameChanged { | |||
| 136 | out.frame = renderer.NewFrame(width, height) | 137 | out.frame = renderer.NewFrame(width, height) | ||
| 137 | needsFrame = true | 138 | needsFrame = true | ||
| 138 | } | 139 | } | ||
| @@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt | |||||
| 144 | } | 145 | } | ||
| 145 | } | 146 | } | ||
| 146 | 147 | ||||
| 147 | - | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | 148 | + | if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { |
| 149 | + | // A cache entry with a new frame has no terminal-shadow equivalent. | |||
| 150 | + | // Redraw it even when the pane has no VT damage, such as an untouched | |||
| 151 | + | // pane first made visible after a tab or session switch. | |||
| 148 | out.damage = []renderer.Damage{renderer.FullRedraw()} | 152 | out.damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 149 | } else { | 153 | } else { | ||
| 150 | out.damage = append(out.damage[:0], damage.Damage...) | 154 | out.damage = append(out.damage[:0], damage.Damage...) | ||
| @@ -221,6 +225,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 221 | } | 225 | } | ||
| 222 | state.panes = scratch.panes[:0] | 226 | state.panes = scratch.panes[:0] | ||
| 223 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | 227 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | ||
| 228 | + | if ac.captureFrames == nil { | |||
| 229 | + | ac.captureFrames = make(map[*pane]capturedPaneRenderState) | |||
| 230 | + | } | |||
| 224 | for _, placement := range layoutSnap.placements { | 231 | for _, placement := range layoutSnap.placements { | ||
| 225 | p := tb.panes[placement.ID] | 232 | p := tb.panes[placement.ID] | ||
| 226 | if p == nil { | 233 | if p == nil { | ||
| @@ -231,10 +238,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 231 | visible = domain.Rect{} | 238 | visible = domain.Rect{} | ||
| 232 | } | 239 | } | ||
| 233 | p.mu.Lock() | 240 | p.mu.Lock() | ||
| 234 | - | if ac.captureFrames == nil { | 241 | + | captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p]) |
| 235 | - | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |||
| 236 | - | } | |||
| 237 | - | captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id]) | |||
| 238 | if mode == damageCaptureConsume { | 242 | if mode == damageCaptureConsume { | ||
| 239 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | 243 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | ||
| 240 | } | 244 | } | ||
| @@ -248,7 +252,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 248 | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | 252 | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | ||
| 249 | } | 253 | } | ||
| 250 | captured.rawDamage, captured.damage = captured.damage, translated | 254 | captured.rawDamage, captured.damage = captured.damage, translated | ||
| 251 | - | ac.captureFrames[captured.id] = captured | 255 | + | ac.captureFrames[p] = captured |
| 252 | state.panes = append(state.panes, captured) | 256 | state.panes = append(state.panes, captured) | ||
| 253 | } | 257 | } | ||
| 254 | scratch.panes = state.panes | 258 | scratch.panes = state.panes | ||
| @@ -256,10 +260,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 256 | p := tb.floating.pane | 260 | p := tb.floating.pane | ||
| 257 | p.mu.Lock() | 261 | p.mu.Lock() | ||
| 258 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | 262 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | ||
| 259 | - | if ac.captureFrames == nil { | 263 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p]) |
| 260 | - | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |||
| 261 | - | } | |||
| 262 | - | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id]) | |||
| 263 | if mode == damageCaptureConsume { | 264 | if mode == damageCaptureConsume { | ||
| 264 | seen := false | 265 | seen := false | ||
| 265 | for _, receipt := range state.receipts { | 266 | for _, receipt := range state.receipts { | ||
| @@ -272,7 +273,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 272 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | 273 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | ||
| 273 | } | 274 | } | ||
| 274 | } | 275 | } | ||
| 275 | - | ac.captureFrames[captured.id] = captured | 276 | + | ac.captureFrames[p] = captured |
| 276 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | 277 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | ||
| 277 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | 278 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | ||
| 278 | p.mu.Unlock() | 279 | p.mu.Unlock() | ||
M internal/usecase/daemon/render_coordinator.go +15 -6
| @@ -265,16 +265,24 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||
| 265 | 265 | // attachment. This check shares c.mu with replacement, making a stale | |
| 266 | 266 | // attachment's timer callback unable to enqueue work for its replacement. | |
| 267 | 267 | // A nil source represents an internal pane/session invalidation. | |
| 268 | - | func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) { | |
| 268 | + | func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool { | |
| 269 | + | return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain | |
| 273 | + | // current when nonzero, so a completed resize cannot publish after a newer | |
| 274 | + | // request supersedes it. | |
| 275 | + | func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool { | |
| 269 | 276 | c.mu.Lock() | |
| 270 | 277 | // An unbound coordinator is the manual/headless harness state; there is no | |
| 271 | 278 | // published production attachment to protect there. A parked target may | |
| 272 | 279 | // still publish its own PTY/session mutations to picker observers, but no | |
| 273 | 280 | // attachment-owned callback may revive that target's render path. | |
| 274 | 281 | detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | |
| 275 | - | if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) { | |
| 282 | + | if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) || | |
| 283 | + | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) { | |
| 276 | 284 | c.mu.Unlock() | |
| 277 | - | return | |
| 285 | + | return false | |
| 278 | 286 | } | |
| 279 | 287 | onInvalidate := c.opts.onInvalidate | |
| 280 | 288 | c.metrics.invalidations.Add(1) | |
| @@ -310,7 +318,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||
| 310 | 318 | if onInvalidate != nil { | |
| 311 | 319 | onInvalidate(inv) | |
| 312 | 320 | } | |
| 313 | - | return | |
| 321 | + | return true | |
| 314 | 322 | } | |
| 315 | 323 | ||
| 316 | 324 | // NewTimer and C may re-enter the coordinator. Publish only if this | |
| @@ -330,7 +338,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||
| 330 | 338 | if onInvalidate != nil { | |
| 331 | 339 | onInvalidate(inv) | |
| 332 | 340 | } | |
| 333 | - | return | |
| 341 | + | return true | |
| 334 | 342 | } | |
| 335 | 343 | if timerC == nil { | |
| 336 | 344 | stopTimer(timer) | |
| @@ -338,7 +346,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||
| 338 | 346 | if onInvalidate != nil { | |
| 339 | 347 | onInvalidate(inv) | |
| 340 | 348 | } | |
| 341 | - | return | |
| 349 | + | return true | |
| 342 | 350 | } | |
| 343 | 351 | go func() { | |
| 344 | 352 | defer close(done) | |
| @@ -356,6 +364,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||
| 356 | 364 | onInvalidate(inv) | |
| 357 | 365 | } | |
| 358 | 366 | runtime.Gosched() | |
| 367 | + | return true | |
| 359 | 368 | } | |
| 360 | 369 | ||
| 361 | 370 | // notifyAck reports that the client acknowledged an output state, releasing | |
| @@ -265,16 +265,24 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||||
| 265 | // attachment. This check shares c.mu with replacement, making a stale | 265 | // attachment. This check shares c.mu with replacement, making a stale | ||
| 266 | // attachment's timer callback unable to enqueue work for its replacement. | 266 | // attachment's timer callback unable to enqueue work for its replacement. | ||
| 267 | // A nil source represents an internal pane/session invalidation. | 267 | // A nil source represents an internal pane/session invalidation. | ||
| 268 | - | func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) { | 268 | + | func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool { |
| 269 | + | return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain | |||
| 273 | + | // current when nonzero, so a completed resize cannot publish after a newer | |||
| 274 | + | // request supersedes it. | |||
| 275 | + | func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool { | |||
| 269 | c.mu.Lock() | 276 | c.mu.Lock() | ||
| 270 | // An unbound coordinator is the manual/headless harness state; there is no | 277 | // An unbound coordinator is the manual/headless harness state; there is no | ||
| 271 | // published production attachment to protect there. A parked target may | 278 | // published production attachment to protect there. A parked target may | ||
| 272 | // still publish its own PTY/session mutations to picker observers, but no | 279 | // still publish its own PTY/session mutations to picker observers, but no | ||
| 273 | // attachment-owned callback may revive that target's render path. | 280 | // attachment-owned callback may revive that target's render path. | ||
| 274 | detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | 281 | detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | ||
| 275 | - | if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) { | 282 | + | if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) || |
| 283 | + | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) { | |||
| 276 | c.mu.Unlock() | 284 | c.mu.Unlock() | ||
| 277 | - | return | 285 | + | return false |
| 278 | } | 286 | } | ||
| 279 | onInvalidate := c.opts.onInvalidate | 287 | onInvalidate := c.opts.onInvalidate | ||
| 280 | c.metrics.invalidations.Add(1) | 288 | c.metrics.invalidations.Add(1) | ||
| @@ -310,7 +318,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||||
| 310 | if onInvalidate != nil { | 318 | if onInvalidate != nil { | ||
| 311 | onInvalidate(inv) | 319 | onInvalidate(inv) | ||
| 312 | } | 320 | } | ||
| 313 | - | return | 321 | + | return true |
| 314 | } | 322 | } | ||
| 315 | 323 | ||||
| 316 | // NewTimer and C may re-enter the coordinator. Publish only if this | 324 | // NewTimer and C may re-enter the coordinator. Publish only if this | ||
| @@ -330,7 +338,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||||
| 330 | if onInvalidate != nil { | 338 | if onInvalidate != nil { | ||
| 331 | onInvalidate(inv) | 339 | onInvalidate(inv) | ||
| 332 | } | 340 | } | ||
| 333 | - | return | 341 | + | return true |
| 334 | } | 342 | } | ||
| 335 | if timerC == nil { | 343 | if timerC == nil { | ||
| 336 | stopTimer(timer) | 344 | stopTimer(timer) | ||
| @@ -338,7 +346,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||||
| 338 | if onInvalidate != nil { | 346 | if onInvalidate != nil { | ||
| 339 | onInvalidate(inv) | 347 | onInvalidate(inv) | ||
| 340 | } | 348 | } | ||
| 341 | - | return | 349 | + | return true |
| 342 | } | 350 | } | ||
| 343 | go func() { | 351 | go func() { | ||
| 344 | defer close(done) | 352 | defer close(done) | ||
| @@ -356,6 +364,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv | |||||
| 356 | onInvalidate(inv) | 364 | onInvalidate(inv) | ||
| 357 | } | 365 | } | ||
| 358 | runtime.Gosched() | 366 | runtime.Gosched() | ||
| 367 | + | return true | |||
| 359 | } | 368 | } | ||
| 360 | 369 | ||||
| 361 | // notifyAck reports that the client acknowledged an output state, releasing | 370 | // notifyAck reports that the client acknowledged an output state, releasing | ||
M internal/usecase/daemon/render_coordinator_test.go +52 -0
| @@ -1558,4 +1558,56 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||
| 1558 | 1558 | requireNoInvalidation(t, invs) | |
| 1559 | 1559 | requireNoCoordinatorOutputFrame(t, sends) | |
| 1560 | 1560 | }) | |
| 1561 | + | ||
| 1562 | + | t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) { | |
| 1563 | + | t.Run("immediate reset invalidation", func(t *testing.T) { | |
| 1564 | + | d, sess, ac, _, _, invs := newResizeFixture(t) | |
| 1565 | + | ||
| 1566 | + | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)) | |
| 1567 | + | inv := awaitInvalidation(t, invs) | |
| 1568 | + | require.True(t, inv.reset) | |
| 1569 | + | }) | |
| 1570 | + | ||
| 1571 | + | t.Run("superseded immediate transaction", func(t *testing.T) { | |
| 1572 | + | resizeStarted := make(chan struct{}) | |
| 1573 | + | releaseResize := make(chan struct{}) | |
| 1574 | + | pty := &transactionalResizePTY{onResize: func() { | |
| 1575 | + | close(resizeStarted) | |
| 1576 | + | <-releaseResize | |
| 1577 | + | }} | |
| 1578 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 1579 | + | clk := newCoordinatorMockClock(t, 2) | |
| 1580 | + | d.clock = clk.clock | |
| 1581 | + | invs := make(chan renderInvalidation, 1) | |
| 1582 | + | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 1583 | + | clock: clk.clock, | |
| 1584 | + | wake: func(renderWake) {}, | |
| 1585 | + | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |
| 1586 | + | }) | |
| 1587 | + | rc.attach(ac) | |
| 1588 | + | sess.installRenderCoordinator(rc) | |
| 1589 | + | ||
| 1590 | + | result := make(chan bool, 1) | |
| 1591 | + | go func() { | |
| 1592 | + | result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true) | |
| 1593 | + | }() | |
| 1594 | + | <-resizeStarted | |
| 1595 | + | require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac)) | |
| 1596 | + | close(releaseResize) | |
| 1597 | + | ||
| 1598 | + | require.False(t, <-result) | |
| 1599 | + | requireNoInvalidation(t, invs) | |
| 1600 | + | }) | |
| 1601 | + | ||
| 1602 | + | t.Run("async schedule and teardown rejection", func(t *testing.T) { | |
| 1603 | + | d, sess, ac, _, clk, _ := newResizeFixture(t) | |
| 1604 | + | ||
| 1605 | + | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false)) | |
| 1606 | + | awaitCoordinatorScheduledTimer(t, clk) | |
| 1607 | + | ||
| 1608 | + | // A torn-down coordinator cannot accept a stale attachment's schedule. | |
| 1609 | + | sess.renderCoordinator().noteSessionTeardown() | |
| 1610 | + | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false)) | |
| 1611 | + | }) | |
| 1612 | + | }) | |
| 1561 | 1613 | } | |
| @@ -1558,4 +1558,56 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||||
| 1558 | requireNoInvalidation(t, invs) | 1558 | requireNoInvalidation(t, invs) | ||
| 1559 | requireNoCoordinatorOutputFrame(t, sends) | 1559 | requireNoCoordinatorOutputFrame(t, sends) | ||
| 1560 | }) | 1560 | }) | ||
| 1561 | + | ||||
| 1562 | + | t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) { | |||
| 1563 | + | t.Run("immediate reset invalidation", func(t *testing.T) { | |||
| 1564 | + | d, sess, ac, _, _, invs := newResizeFixture(t) | |||
| 1565 | + | ||||
| 1566 | + | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)) | |||
| 1567 | + | inv := awaitInvalidation(t, invs) | |||
| 1568 | + | require.True(t, inv.reset) | |||
| 1569 | + | }) | |||
| 1570 | + | ||||
| 1571 | + | t.Run("superseded immediate transaction", func(t *testing.T) { | |||
| 1572 | + | resizeStarted := make(chan struct{}) | |||
| 1573 | + | releaseResize := make(chan struct{}) | |||
| 1574 | + | pty := &transactionalResizePTY{onResize: func() { | |||
| 1575 | + | close(resizeStarted) | |||
| 1576 | + | <-releaseResize | |||
| 1577 | + | }} | |||
| 1578 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 1579 | + | clk := newCoordinatorMockClock(t, 2) | |||
| 1580 | + | d.clock = clk.clock | |||
| 1581 | + | invs := make(chan renderInvalidation, 1) | |||
| 1582 | + | rc := newRenderCoordinator(renderCoordinatorOptions{ | |||
| 1583 | + | clock: clk.clock, | |||
| 1584 | + | wake: func(renderWake) {}, | |||
| 1585 | + | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |||
| 1586 | + | }) | |||
| 1587 | + | rc.attach(ac) | |||
| 1588 | + | sess.installRenderCoordinator(rc) | |||
| 1589 | + | ||||
| 1590 | + | result := make(chan bool, 1) | |||
| 1591 | + | go func() { | |||
| 1592 | + | result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true) | |||
| 1593 | + | }() | |||
| 1594 | + | <-resizeStarted | |||
| 1595 | + | require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac)) | |||
| 1596 | + | close(releaseResize) | |||
| 1597 | + | ||||
| 1598 | + | require.False(t, <-result) | |||
| 1599 | + | requireNoInvalidation(t, invs) | |||
| 1600 | + | }) | |||
| 1601 | + | ||||
| 1602 | + | t.Run("async schedule and teardown rejection", func(t *testing.T) { | |||
| 1603 | + | d, sess, ac, _, clk, _ := newResizeFixture(t) | |||
| 1604 | + | ||||
| 1605 | + | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false)) | |||
| 1606 | + | awaitCoordinatorScheduledTimer(t, clk) | |||
| 1607 | + | ||||
| 1608 | + | // A torn-down coordinator cannot accept a stale attachment's schedule. | |||
| 1609 | + | sess.renderCoordinator().noteSessionTeardown() | |||
| 1610 | + | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false)) | |||
| 1611 | + | }) | |||
| 1612 | + | }) | |||
| 1561 | } | 1613 | } | ||
M internal/usecase/daemon/render_test.go +134 -0
| @@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||
| 55 | 55 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |
| 59 | + | for _, tc := range []struct { | |
| 60 | + | name string | |
| 61 | + | clientSize domain.Size | |
| 62 | + | }{ | |
| 63 | + | {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}}, | |
| 64 | + | {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}}, | |
| 65 | + | } { | |
| 66 | + | t.Run(tc.name, func(t *testing.T) { | |
| 67 | + | pty, releasePTY := newBlockingPTY(t) | |
| 68 | + | defer releasePTY() | |
| 69 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 70 | + | ac.sendMu.Lock() | |
| 71 | + | ac.size = tc.clientSize | |
| 72 | + | ac.sendMu.Unlock() | |
| 73 | + | ||
| 74 | + | // A retained visible popup still needs activation warmup. When the | |
| 75 | + | // outer terminal also changes, its completed transaction must cover | |
| 76 | + | // that popup rather than emitting a second reset-producing frame. | |
| 77 | + | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |
| 78 | + | installTestFloating(sess.activeTab(), floating, true) | |
| 79 | + | ||
| 80 | + | d.firstPaint(sess, ac, tc.clientSize) | |
| 81 | + | ||
| 82 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 83 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset") | |
| 86 | + | select { | |
| 87 | + | case extra := <-sends: | |
| 88 | + | t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra) | |
| 89 | + | default: | |
| 90 | + | } | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 58 | 95 | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |
| 59 | 96 | p, release := newBlockingPTY(t) | |
| 60 | 97 | defer release() | |
| @@ -254,6 +291,103 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 254 | 291 | }) | |
| 255 | 292 | } | |
| 256 | 293 | ||
| 294 | + | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | |
| 295 | + | for _, tc := range []struct { | |
| 296 | + | name string | |
| 297 | + | window uint64 | |
| 298 | + | exitBeforeACK bool | |
| 299 | + | exitSuffix string | |
| 300 | + | overlay bool | |
| 301 | + | }{ | |
| 302 | + | {name: "datagram output window (1): exit waits for ACK", window: 1}, | |
| 303 | + | {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |
| 304 | + | {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates}, | |
| 305 | + | {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |
| 306 | + | {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true}, | |
| 307 | + | } { | |
| 308 | + | t.Run(tc.name, func(t *testing.T) { | |
| 309 | + | pty, steps, processed := newChannelPTY(t) | |
| 310 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 311 | + | // Datagram clients use a one-frame window; local clients retain the | |
| 312 | + | // default eight-frame window. Both ACK orderings stay deterministic. | |
| 313 | + | ac.output.maxOutstanding = tc.window | |
| 314 | + | clock := newCoordinatorMockClock(t, 8) | |
| 315 | + | d.clock = clock.clock | |
| 316 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 317 | + | client := vt.NewScreen(80, 25) | |
| 318 | + | pane := sess.tabs[0].focusedPane() | |
| 319 | + | ||
| 320 | + | d.sessWg.Add(1) | |
| 321 | + | go d.ptyReader(sess, sess.tabs[0], pane) | |
| 322 | + | t.Cleanup(func() { | |
| 323 | + | steps <- channelPTYStep{err: io.EOF} | |
| 324 | + | d.sessWg.Wait() | |
| 325 | + | }) | |
| 326 | + | ||
| 327 | + | replay := func(data []byte, deadline time.Duration) ports.Output { | |
| 328 | + | t.Helper() | |
| 329 | + | steps <- channelPTYStep{data: data} | |
| 330 | + | awaitPTYReadProcessed(t, processed) | |
| 331 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline) | |
| 332 | + | return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 333 | + | } | |
| 334 | + | acknowledge := func(out ports.Output) { | |
| 335 | + | ac.ackOutputState(out.NewStateNum) | |
| 336 | + | rc.notifyAck() | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | if tc.overlay { | |
| 340 | + | d.enterPalette(sess, ac) | |
| 341 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| 342 | + | acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))) | |
| 343 | + | } | |
| 344 | + | acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline)) | |
| 345 | + | primary := client.Frame.Clone() | |
| 346 | + | ||
| 347 | + | // The reported PTY stream is deliberately split into enter, body, and | |
| 348 | + | // exit reads. MATRIX distinguishes alternate cells in the replayed | |
| 349 | + | // terminal model from all primary, chrome, and overlay cells. | |
| 350 | + | acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline)) | |
| 351 | + | alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline) | |
| 352 | + | pane.mu.Lock() | |
| 353 | + | require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer") | |
| 354 | + | pane.mu.Unlock() | |
| 355 | + | ||
| 356 | + | exitData := []byte("\x1b[?1049l" + tc.exitSuffix) | |
| 357 | + | var exit ports.Output | |
| 358 | + | if tc.exitBeforeACK { | |
| 359 | + | steps <- channelPTYStep{data: exitData} | |
| 360 | + | awaitPTYReadProcessed(t, processed) | |
| 361 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 362 | + | if tc.window == 1 { | |
| 363 | + | requireNoCoordinatorOutputFrame(t, sends) | |
| 364 | + | acknowledge(alternate) | |
| 365 | + | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 366 | + | } else { | |
| 367 | + | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 368 | + | require.Equal(t, alternate.NewStateNum, exit.BaseStateNum, | |
| 369 | + | "local exit must be rendered before the alternate-frame ACK") | |
| 370 | + | acknowledge(alternate) | |
| 371 | + | } | |
| 372 | + | } else { | |
| 373 | + | acknowledge(alternate) | |
| 374 | + | steps <- channelPTYStep{data: exitData} | |
| 375 | + | awaitPTYReadProcessed(t, processed) | |
| 376 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 377 | + | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 378 | + | } | |
| 379 | + | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |
| 380 | + | rendered := strings.Join(frameRows(client.Frame), "\n") | |
| 381 | + | require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells") | |
| 382 | + | if tc.exitSuffix == "" { | |
| 383 | + | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen") | |
| 384 | + | } else { | |
| 385 | + | require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame") | |
| 386 | + | } | |
| 387 | + | }) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 257 | 391 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | |
| 258 | 392 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | |
| 259 | 393 | pty, steps, processed := newChannelPTY(t) | |
| @@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||||
| 55 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | 55 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | + | func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 59 | + | for _, tc := range []struct { | |||
| 60 | + | name string | |||
| 61 | + | clientSize domain.Size | |||
| 62 | + | }{ | |||
| 63 | + | {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}}, | |||
| 64 | + | {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}}, | |||
| 65 | + | } { | |||
| 66 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 67 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 68 | + | defer releasePTY() | |||
| 69 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 70 | + | ac.sendMu.Lock() | |||
| 71 | + | ac.size = tc.clientSize | |||
| 72 | + | ac.sendMu.Unlock() | |||
| 73 | + | ||||
| 74 | + | // A retained visible popup still needs activation warmup. When the | |||
| 75 | + | // outer terminal also changes, its completed transaction must cover | |||
| 76 | + | // that popup rather than emitting a second reset-producing frame. | |||
| 77 | + | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 78 | + | installTestFloating(sess.activeTab(), floating, true) | |||
| 79 | + | ||||
| 80 | + | d.firstPaint(sess, ac, tc.clientSize) | |||
| 81 | + | ||||
| 82 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 83 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset") | |||
| 86 | + | select { | |||
| 87 | + | case extra := <-sends: | |||
| 88 | + | t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra) | |||
| 89 | + | default: | |||
| 90 | + | } | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 58 | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | 95 | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | ||
| 59 | p, release := newBlockingPTY(t) | 96 | p, release := newBlockingPTY(t) | ||
| 60 | defer release() | 97 | defer release() | ||
| @@ -254,6 +291,103 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||||
| 254 | }) | 291 | }) | ||
| 255 | } | 292 | } | ||
| 256 | 293 | ||||
| 294 | + | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | |||
| 295 | + | for _, tc := range []struct { | |||
| 296 | + | name string | |||
| 297 | + | window uint64 | |||
| 298 | + | exitBeforeACK bool | |||
| 299 | + | exitSuffix string | |||
| 300 | + | overlay bool | |||
| 301 | + | }{ | |||
| 302 | + | {name: "datagram output window (1): exit waits for ACK", window: 1}, | |||
| 303 | + | {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |||
| 304 | + | {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates}, | |||
| 305 | + | {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |||
| 306 | + | {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true}, | |||
| 307 | + | } { | |||
| 308 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 309 | + | pty, steps, processed := newChannelPTY(t) | |||
| 310 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 311 | + | // Datagram clients use a one-frame window; local clients retain the | |||
| 312 | + | // default eight-frame window. Both ACK orderings stay deterministic. | |||
| 313 | + | ac.output.maxOutstanding = tc.window | |||
| 314 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 315 | + | d.clock = clock.clock | |||
| 316 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 317 | + | client := vt.NewScreen(80, 25) | |||
| 318 | + | pane := sess.tabs[0].focusedPane() | |||
| 319 | + | ||||
| 320 | + | d.sessWg.Add(1) | |||
| 321 | + | go d.ptyReader(sess, sess.tabs[0], pane) | |||
| 322 | + | t.Cleanup(func() { | |||
| 323 | + | steps <- channelPTYStep{err: io.EOF} | |||
| 324 | + | d.sessWg.Wait() | |||
| 325 | + | }) | |||
| 326 | + | ||||
| 327 | + | replay := func(data []byte, deadline time.Duration) ports.Output { | |||
| 328 | + | t.Helper() | |||
| 329 | + | steps <- channelPTYStep{data: data} | |||
| 330 | + | awaitPTYReadProcessed(t, processed) | |||
| 331 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline) | |||
| 332 | + | return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 333 | + | } | |||
| 334 | + | acknowledge := func(out ports.Output) { | |||
| 335 | + | ac.ackOutputState(out.NewStateNum) | |||
| 336 | + | rc.notifyAck() | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | if tc.overlay { | |||
| 340 | + | d.enterPalette(sess, ac) | |||
| 341 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline) | |||
| 342 | + | acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))) | |||
| 343 | + | } | |||
| 344 | + | acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline)) | |||
| 345 | + | primary := client.Frame.Clone() | |||
| 346 | + | ||||
| 347 | + | // The reported PTY stream is deliberately split into enter, body, and | |||
| 348 | + | // exit reads. MATRIX distinguishes alternate cells in the replayed | |||
| 349 | + | // terminal model from all primary, chrome, and overlay cells. | |||
| 350 | + | acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline)) | |||
| 351 | + | alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline) | |||
| 352 | + | pane.mu.Lock() | |||
| 353 | + | require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer") | |||
| 354 | + | pane.mu.Unlock() | |||
| 355 | + | ||||
| 356 | + | exitData := []byte("\x1b[?1049l" + tc.exitSuffix) | |||
| 357 | + | var exit ports.Output | |||
| 358 | + | if tc.exitBeforeACK { | |||
| 359 | + | steps <- channelPTYStep{data: exitData} | |||
| 360 | + | awaitPTYReadProcessed(t, processed) | |||
| 361 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 362 | + | if tc.window == 1 { | |||
| 363 | + | requireNoCoordinatorOutputFrame(t, sends) | |||
| 364 | + | acknowledge(alternate) | |||
| 365 | + | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 366 | + | } else { | |||
| 367 | + | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 368 | + | require.Equal(t, alternate.NewStateNum, exit.BaseStateNum, | |||
| 369 | + | "local exit must be rendered before the alternate-frame ACK") | |||
| 370 | + | acknowledge(alternate) | |||
| 371 | + | } | |||
| 372 | + | } else { | |||
| 373 | + | acknowledge(alternate) | |||
| 374 | + | steps <- channelPTYStep{data: exitData} | |||
| 375 | + | awaitPTYReadProcessed(t, processed) | |||
| 376 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 377 | + | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 378 | + | } | |||
| 379 | + | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |||
| 380 | + | rendered := strings.Join(frameRows(client.Frame), "\n") | |||
| 381 | + | require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells") | |||
| 382 | + | if tc.exitSuffix == "" { | |||
| 383 | + | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen") | |||
| 384 | + | } else { | |||
| 385 | + | require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame") | |||
| 386 | + | } | |||
| 387 | + | }) | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 257 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | 391 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | ||
| 258 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | 392 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | ||
| 259 | pty, steps, processed := newChannelPTY(t) | 393 | pty, steps, processed := newChannelPTY(t) | ||
M internal/usecase/daemon/resume.go +5 -0
| @@ -48,6 +48,11 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 48 | 48 | if !d.prepareParkAttachment(sess, ac) { | |
| 49 | 49 | return false | |
| 50 | 50 | } | |
| 51 | + | // A parked attachment has no session owner. Release pane snapshots before | |
| 52 | + | // publishing it in the daemon registry so headless pane/session teardown | |
| 53 | + | // cannot leave closed panes retained through its capture cache. This must | |
| 54 | + | // remain outside d.mu: sendMu is ordered before the daemon lock. | |
| 55 | + | ac.clearCaptureFrames() | |
| 51 | 56 | d.mu.Lock() | |
| 52 | 57 | if d.closing { | |
| 53 | 58 | d.mu.Unlock() | |
| @@ -48,6 +48,11 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||||
| 48 | if !d.prepareParkAttachment(sess, ac) { | 48 | if !d.prepareParkAttachment(sess, ac) { | ||
| 49 | return false | 49 | return false | ||
| 50 | } | 50 | } | ||
| 51 | + | // A parked attachment has no session owner. Release pane snapshots before | |||
| 52 | + | // publishing it in the daemon registry so headless pane/session teardown | |||
| 53 | + | // cannot leave closed panes retained through its capture cache. This must | |||
| 54 | + | // remain outside d.mu: sendMu is ordered before the daemon lock. | |||
| 55 | + | ac.clearCaptureFrames() | |||
| 51 | d.mu.Lock() | 56 | d.mu.Lock() | ||
| 52 | if d.closing { | 57 | if d.closing { | ||
| 53 | d.mu.Unlock() | 58 | d.mu.Unlock() | ||
M internal/usecase/daemon/resume_test.go +57 -0
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "strings" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -10,6 +11,8 @@ import ( | |||
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | + | "github.com/bnema/vev/pkg/vt" | |
| 13 | 16 | ) | |
| 14 | 17 | ||
| 15 | 18 | type closeTrackingTransport struct { | |
| @@ -296,6 +299,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 296 | 299 | require.Equal(t, first.NewStateNum, second.BaseStateNum) | |
| 297 | 300 | } | |
| 298 | 301 | ||
| 302 | + | func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |
| 303 | + | pty, release := newBlockingPTY(t) | |
| 304 | + | defer release() | |
| 305 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 306 | + | oldTransport := &closeTrackingTransport{} | |
| 307 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | ||
| 310 | + | tb := sess.tabs[0] | |
| 311 | + | survivor := tb.panes["pane-1"] | |
| 312 | + | closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23}) | |
| 313 | + | tb.mu.Lock() | |
| 314 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 315 | + | tb.panes[closed.id] = closed | |
| 316 | + | d.applyLayoutLocked(tb) | |
| 317 | + | tb.mu.Unlock() | |
| 318 | + | survivor.screen.Write([]byte("survivor")) | |
| 319 | + | closed.screen.Write([]byte("closed")) | |
| 320 | + | d.paint(sess, ac, true) | |
| 321 | + | ||
| 322 | + | ac.sendMu.Lock() | |
| 323 | + | require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking") | |
| 324 | + | ac.sendMu.Unlock() | |
| 325 | + | token := ac.resumeToken | |
| 326 | + | require.True(t, sess.detachIfCurrent(ac)) | |
| 327 | + | require.True(t, d.parkAttachment(sess, ac)) | |
| 328 | + | ||
| 329 | + | // Headless close cannot find the parked attachment through sess.client. | |
| 330 | + | // Its capture must already have been released before the attachment parked. | |
| 331 | + | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | |
| 332 | + | ac.sendMu.Lock() | |
| 333 | + | require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless") | |
| 334 | + | ac.sendMu.Unlock() | |
| 335 | + | ||
| 336 | + | newTransport := &closeTrackingTransport{} | |
| 337 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport) | |
| 338 | + | require.NoError(t, err) | |
| 339 | + | require.Same(t, sess, resumedSess) | |
| 340 | + | require.Same(t, ac, resumedAC) | |
| 341 | + | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC)) | |
| 342 | + | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |
| 343 | + | ||
| 344 | + | sends := newTransport.Sends() | |
| 345 | + | require.Len(t, sends, 1) | |
| 346 | + | output, err := ports.UnmarshalOutput(sends[0].Payload) | |
| 347 | + | require.NoError(t, err) | |
| 348 | + | require.Zero(t, output.BaseStateNum, "resume must start with a complete frame") | |
| 349 | + | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | |
| 350 | + | terminal.Write(output.Data) | |
| 351 | + | contents := strings.Join(frameRows(terminal.Frame), "\n") | |
| 352 | + | require.Contains(t, contents, "survivor", "resume first paint must contain current headless content") | |
| 353 | + | require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content") | |
| 354 | + | } | |
| 355 | + | ||
| 299 | 356 | func TestExplicitDetachDoesNotPark(t *testing.T) { | |
| 300 | 357 | pty, release := newBlockingPTY(t) | |
| 301 | 358 | defer release() | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "strings" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -10,6 +11,8 @@ import ( | |||||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 15 | + | "github.com/bnema/vev/pkg/vt" | |||
| 13 | ) | 16 | ) | ||
| 14 | 17 | ||||
| 15 | type closeTrackingTransport struct { | 18 | type closeTrackingTransport struct { | ||
| @@ -296,6 +299,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 296 | require.Equal(t, first.NewStateNum, second.BaseStateNum) | 299 | require.Equal(t, first.NewStateNum, second.BaseStateNum) | ||
| 297 | } | 300 | } | ||
| 298 | 301 | ||||
| 302 | + | func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 303 | + | pty, release := newBlockingPTY(t) | |||
| 304 | + | defer release() | |||
| 305 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 306 | + | oldTransport := &closeTrackingTransport{} | |||
| 307 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |||
| 308 | + | require.NoError(t, err) | |||
| 309 | + | ||||
| 310 | + | tb := sess.tabs[0] | |||
| 311 | + | survivor := tb.panes["pane-1"] | |||
| 312 | + | closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23}) | |||
| 313 | + | tb.mu.Lock() | |||
| 314 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |||
| 315 | + | tb.panes[closed.id] = closed | |||
| 316 | + | d.applyLayoutLocked(tb) | |||
| 317 | + | tb.mu.Unlock() | |||
| 318 | + | survivor.screen.Write([]byte("survivor")) | |||
| 319 | + | closed.screen.Write([]byte("closed")) | |||
| 320 | + | d.paint(sess, ac, true) | |||
| 321 | + | ||||
| 322 | + | ac.sendMu.Lock() | |||
| 323 | + | require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking") | |||
| 324 | + | ac.sendMu.Unlock() | |||
| 325 | + | token := ac.resumeToken | |||
| 326 | + | require.True(t, sess.detachIfCurrent(ac)) | |||
| 327 | + | require.True(t, d.parkAttachment(sess, ac)) | |||
| 328 | + | ||||
| 329 | + | // Headless close cannot find the parked attachment through sess.client. | |||
| 330 | + | // Its capture must already have been released before the attachment parked. | |||
| 331 | + | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | |||
| 332 | + | ac.sendMu.Lock() | |||
| 333 | + | require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless") | |||
| 334 | + | ac.sendMu.Unlock() | |||
| 335 | + | ||||
| 336 | + | newTransport := &closeTrackingTransport{} | |||
| 337 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport) | |||
| 338 | + | require.NoError(t, err) | |||
| 339 | + | require.Same(t, sess, resumedSess) | |||
| 340 | + | require.Same(t, ac, resumedAC) | |||
| 341 | + | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC)) | |||
| 342 | + | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |||
| 343 | + | ||||
| 344 | + | sends := newTransport.Sends() | |||
| 345 | + | require.Len(t, sends, 1) | |||
| 346 | + | output, err := ports.UnmarshalOutput(sends[0].Payload) | |||
| 347 | + | require.NoError(t, err) | |||
| 348 | + | require.Zero(t, output.BaseStateNum, "resume must start with a complete frame") | |||
| 349 | + | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | |||
| 350 | + | terminal.Write(output.Data) | |||
| 351 | + | contents := strings.Join(frameRows(terminal.Frame), "\n") | |||
| 352 | + | require.Contains(t, contents, "survivor", "resume first paint must contain current headless content") | |||
| 353 | + | require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content") | |||
| 354 | + | } | |||
| 355 | + | ||||
| 299 | func TestExplicitDetachDoesNotPark(t *testing.T) { | 356 | func TestExplicitDetachDoesNotPark(t *testing.T) { | ||
| 300 | pty, release := newBlockingPTY(t) | 357 | pty, release := newBlockingPTY(t) | ||
| 301 | defer release() | 358 | defer release() | ||
M internal/usecase/daemon/session.go +7 -3
| @@ -632,14 +632,17 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 632 | 632 | markSnapshotDirty(sess) | |
| 633 | 633 | ||
| 634 | 634 | d.clearDestroyedTabPreview(tb) | |
| 635 | + | tb.mu.Lock() | |
| 636 | + | panes := tb.panesSnapshot() | |
| 637 | + | tb.mu.Unlock() | |
| 638 | + | if ac != nil { | |
| 639 | + | ac.pruneCaptureFrames(panes...) | |
| 640 | + | } | |
| 635 | 641 | if tb.cancel != nil { | |
| 636 | 642 | tb.cancel() | |
| 637 | 643 | } | |
| 638 | 644 | d.teardownFloating(tb, ac) | |
| 639 | 645 | if rc := sess.renderCoordinator(); rc != nil { | |
| 640 | - | tb.mu.Lock() | |
| 641 | - | panes := tb.panesSnapshot() | |
| 642 | - | tb.mu.Unlock() | |
| 643 | 646 | for _, p := range panes { | |
| 644 | 647 | rc.noteSyncPaneRemoved(p) | |
| 645 | 648 | } | |
| @@ -742,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 742 | 745 | d.unregisterPreview(ac) | |
| 743 | 746 | ac.clearPreviousSession() | |
| 744 | 747 | ac.setSession(nil) | |
| 748 | + | ac.clearCaptureFrames() | |
| 745 | 749 | } | |
| 746 | 750 | ||
| 747 | 751 | // Prevent queued launches from entering Open, then cancel the parent | |
| @@ -632,14 +632,17 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 632 | markSnapshotDirty(sess) | 632 | markSnapshotDirty(sess) | ||
| 633 | 633 | ||||
| 634 | d.clearDestroyedTabPreview(tb) | 634 | d.clearDestroyedTabPreview(tb) | ||
| 635 | + | tb.mu.Lock() | |||
| 636 | + | panes := tb.panesSnapshot() | |||
| 637 | + | tb.mu.Unlock() | |||
| 638 | + | if ac != nil { | |||
| 639 | + | ac.pruneCaptureFrames(panes...) | |||
| 640 | + | } | |||
| 635 | if tb.cancel != nil { | 641 | if tb.cancel != nil { | ||
| 636 | tb.cancel() | 642 | tb.cancel() | ||
| 637 | } | 643 | } | ||
| 638 | d.teardownFloating(tb, ac) | 644 | d.teardownFloating(tb, ac) | ||
| 639 | if rc := sess.renderCoordinator(); rc != nil { | 645 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 640 | - | tb.mu.Lock() | |||
| 641 | - | panes := tb.panesSnapshot() | |||
| 642 | - | tb.mu.Unlock() | |||
| 643 | for _, p := range panes { | 646 | for _, p := range panes { | ||
| 644 | rc.noteSyncPaneRemoved(p) | 647 | rc.noteSyncPaneRemoved(p) | ||
| 645 | } | 648 | } | ||
| @@ -742,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 742 | d.unregisterPreview(ac) | 745 | d.unregisterPreview(ac) | ||
| 743 | ac.clearPreviousSession() | 746 | ac.clearPreviousSession() | ||
| 744 | ac.setSession(nil) | 747 | ac.setSession(nil) | ||
| 748 | + | ac.clearCaptureFrames() | |||
| 745 | } | 749 | } | ||
| 746 | 750 | ||||
| 747 | // Prevent queued launches from entering Open, then cancel the parent | 751 | // Prevent queued launches from entering Open, then cancel the parent | ||
M internal/usecase/daemon/transactional_resize.go +20 -14
| @@ -163,25 +163,25 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||
| 163 | 163 | markSnapshotDirty(sess) | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | - | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) { | |
| 166 | + | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool { | |
| 167 | 167 | rc := sess.renderCoordinator() | |
| 168 | 168 | if rc == nil { | |
| 169 | - | return | |
| 169 | + | return false | |
| 170 | 170 | } | |
| 171 | 171 | snap := rc.resizeSnapshot() | |
| 172 | 172 | if !rc.resizeCurrent(epoch, ac, false) { | |
| 173 | - | return | |
| 173 | + | return false | |
| 174 | 174 | } | |
| 175 | 175 | d.exitCopyMode(ac) | |
| 176 | 176 | plan := d.prepareResize(sess, snap.size) | |
| 177 | 177 | if !rc.resizeCurrent(epoch, ac, false) { | |
| 178 | - | return | |
| 178 | + | return false | |
| 179 | 179 | } | |
| 180 | 180 | if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) { | |
| 181 | - | return | |
| 181 | + | return false | |
| 182 | 182 | } | |
| 183 | 183 | if !rc.resizeCurrent(epoch, ac, true) { | |
| 184 | - | return | |
| 184 | + | return false | |
| 185 | 185 | } | |
| 186 | 186 | d.commitResize(sess, ac, plan) | |
| 187 | 187 | failed := make([]resizeMember, 0, len(plan.members)) | |
| @@ -196,11 +196,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u | |||
| 196 | 196 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 197 | 197 | // A successful transaction publishes exactly one full S2 frame. The | |
| 198 | 198 | // coordinator is the only emission route and stale epochs never reach it. | |
| 199 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |
| 199 | + | if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |
| 200 | + | return false | |
| 201 | + | } | |
| 200 | 202 | // The resize debounce has already elapsed. Consume this sticky reset now; | |
| 201 | 203 | // fire preserves ACK and synchronized-output gates rather than scheduling a | |
| 202 | 204 | // second urgent deadline. | |
| 203 | 205 | rc.fireCurrent(false) | |
| 206 | + | return true | |
| 204 | 207 | } | |
| 205 | 208 | ||
| 206 | 209 | // retryResizeMembers retries only members which failed the committed epoch. | |
| @@ -242,9 +245,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin | |||
| 242 | 245 | } | |
| 243 | 246 | } | |
| 244 | 247 | ||
| 245 | - | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) { | |
| 248 | + | // requestTransactionalResize reports reset invalidation completion for immediate | |
| 249 | + | // attached requests, and coordinator schedule acceptance for async requests. | |
| 250 | + | // Headless requests have no reset invalidation and report geometry completion. | |
| 251 | + | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool { | |
| 246 | 252 | if sess == nil || !size.Valid() { | |
| 247 | - | return | |
| 253 | + | return false | |
| 248 | 254 | } | |
| 249 | 255 | if ac == nil { | |
| 250 | 256 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| @@ -252,15 +258,15 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s | |||
| 252 | 258 | plan := d.prepareResize(sess, size) | |
| 253 | 259 | d.applyResize(&plan) | |
| 254 | 260 | d.commitResize(sess, nil, plan) | |
| 255 | - | return | |
| 261 | + | return true | |
| 256 | 262 | } | |
| 257 | 263 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 258 | 264 | if immediate { | |
| 259 | 265 | epoch := rc.recordResizeRequest(size, ac) | |
| 260 | - | if epoch != 0 { | |
| 261 | - | d.runResizeTransaction(sess, ac, epoch) | |
| 266 | + | if epoch == 0 { | |
| 267 | + | return false | |
| 262 | 268 | } | |
| 263 | - | return | |
| 269 | + | return d.runResizeTransaction(sess, ac, epoch) | |
| 264 | 270 | } | |
| 265 | - | rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) | |
| 271 | + | return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0 | |
| 266 | 272 | } | |
| @@ -163,25 +163,25 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||||
| 163 | markSnapshotDirty(sess) | 163 | markSnapshotDirty(sess) | ||
| 164 | } | 164 | } | ||
| 165 | 165 | ||||
| 166 | - | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) { | 166 | + | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool { |
| 167 | rc := sess.renderCoordinator() | 167 | rc := sess.renderCoordinator() | ||
| 168 | if rc == nil { | 168 | if rc == nil { | ||
| 169 | - | return | 169 | + | return false |
| 170 | } | 170 | } | ||
| 171 | snap := rc.resizeSnapshot() | 171 | snap := rc.resizeSnapshot() | ||
| 172 | if !rc.resizeCurrent(epoch, ac, false) { | 172 | if !rc.resizeCurrent(epoch, ac, false) { | ||
| 173 | - | return | 173 | + | return false |
| 174 | } | 174 | } | ||
| 175 | d.exitCopyMode(ac) | 175 | d.exitCopyMode(ac) | ||
| 176 | plan := d.prepareResize(sess, snap.size) | 176 | plan := d.prepareResize(sess, snap.size) | ||
| 177 | if !rc.resizeCurrent(epoch, ac, false) { | 177 | if !rc.resizeCurrent(epoch, ac, false) { | ||
| 178 | - | return | 178 | + | return false |
| 179 | } | 179 | } | ||
| 180 | if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) { | 180 | if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) { | ||
| 181 | - | return | 181 | + | return false |
| 182 | } | 182 | } | ||
| 183 | if !rc.resizeCurrent(epoch, ac, true) { | 183 | if !rc.resizeCurrent(epoch, ac, true) { | ||
| 184 | - | return | 184 | + | return false |
| 185 | } | 185 | } | ||
| 186 | d.commitResize(sess, ac, plan) | 186 | d.commitResize(sess, ac, plan) | ||
| 187 | failed := make([]resizeMember, 0, len(plan.members)) | 187 | failed := make([]resizeMember, 0, len(plan.members)) | ||
| @@ -196,11 +196,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u | |||||
| 196 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 196 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 197 | // A successful transaction publishes exactly one full S2 frame. The | 197 | // A successful transaction publishes exactly one full S2 frame. The | ||
| 198 | // coordinator is the only emission route and stale epochs never reach it. | 198 | // coordinator is the only emission route and stale epochs never reach it. | ||
| 199 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | 199 | + | if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { |
| 200 | + | return false | |||
| 201 | + | } | |||
| 200 | // The resize debounce has already elapsed. Consume this sticky reset now; | 202 | // The resize debounce has already elapsed. Consume this sticky reset now; | ||
| 201 | // fire preserves ACK and synchronized-output gates rather than scheduling a | 203 | // fire preserves ACK and synchronized-output gates rather than scheduling a | ||
| 202 | // second urgent deadline. | 204 | // second urgent deadline. | ||
| 203 | rc.fireCurrent(false) | 205 | rc.fireCurrent(false) | ||
| 206 | + | return true | |||
| 204 | } | 207 | } | ||
| 205 | 208 | ||||
| 206 | // retryResizeMembers retries only members which failed the committed epoch. | 209 | // retryResizeMembers retries only members which failed the committed epoch. | ||
| @@ -242,9 +245,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin | |||||
| 242 | } | 245 | } | ||
| 243 | } | 246 | } | ||
| 244 | 247 | ||||
| 245 | - | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) { | 248 | + | // requestTransactionalResize reports reset invalidation completion for immediate |
| 249 | + | // attached requests, and coordinator schedule acceptance for async requests. | |||
| 250 | + | // Headless requests have no reset invalidation and report geometry completion. | |||
| 251 | + | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool { | |||
| 246 | if sess == nil || !size.Valid() { | 252 | if sess == nil || !size.Valid() { | ||
| 247 | - | return | 253 | + | return false |
| 248 | } | 254 | } | ||
| 249 | if ac == nil { | 255 | if ac == nil { | ||
| 250 | // Headless geometry has no coordinator/transport to coalesce, but keeps | 256 | // Headless geometry has no coordinator/transport to coalesce, but keeps | ||
| @@ -252,15 +258,15 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s | |||||
| 252 | plan := d.prepareResize(sess, size) | 258 | plan := d.prepareResize(sess, size) | ||
| 253 | d.applyResize(&plan) | 259 | d.applyResize(&plan) | ||
| 254 | d.commitResize(sess, nil, plan) | 260 | d.commitResize(sess, nil, plan) | ||
| 255 | - | return | 261 | + | return true |
| 256 | } | 262 | } | ||
| 257 | rc := d.attachCoordinator(sess, nil, ac, true) | 263 | rc := d.attachCoordinator(sess, nil, ac, true) | ||
| 258 | if immediate { | 264 | if immediate { | ||
| 259 | epoch := rc.recordResizeRequest(size, ac) | 265 | epoch := rc.recordResizeRequest(size, ac) | ||
| 260 | - | if epoch != 0 { | 266 | + | if epoch == 0 { |
| 261 | - | d.runResizeTransaction(sess, ac, epoch) | 267 | + | return false |
| 262 | } | 268 | } | ||
| 263 | - | return | 269 | + | return d.runResizeTransaction(sess, ac, epoch) |
| 264 | } | 270 | } | ||
| 265 | - | rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) | 271 | + | return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0 |
| 266 | } | 272 | } | ||
M pkg/vt/screen.go +13 -7
| @@ -92,12 +92,13 @@ type Screen struct { | |||
| 92 | 92 | defaultColorsKnown bool | |
| 93 | 93 | terminalTitle string | |
| 94 | 94 | ||
| 95 | - | damage []renderer.Damage | |
| 96 | - | damageGeneration uint64 | |
| 97 | - | damageSaturated bool | |
| 98 | - | escapeBuf []byte | |
| 99 | - | csiScratch []int | |
| 100 | - | sgrScratch []int | |
| 95 | + | damage []renderer.Damage | |
| 96 | + | damageGeneration uint64 | |
| 97 | + | damageSaturated bool | |
| 98 | + | damageFullRedrawSticky bool | |
| 99 | + | escapeBuf []byte | |
| 100 | + | csiScratch []int | |
| 101 | + | sgrScratch []int | |
| 101 | 102 | ||
| 102 | 103 | scrollTop int | |
| 103 | 104 | scrollBottom int | |
| @@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||
| 191 | 192 | func (s *Screen) ClearDamage() { | |
| 192 | 193 | s.damage = s.damage[:0] | |
| 193 | 194 | s.damageSaturated = false | |
| 195 | + | s.damageFullRedrawSticky = false | |
| 194 | 196 | } | |
| 195 | 197 | ||
| 196 | 198 | // CaptureDamage snapshots pending damage without consuming it. | |
| @@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 652 | 654 | ||
| 653 | 655 | func (s *Screen) record(d renderer.Damage) { | |
| 654 | 656 | s.damageGeneration++ | |
| 655 | - | if s.damageSaturated { | |
| 657 | + | if s.damageSaturated || s.damageFullRedrawSticky { | |
| 656 | 658 | return | |
| 657 | 659 | } | |
| 658 | 660 | if len(s.damage) >= maxPendingDamage { | |
| @@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) { | |||
| 679 | 681 | func (s *Screen) fullRedraw() { | |
| 680 | 682 | s.damageGeneration++ | |
| 681 | 683 | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 684 | + | // A structural frame replacement (such as DEC 1049 exit) cannot be | |
| 685 | + | // represented by the following text damage alone. Retain the redraw until | |
| 686 | + | // the render owner acknowledges this screen generation. | |
| 687 | + | s.damageFullRedrawSticky = true | |
| 682 | 688 | } | |
| 683 | 689 | ||
| 684 | 690 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |
| @@ -92,12 +92,13 @@ type Screen struct { | |||||
| 92 | defaultColorsKnown bool | 92 | defaultColorsKnown bool | ||
| 93 | terminalTitle string | 93 | terminalTitle string | ||
| 94 | 94 | ||||
| 95 | - | damage []renderer.Damage | 95 | + | damage []renderer.Damage |
| 96 | - | damageGeneration uint64 | 96 | + | damageGeneration uint64 |
| 97 | - | damageSaturated bool | 97 | + | damageSaturated bool |
| 98 | - | escapeBuf []byte | 98 | + | damageFullRedrawSticky bool |
| 99 | - | csiScratch []int | 99 | + | escapeBuf []byte |
| 100 | - | sgrScratch []int | 100 | + | csiScratch []int |
| 101 | + | sgrScratch []int | |||
| 101 | 102 | ||||
| 102 | scrollTop int | 103 | scrollTop int | ||
| 103 | scrollBottom int | 104 | scrollBottom int | ||
| @@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||||
| 191 | func (s *Screen) ClearDamage() { | 192 | func (s *Screen) ClearDamage() { | ||
| 192 | s.damage = s.damage[:0] | 193 | s.damage = s.damage[:0] | ||
| 193 | s.damageSaturated = false | 194 | s.damageSaturated = false | ||
| 195 | + | s.damageFullRedrawSticky = false | |||
| 194 | } | 196 | } | ||
| 195 | 197 | ||||
| 196 | // CaptureDamage snapshots pending damage without consuming it. | 198 | // CaptureDamage snapshots pending damage without consuming it. | ||
| @@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||||
| 652 | 654 | ||||
| 653 | func (s *Screen) record(d renderer.Damage) { | 655 | func (s *Screen) record(d renderer.Damage) { | ||
| 654 | s.damageGeneration++ | 656 | s.damageGeneration++ | ||
| 655 | - | if s.damageSaturated { | 657 | + | if s.damageSaturated || s.damageFullRedrawSticky { |
| 656 | return | 658 | return | ||
| 657 | } | 659 | } | ||
| 658 | if len(s.damage) >= maxPendingDamage { | 660 | if len(s.damage) >= maxPendingDamage { | ||
| @@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) { | |||||
| 679 | func (s *Screen) fullRedraw() { | 681 | func (s *Screen) fullRedraw() { | ||
| 680 | s.damageGeneration++ | 682 | s.damageGeneration++ | ||
| 681 | s.damage = []renderer.Damage{renderer.FullRedraw()} | 683 | s.damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 684 | + | // A structural frame replacement (such as DEC 1049 exit) cannot be | |||
| 685 | + | // represented by the following text damage alone. Retain the redraw until | |||
| 686 | + | // the render owner acknowledges this screen generation. | |||
| 687 | + | s.damageFullRedrawSticky = true | |||
| 682 | } | 688 | } | ||
| 683 | 689 | ||||
| 684 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | 690 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | ||