detached e5fa94f

Merge pull request #79 from bnema/fix/render-refresh-stale-state

fix(render): prevent stale frames across transitions
commit
e5fa94fc95420e59683fc856566ab90bcd4c1a98
author
bnema · · 2026-07-13 07:53 UTC
committer
GitHub · · 2026-07-13 07:53 UTC
merge of
05e3a12 + ef6cad6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +502 -66
M internal/usecase/daemon/client.go +53 -20
@@ -14,7 +14,6 @@ import (
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
1616 "github.com/bnema/vev/internal/usecase/keys"
17- "github.com/bnema/vev/internal/usecase/layout"
1817 "github.com/bnema/vev/internal/usecase/mouse"
1918 themeui "github.com/bnema/vev/internal/usecase/theme"
2019 )
@@ -41,17 +40,19 @@ type attachedClient struct {
4140 // sendMu and must never share mutable backing storage.
4241 pipelineCache composeCacheInput
4342 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
5556 // previousSession is guarded independently. It is retained through temporary
5657 // setSession(nil) hand-offs and cleared only on terminal teardown.
5758 previousSession Guarded[*session]
@@ -113,6 +114,30 @@ func (ac *attachedClient) clearPreviousSession() {
113114 }
114115 }
115116
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+
116141 func (ac *attachedClient) getTheme() themeui.Theme {
117142 ac.themeMu.Lock()
118143 defer ac.themeMu.Unlock()
@@ -383,6 +408,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
383408 }
384409 current.sendMu.Lock()
385410 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
386415 current.sendMu.Unlock()
387416 d.attachCoordinator(target, old, current, true)
388417 }
@@ -470,14 +499,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
470499 wsz := tb.size
471500 tb.mu.Unlock()
472501
502+ outerResizeAccepted := false
473503 if clientSize.Valid() && wsz != tabSize(clientSize) {
474- d.resizeForFirstPaint(sess, ac, clientSize)
504+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
475505 }
476506 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+ }
481514 }
482515
483516 // 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) {
613646 }
614647
615648 // 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)
619652 }
620653
621654 // 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 composeCacheInput41 pipelineCache composeCacheInput
43 pipelineScratch composeCacheInput42 pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held43+ renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held44+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
46- size domain.Size45+ // snapshots cannot leak when an attachment switches tabs or sessions.
47- keys *keys.Router46+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
48- sess Guarded[*session]47+ size domain.Size
49- mouseScan mouse.Scanner48+ keys *keys.Router
50- themeMu sync.Mutex49+ sess Guarded[*session]
51- theme themeui.Theme50+ mouseScan mouse.Scanner
52- clientTheme themeui.Theme51+ themeMu sync.Mutex
53- lastCursor cursorOut52+ theme themeui.Theme
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held53+ 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 temporary56 // 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.size499 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 the507+ // 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 messages516 // 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. The648 // 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 session654 // 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) {
154154 // activateTab performs the work associated with making a tab the destination.
155155 // It is deliberately idempotent: every tab transition can use this single
156156 // 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 {
158164 if d == nil || sess == nil || tb == nil {
159- return
165+ return false
160166 }
161167 // A headless session has no actual terminal destination. Deferring warmup
162168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166172 sess.mu.Unlock()
167173 d.exitCopyMode(ac)
168174 if ac == nil {
169- return
175+ return false
170176 }
171177 d.ensureFloatingWarm(sess, tb)
178+ if outerResizeAccepted {
179+ return false
180+ }
172181 tb.mu.Lock()
173182 hasFloating := tb.floating.pane != nil
174183 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175184 tb.mu.Unlock()
176185 if hasFloating {
177- d.requestTransactionalResize(sess, ac, size, true)
186+ return d.requestTransactionalResize(sess, ac, size, true)
178187 }
188+ return false
179189 }
180190
181191 // 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
427437 sess.mu.Lock()
428438 ac := sess.client
429439 sess.mu.Unlock()
440+ if ac != nil {
441+ ac.pruneCaptureFrames(p)
442+ }
430443 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
431444 if ac != nil && (visible || copyCleared) {
432445 d.invalidateRender(sess, ac, true, "floating.go")
@@ -443,6 +456,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
443456 tb.mu.Unlock()
444457 if ac != nil {
445458 ac.overlays.clearCopyModeForPane(p)
459+ ac.pruneCaptureFrames(p)
446460 }
447461 closeFloatingPane(p)
448462 }
@@ -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 single155 // 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- return165+ return false
160 }166 }
161 // A headless session has no actual terminal destination. Deferring warmup167 // A headless session has no actual terminal destination. Deferring warmup
162 // until firstPaint keeps restored tabs cold and avoids launching children168 // 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- return175+ 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 != nil182 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 slot191 // 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.client438 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) {
8787 d.sessWg.Wait()
8888 }
8989
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+
90161 func TestAltCForwardsToPTY(t *testing.T) {
91162 writes := make(chan []byte, 2)
92163 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
255255
256256 if ac != nil {
257257 ac.overlays.clearCopyModeForPane(p)
258+ ac.pruneCaptureFrames(p)
258259 }
259260
260261 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
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67 "time"
78
@@ -14,6 +15,7 @@ import (
1415 "github.com/bnema/vev/internal/usecase/keys"
1516 "github.com/bnema/vev/internal/usecase/layout"
1617 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
1719 )
1820
1921 // --- test doubles -----------------------------------------------------------
@@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
216218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
217219 }
218220
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+
219262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
220263 p1, releasePTY1 := newBlockingPTY(t)
221264 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
132132 width := min(max(visible.Width, 0), p.screen.Frame.Width)
133133 height := min(max(visible.Height, 0), p.screen.Frame.Height)
134134 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 {
136137 out.frame = renderer.NewFrame(width, height)
137138 needsFrame = true
138139 }
@@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
144145 }
145146 }
146147
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.
148152 out.damage = []renderer.Damage{renderer.FullRedraw()}
149153 } else {
150154 out.damage = append(out.damage[:0], damage.Damage...)
@@ -221,6 +225,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
221225 }
222226 state.panes = scratch.panes[:0]
223227 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
228+ if ac.captureFrames == nil {
229+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
230+ }
224231 for _, placement := range layoutSnap.placements {
225232 p := tb.panes[placement.ID]
226233 if p == nil {
@@ -231,10 +238,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
231238 visible = domain.Rect{}
232239 }
233240 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])
238242 if mode == damageCaptureConsume {
239243 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240244 }
@@ -248,7 +252,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
248252 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249253 }
250254 captured.rawDamage, captured.damage = captured.damage, translated
251- ac.captureFrames[captured.id] = captured
255+ ac.captureFrames[p] = captured
252256 state.panes = append(state.panes, captured)
253257 }
254258 scratch.panes = state.panes
@@ -256,10 +260,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
256260 p := tb.floating.pane
257261 p.mu.Lock()
258262 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])
263264 if mode == damageCaptureConsume {
264265 seen := false
265266 for _, receipt := range state.receipts {
@@ -272,7 +273,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
272273 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273274 }
274275 }
275- ac.captureFrames[captured.id] = captured
276+ ac.captureFrames[p] = captured
276277 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277278 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278279 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) > 0134 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 = true138 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, translated254 captured.rawDamage, captured.damage = captured.damage, translated
251- ac.captureFrames[captured.id] = captured255+ ac.captureFrames[p] = captured
252 state.panes = append(state.panes, captured)256 state.panes = append(state.panes, captured)
253 }257 }
254 scratch.panes = state.panes258 scratch.panes = state.panes
@@ -256,10 +260,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
256 p := tb.floating.pane260 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 := false265 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] = captured276+ 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) {
265265 // attachment. This check shares c.mu with replacement, making a stale
266266 // attachment's timer callback unable to enqueue work for its replacement.
267267 // 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 {
269276 c.mu.Lock()
270277 // An unbound coordinator is the manual/headless harness state; there is no
271278 // published production attachment to protect there. A parked target may
272279 // still publish its own PTY/session mutations to picker observers, but no
273280 // attachment-owned callback may revive that target's render path.
274281 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)) {
276284 c.mu.Unlock()
277- return
285+ return false
278286 }
279287 onInvalidate := c.opts.onInvalidate
280288 c.metrics.invalidations.Add(1)
@@ -310,7 +318,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
310318 if onInvalidate != nil {
311319 onInvalidate(inv)
312320 }
313- return
321+ return true
314322 }
315323
316324 // NewTimer and C may re-enter the coordinator. Publish only if this
@@ -330,7 +338,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
330338 if onInvalidate != nil {
331339 onInvalidate(inv)
332340 }
333- return
341+ return true
334342 }
335343 if timerC == nil {
336344 stopTimer(timer)
@@ -338,7 +346,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
338346 if onInvalidate != nil {
339347 onInvalidate(inv)
340348 }
341- return
349+ return true
342350 }
343351 go func() {
344352 defer close(done)
@@ -356,6 +364,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
356364 onInvalidate(inv)
357365 }
358366 runtime.Gosched()
367+ return true
359368 }
360369
361370 // 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 stale265 // 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 no277 // An unbound coordinator is the manual/headless harness state; there is no
271 // published production attachment to protect there. A parked target may278 // published production attachment to protect there. A parked target may
272 // still publish its own PTY/session mutations to picker observers, but no279 // 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- return285+ return false
278 }286 }
279 onInvalidate := c.opts.onInvalidate287 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- return321+ return true
314 }322 }
315 323
316 // NewTimer and C may re-enter the coordinator. Publish only if this324 // 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- return341+ 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- return349+ 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, releasing370 // 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) {
15581558 requireNoInvalidation(t, invs)
15591559 requireNoCoordinatorOutputFrame(t, sends)
15601560 })
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+ })
15611613 }
@@ -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) {
5555 require.True(t, paletteVisible, "palette must remain composed above copy mode")
5656 }
5757
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+
5895 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
5996 p, release := newBlockingPTY(t)
6097 defer release()
@@ -254,6 +291,103 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
254291 })
255292 }
256293
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+
257391 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
258392 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
259393 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 {
4848 if !d.prepareParkAttachment(sess, ac) {
4949 return false
5050 }
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()
5156 d.mu.Lock()
5257 if d.closing {
5358 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 false49 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
22
33 import (
44 "errors"
5+ "strings"
56 "sync"
67 "testing"
78 "time"
@@ -10,6 +11,8 @@ import (
1011
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/pkg/vt"
1316 )
1417
1518 type closeTrackingTransport struct {
@@ -296,6 +299,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
296299 require.Equal(t, first.NewStateNum, second.BaseStateNum)
297300 }
298301
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+
299356 func TestExplicitDetachDoesNotPark(t *testing.T) {
300357 pty, release := newBlockingPTY(t)
301358 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) {
632632 markSnapshotDirty(sess)
633633
634634 d.clearDestroyedTabPreview(tb)
635+ tb.mu.Lock()
636+ panes := tb.panesSnapshot()
637+ tb.mu.Unlock()
638+ if ac != nil {
639+ ac.pruneCaptureFrames(panes...)
640+ }
635641 if tb.cancel != nil {
636642 tb.cancel()
637643 }
638644 d.teardownFloating(tb, ac)
639645 if rc := sess.renderCoordinator(); rc != nil {
640- tb.mu.Lock()
641- panes := tb.panesSnapshot()
642- tb.mu.Unlock()
643646 for _, p := range panes {
644647 rc.noteSyncPaneRemoved(p)
645648 }
@@ -742,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
742745 d.unregisterPreview(ac)
743746 ac.clearPreviousSession()
744747 ac.setSession(nil)
748+ ac.clearCaptureFrames()
745749 }
746750
747751 // 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 parent751 // 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
163163 markSnapshotDirty(sess)
164164 }
165165
166-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
166+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {
167167 rc := sess.renderCoordinator()
168168 if rc == nil {
169- return
169+ return false
170170 }
171171 snap := rc.resizeSnapshot()
172172 if !rc.resizeCurrent(epoch, ac, false) {
173- return
173+ return false
174174 }
175175 d.exitCopyMode(ac)
176176 plan := d.prepareResize(sess, snap.size)
177177 if !rc.resizeCurrent(epoch, ac, false) {
178- return
178+ return false
179179 }
180180 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
181- return
181+ return false
182182 }
183183 if !rc.resizeCurrent(epoch, ac, true) {
184- return
184+ return false
185185 }
186186 d.commitResize(sess, ac, plan)
187187 failed := make([]resizeMember, 0, len(plan.members))
@@ -196,11 +196,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
196196 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
197197 // A successful transaction publishes exactly one full S2 frame. The
198198 // 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+ }
200202 // The resize debounce has already elapsed. Consume this sticky reset now;
201203 // fire preserves ACK and synchronized-output gates rather than scheduling a
202204 // second urgent deadline.
203205 rc.fireCurrent(false)
206+ return true
204207 }
205208
206209 // retryResizeMembers retries only members which failed the committed epoch.
@@ -242,9 +245,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
242245 }
243246 }
244247
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 {
246252 if sess == nil || !size.Valid() {
247- return
253+ return false
248254 }
249255 if ac == nil {
250256 // Headless geometry has no coordinator/transport to coalesce, but keeps
@@ -252,15 +258,15 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
252258 plan := d.prepareResize(sess, size)
253259 d.applyResize(&plan)
254260 d.commitResize(sess, nil, plan)
255- return
261+ return true
256262 }
257263 rc := d.attachCoordinator(sess, nil, ac, true)
258264 if immediate {
259265 epoch := rc.recordResizeRequest(size, ac)
260- if epoch != 0 {
261- d.runResizeTransaction(sess, ac, epoch)
266+ if epoch == 0 {
267+ return false
262268 }
263- return
269+ return d.runResizeTransaction(sess, ac, epoch)
264270 }
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
266272 }
@@ -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- return169+ 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- return173+ 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- return178+ 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- return181+ return false
182 }182 }
183 if !rc.resizeCurrent(epoch, ac, true) {183 if !rc.resizeCurrent(epoch, ac, true) {
184- return184+ 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. The197 // 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 a203 // 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- return253+ return false
248 }254 }
249 if ac == nil {255 if ac == nil {
250 // Headless geometry has no coordinator/transport to coalesce, but keeps256 // 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- return261+ 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- return269+ 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 {
9292 defaultColorsKnown bool
9393 terminalTitle string
9494
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
101102
102103 scrollTop int
103104 scrollBottom int
@@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage }
191192 func (s *Screen) ClearDamage() {
192193 s.damage = s.damage[:0]
193194 s.damageSaturated = false
195+ s.damageFullRedrawSticky = false
194196 }
195197
196198 // CaptureDamage snapshots pending damage without consuming it.
@@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
652654
653655 func (s *Screen) record(d renderer.Damage) {
654656 s.damageGeneration++
655- if s.damageSaturated {
657+ if s.damageSaturated || s.damageFullRedrawSticky {
656658 return
657659 }
658660 if len(s.damage) >= maxPendingDamage {
@@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) {
679681 func (s *Screen) fullRedraw() {
680682 s.damageGeneration++
681683 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
682688 }
683689
684690 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
@@ -92,12 +92,13 @@ type Screen struct {
92 defaultColorsKnown bool92 defaultColorsKnown bool
93 terminalTitle string93 terminalTitle string
94 94
95- damage []renderer.Damage95+ damage []renderer.Damage
96- damageGeneration uint6496+ damageGeneration uint64
97- damageSaturated bool97+ damageSaturated bool
98- escapeBuf []byte98+ damageFullRedrawSticky bool
99- csiScratch []int99+ escapeBuf []byte
100- sgrScratch []int100+ csiScratch []int
101+ sgrScratch []int
101 102
102 scrollTop int103 scrollTop int
103 scrollBottom int104 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 = false194 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 return658 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close