detached 5e5cdfb

fix(daemon): commit render damage after emission

commit
5e5cdfbc2b3c68194b611596768d344e5f31ebf6
author
brice · · 2026-07-12 07:00 UTC
parent
cbdec07
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +188 -16
M internal/usecase/daemon/client.go +1 -0
@@ -54,6 +54,7 @@ type attachedClient struct {
5454 theme themeui.Theme
5555 clientTheme themeui.Theme
5656 lastCursor cursorOut
57+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
5758 // previousSession is guarded independently. It is retained through temporary
5859 // setSession(nil) hand-offs and cleared only on terminal teardown.
5960 previousSession Guarded[*session]
@@ -54,6 +54,7 @@ type attachedClient struct {
54 theme themeui.Theme54 theme themeui.Theme
55 clientTheme themeui.Theme55 clientTheme themeui.Theme
56 lastCursor cursorOut56 lastCursor cursorOut
57+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
57 // previousSession is guarded independently. It is retained through temporary58 // previousSession is guarded independently. It is retained through temporary
58 // setSession(nil) hand-offs and cleared only on terminal teardown.59 // setSession(nil) hand-offs and cleared only on terminal teardown.
59 previousSession Guarded[*session]60 previousSession Guarded[*session]
M internal/usecase/daemon/floating_render_legacy_test.go +3 -0
@@ -35,6 +35,9 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
3535 previousCapture = cache.floatingCaptured
3636 }
3737 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
38+ // This retained legacy test helper models its historical synchronous
39+ // consume path; production commits receipts in emitFrame instead.
40+ p.screen.AcknowledgeDamage(captured.damageGeneration)
3841 if cache != nil {
3942 cache.floatingCaptured = captured
4043 }
@@ -35,6 +35,9 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
35 previousCapture = cache.floatingCaptured35 previousCapture = cache.floatingCaptured
36 }36 }
37 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)37 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
38+ // This retained legacy test helper models its historical synchronous
39+ // consume path; production commits receipts in emitFrame instead.
40+ p.screen.AcknowledgeDamage(captured.damageGeneration)
38 if cache != nil {41 if cache != nil {
39 cache.floatingCaptured = captured42 cache.floatingCaptured = captured
40 }43 }
M internal/usecase/daemon/performance_benchmark_test.go +5 -5
@@ -342,6 +342,11 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
342342 // The fixture uses the production coordinator. stubClock's inert timer is
343343 // completed synchronously by the coordinator, keeping benchmark operations
344344 // deterministic while retaining their real invalidation path.
345+ ac.renderStages = renderStageHooks{
346+ capture: func() { fixture.renderCaptures++ },
347+ compose: func() { fixture.renderCompositions++ },
348+ emit: func() { fixture.renderEmissions++ },
349+ }
345350 d.attachCoordinator(sess, nil, ac, true)
346351
347352 // Prime the real renderer shadow before measurements. Subsequent paints use
@@ -408,11 +413,6 @@ func (f *performanceFixture) paintLive() {
408413 f.d.paint(f.sess, f.ac, false)
409414 }
410415 f.paints++
411- // The production coordinator is synchronous under the fixture's inert
412- // clock, so this request completes all three single-owner pipeline stages.
413- f.renderCaptures++
414- f.renderCompositions++
415- f.renderEmissions++
416416 }
417417
418418 func (f *performanceFixture) captureSnapshot() {
@@ -342,6 +342,11 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
342 // The fixture uses the production coordinator. stubClock's inert timer is342 // The fixture uses the production coordinator. stubClock's inert timer is
343 // completed synchronously by the coordinator, keeping benchmark operations343 // completed synchronously by the coordinator, keeping benchmark operations
344 // deterministic while retaining their real invalidation path.344 // deterministic while retaining their real invalidation path.
345+ ac.renderStages = renderStageHooks{
346+ capture: func() { fixture.renderCaptures++ },
347+ compose: func() { fixture.renderCompositions++ },
348+ emit: func() { fixture.renderEmissions++ },
349+ }
345 d.attachCoordinator(sess, nil, ac, true)350 d.attachCoordinator(sess, nil, ac, true)
346 351
347 // Prime the real renderer shadow before measurements. Subsequent paints use352 // Prime the real renderer shadow before measurements. Subsequent paints use
@@ -408,11 +413,6 @@ func (f *performanceFixture) paintLive() {
408 f.d.paint(f.sess, f.ac, false)413 f.d.paint(f.sess, f.ac, false)
409 }414 }
410 f.paints++415 f.paints++
411- // The production coordinator is synchronous under the fixture's inert
412- // clock, so this request completes all three single-owner pipeline stages.
413- f.renderCaptures++
414- f.renderCompositions++
415- f.renderEmissions++
416 }416 }
417 417
418 func (f *performanceFixture) captureSnapshot() {418 func (f *performanceFixture) captureSnapshot() {
M internal/usecase/daemon/render.go +6 -0
@@ -321,6 +321,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
321321 ac.sendMu.Unlock()
322322 return
323323 }
324+ if ac.renderStages.capture != nil {
325+ ac.renderStages.capture()
326+ }
324327 if overlays.pickerActive && overlays.previewTab == tb {
325328 previewState := *state
326329 previewState.overlays = capturedOverlayRenderState{}
@@ -329,6 +332,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
329332 }
330333 captureOverlayLayers(state, overlays, paletteCfg)
331334 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
335+ if ac.renderStages.compose != nil {
336+ ac.renderStages.compose()
337+ }
332338 d.emitFrame(sess, ac, state, composed)
333339 }
334340
@@ -321,6 +321,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
321 ac.sendMu.Unlock()321 ac.sendMu.Unlock()
322 return322 return
323 }323 }
324+ if ac.renderStages.capture != nil {
325+ ac.renderStages.capture()
326+ }
324 if overlays.pickerActive && overlays.previewTab == tb {327 if overlays.pickerActive && overlays.previewTab == tb {
325 previewState := *state328 previewState := *state
326 previewState.overlays = capturedOverlayRenderState{}329 previewState.overlays = capturedOverlayRenderState{}
@@ -329,6 +332,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
329 }332 }
330 captureOverlayLayers(state, overlays, paletteCfg)333 captureOverlayLayers(state, overlays, paletteCfg)
331 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)334 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
335+ if ac.renderStages.compose != nil {
336+ ac.renderStages.compose()
337+ }
332 d.emitFrame(sess, ac, state, composed)338 d.emitFrame(sess, ac, state, composed)
333 }339 }
334 340
M internal/usecase/daemon/render_capture.go +27 -6
@@ -36,6 +36,12 @@ type renderCaptureScratch struct {
3636 titleIDs []layout.PaneID
3737 treeNodes []layout.Node
3838 treeUsed int
39+ receipts []damageReceipt
40+}
41+
42+type damageReceipt struct {
43+ pane *pane
44+ generation uint64
3945 }
4046
4147 type capturedRenderState struct {
@@ -52,6 +58,7 @@ type capturedRenderState struct {
5258 cursor capturedCursorInputs
5359 tabGeneration uint64
5460 floatingGeneration uint64
61+ receipts []damageReceipt // private capture receipts; compose must not inspect these
5562 }
5663
5764 type capturedTabLayout struct {
@@ -116,7 +123,7 @@ func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptu
116123 return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
117124 }
118125
119-func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
126+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
120127 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
121128 damage := p.screen.CaptureDamage()
122129 out.damageGeneration = damage.Generation
@@ -142,11 +149,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC
142149 } else {
143150 out.damage = append(out.damage[:0], damage.Damage...)
144151 }
145- if mode == damageCaptureConsume {
146- if !p.screen.AcknowledgeDamage(damage.Generation) {
147- out.damage = []renderer.Damage{renderer.FullRedraw()}
148- }
149- }
152+ // Consumption is transactional: capture only snapshots damage. emitFrame
153+ // acknowledges this generation after preparation and transport success.
150154 return out
151155 }
152156
@@ -213,6 +217,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
213217 overlays: overlays, preview: preview,
214218 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
215219 floatingGeneration: tb.floating.generation,
220+ receipts: scratch.receipts[:0],
216221 }
217222 state.panes = scratch.panes[:0]
218223 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
@@ -230,6 +235,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
230235 ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
231236 }
232237 captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
238+ if mode == damageCaptureConsume {
239+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240+ }
233241 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
234242 if captured.focused {
235243 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
@@ -252,11 +260,24 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
252260 ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
253261 }
254262 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
263+ if mode == damageCaptureConsume {
264+ seen := false
265+ for _, receipt := range state.receipts {
266+ if receipt.pane == p {
267+ seen = true
268+ break
269+ }
270+ }
271+ if !seen {
272+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273+ }
274+ }
255275 ac.captureFrames[captured.id] = captured
256276 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
257277 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
258278 p.mu.Unlock()
259279 }
280+ scratch.receipts = state.receipts
260281 return state, true
261282 }
262283
@@ -36,6 +36,12 @@ type renderCaptureScratch struct {
36 titleIDs []layout.PaneID36 titleIDs []layout.PaneID
37 treeNodes []layout.Node37 treeNodes []layout.Node
38 treeUsed int38 treeUsed int
39+ receipts []damageReceipt
40+}
41+
42+type damageReceipt struct {
43+ pane *pane
44+ generation uint64
39 }45 }
40 46
41 type capturedRenderState struct {47 type capturedRenderState struct {
@@ -52,6 +58,7 @@ type capturedRenderState struct {
52 cursor capturedCursorInputs58 cursor capturedCursorInputs
53 tabGeneration uint6459 tabGeneration uint64
54 floatingGeneration uint6460 floatingGeneration uint64
61+ receipts []damageReceipt // private capture receipts; compose must not inspect these
55 }62 }
56 63
57 type capturedTabLayout struct {64 type capturedTabLayout struct {
@@ -116,7 +123,7 @@ func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptu
116 return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})123 return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
117 }124 }
118 125
119-func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {126+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
120 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation127 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
121 damage := p.screen.CaptureDamage()128 damage := p.screen.CaptureDamage()
122 out.damageGeneration = damage.Generation129 out.damageGeneration = damage.Generation
@@ -142,11 +149,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC
142 } else {149 } else {
143 out.damage = append(out.damage[:0], damage.Damage...)150 out.damage = append(out.damage[:0], damage.Damage...)
144 }151 }
145- if mode == damageCaptureConsume {152+ // Consumption is transactional: capture only snapshots damage. emitFrame
146- if !p.screen.AcknowledgeDamage(damage.Generation) {153+ // acknowledges this generation after preparation and transport success.
147- out.damage = []renderer.Damage{renderer.FullRedraw()}
148- }
149- }
150 return out154 return out
151 }155 }
152 156
@@ -213,6 +217,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
213 overlays: overlays, preview: preview,217 overlays: overlays, preview: preview,
214 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},218 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
215 floatingGeneration: tb.floating.generation,219 floatingGeneration: tb.floating.generation,
220+ receipts: scratch.receipts[:0],
216 }221 }
217 state.panes = scratch.panes[:0]222 state.panes = scratch.panes[:0]
218 state.tabGeneration = uint64(len(layoutSnap.fingerprint))223 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
@@ -230,6 +235,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
230 ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)235 ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
231 }236 }
232 captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])237 captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
238+ if mode == damageCaptureConsume {
239+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240+ }
233 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus241 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
234 if captured.focused {242 if captured.focused {
235 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)243 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
@@ -252,11 +260,24 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
252 ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)260 ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
253 }261 }
254 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])262 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
263+ if mode == damageCaptureConsume {
264+ seen := false
265+ for _, receipt := range state.receipts {
266+ if receipt.pane == p {
267+ seen = true
268+ break
269+ }
270+ }
271+ if !seen {
272+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273+ }
274+ }
255 ac.captureFrames[captured.id] = captured275 ac.captureFrames[captured.id] = captured
256 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}276 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
257 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)277 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
258 p.mu.Unlock()278 p.mu.Unlock()
259 }279 }
280+ scratch.receipts = state.receipts
260 return state, true281 return state, true
261 }282 }
262 283
A internal/usecase/daemon/render_damage_receipt_test.go +102 -0
@@ -0,0 +1,102 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// These are deliberately end-to-end pipeline tests: the pending damage comes
14+// from a real pane VT Write, not a hand-built FullRedraw.
15+func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
16+ for _, tt := range []struct {
17+ name string
18+ fail func(*composedRenderFrame, *attachedClient)
19+ restore func(*session, *attachedClient, ports.Transport)
20+ }{
21+ {
22+ name: "prepare failure",
23+ fail: func(c *composedRenderFrame, _ *attachedClient) {
24+ // An invalid frame makes the real output.prepare reject after capture.
25+ c.frame = renderer.Frame{Width: 1}
26+ },
27+ restore: func(_ *session, _ *attachedClient, _ ports.Transport) {},
28+ },
29+ {
30+ name: "send failure",
31+ fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
32+ restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
33+ sess.mu.Lock()
34+ sess.client = ac
35+ sess.mu.Unlock()
36+ ac.setSession(sess)
37+ ac.replaceTransport(healthy)
38+ },
39+ },
40+ } {
41+ t.Run(tt.name, func(t *testing.T) {
42+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
43+ healthy := ac.transport()
44+ p := sess.tabs[0].focusedPane()
45+
46+ // Prime both the renderer shadow and the committed capture cache.
47+ d.paint(sess, ac, true)
48+ <-sends
49+ p.mu.Lock()
50+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
51+ p.mu.Unlock()
52+
53+ state, composed := captureComposeForReceiptTest(t, sess, ac)
54+ tt.fail(&composed, ac)
55+ require.True(t, d.emitFrame(sess, ac, state, composed))
56+ p.mu.Lock()
57+ require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
58+ p.mu.Unlock()
59+
60+ tt.restore(sess, ac, healthy)
61+ state, composed = captureComposeForReceiptTest(t, sess, ac)
62+ require.True(t, d.emitFrame(sess, ac, state, composed))
63+ frame := <-sends
64+ out, err := ports.UnmarshalOutput(frame.Payload)
65+ require.NoError(t, err)
66+ require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
67+ p.mu.Lock()
68+ require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
69+ require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
70+ p.mu.Unlock()
71+ })
72+ }
73+}
74+
75+func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77+ d.paint(sess, ac, true)
78+ <-sends
79+ p := sess.tabs[0].focusedPane()
80+ p.mu.Lock()
81+ p.screen.Write([]byte("\x1b[1;1Hfirst"))
82+ p.mu.Unlock()
83+ state, composed := captureComposeForReceiptTest(t, sess, ac)
84+ p.mu.Lock()
85+ p.screen.Write([]byte("\x1b[2;1Hsecond"))
86+ p.mu.Unlock()
87+ require.True(t, d.emitFrame(sess, ac, state, composed))
88+ <-sends
89+ p.mu.Lock()
90+ defer p.mu.Unlock()
91+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
92+}
93+
94+func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
95+ t.Helper()
96+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
97+ state, ok := captureRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, damageCaptureConsume)
98+ require.True(t, ok)
99+ return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100+}
101+
102+func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
@@ -0,0 +1,102 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// These are deliberately end-to-end pipeline tests: the pending damage comes
14+// from a real pane VT Write, not a hand-built FullRedraw.
15+func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
16+ for _, tt := range []struct {
17+ name string
18+ fail func(*composedRenderFrame, *attachedClient)
19+ restore func(*session, *attachedClient, ports.Transport)
20+ }{
21+ {
22+ name: "prepare failure",
23+ fail: func(c *composedRenderFrame, _ *attachedClient) {
24+ // An invalid frame makes the real output.prepare reject after capture.
25+ c.frame = renderer.Frame{Width: 1}
26+ },
27+ restore: func(_ *session, _ *attachedClient, _ ports.Transport) {},
28+ },
29+ {
30+ name: "send failure",
31+ fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
32+ restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
33+ sess.mu.Lock()
34+ sess.client = ac
35+ sess.mu.Unlock()
36+ ac.setSession(sess)
37+ ac.replaceTransport(healthy)
38+ },
39+ },
40+ } {
41+ t.Run(tt.name, func(t *testing.T) {
42+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
43+ healthy := ac.transport()
44+ p := sess.tabs[0].focusedPane()
45+
46+ // Prime both the renderer shadow and the committed capture cache.
47+ d.paint(sess, ac, true)
48+ <-sends
49+ p.mu.Lock()
50+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
51+ p.mu.Unlock()
52+
53+ state, composed := captureComposeForReceiptTest(t, sess, ac)
54+ tt.fail(&composed, ac)
55+ require.True(t, d.emitFrame(sess, ac, state, composed))
56+ p.mu.Lock()
57+ require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
58+ p.mu.Unlock()
59+
60+ tt.restore(sess, ac, healthy)
61+ state, composed = captureComposeForReceiptTest(t, sess, ac)
62+ require.True(t, d.emitFrame(sess, ac, state, composed))
63+ frame := <-sends
64+ out, err := ports.UnmarshalOutput(frame.Payload)
65+ require.NoError(t, err)
66+ require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
67+ p.mu.Lock()
68+ require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
69+ require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
70+ p.mu.Unlock()
71+ })
72+ }
73+}
74+
75+func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77+ d.paint(sess, ac, true)
78+ <-sends
79+ p := sess.tabs[0].focusedPane()
80+ p.mu.Lock()
81+ p.screen.Write([]byte("\x1b[1;1Hfirst"))
82+ p.mu.Unlock()
83+ state, composed := captureComposeForReceiptTest(t, sess, ac)
84+ p.mu.Lock()
85+ p.screen.Write([]byte("\x1b[2;1Hsecond"))
86+ p.mu.Unlock()
87+ require.True(t, d.emitFrame(sess, ac, state, composed))
88+ <-sends
89+ p.mu.Lock()
90+ defer p.mu.Unlock()
91+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
92+}
93+
94+func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
95+ t.Helper()
96+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
97+ state, ok := captureRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, damageCaptureConsume)
98+ require.True(t, ok)
99+ return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100+}
101+
102+func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
M internal/usecase/daemon/render_legacy_test.go +3 -1
@@ -208,7 +208,8 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
208208 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
209209 if consumeDamage {
210210 p.mu.Lock()
211- _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)
211+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode)
212+ p.screen.AcknowledgeDamage(captured.damageGeneration)
212213 p.mu.Unlock()
213214 }
214215 continue
@@ -218,6 +219,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
218219 if consumeDamage {
219220 p.mu.Lock()
220221 captured := capturePaneRenderStateLocked(p, pl.Content, mode)
222+ p.screen.AcknowledgeDamage(captured.damageGeneration)
221223 p.mu.Unlock()
222224 paneFrame, paneDamage = captured.frame, captured.damage
223225 } else {
@@ -208,7 +208,8 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
208 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {208 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
209 if consumeDamage {209 if consumeDamage {
210 p.mu.Lock()210 p.mu.Lock()
211- _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode)211+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode)
212+ p.screen.AcknowledgeDamage(captured.damageGeneration)
212 p.mu.Unlock()213 p.mu.Unlock()
213 }214 }
214 continue215 continue
@@ -218,6 +219,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
218 if consumeDamage {219 if consumeDamage {
219 p.mu.Lock()220 p.mu.Lock()
220 captured := capturePaneRenderStateLocked(p, pl.Content, mode)221 captured := capturePaneRenderStateLocked(p, pl.Content, mode)
222+ p.screen.AcknowledgeDamage(captured.damageGeneration)
221 p.mu.Unlock()223 p.mu.Unlock()
222 paneFrame, paneDamage = captured.frame, captured.damage224 paneFrame, paneDamage = captured.frame, captured.damage
223 } else {225 } else {
M internal/usecase/daemon/render_pipeline.go +38 -1
@@ -16,6 +16,17 @@ import (
1616
1717 // composeCacheInput is an attachment-local value snapshot. composeFrame never
1818 // retains or accesses the attachment that owns it.
19+// renderStageHooks are optional observability hooks. They are invoked only at
20+// completed production boundaries: capture and compose after their result is
21+// available, and emit after prepare plus transport success (including a
22+// successful no-byte emission). Failed attempts count for capture/compose but
23+// never for emit.
24+type renderStageHooks struct {
25+ capture func()
26+ compose func()
27+ emit func()
28+}
29+
1930 type composeCacheInput struct {
2031 valid bool
2132 frame renderer.Frame
@@ -255,6 +266,22 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
255266 return b
256267 }
257268
269+// commitDamageReceipts acknowledges only snapshots that reached the client.
270+// sendMu serializes attachment transactions; panes are locked individually so
271+// no pane lock is held while another is acquired. A stale generation is
272+// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
273+// the next transaction.
274+func commitDamageReceipts(receipts []damageReceipt) {
275+ for _, receipt := range receipts {
276+ if receipt.pane == nil {
277+ continue
278+ }
279+ receipt.pane.mu.Lock()
280+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
281+ receipt.pane.mu.Unlock()
282+ }
283+}
284+
258285 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
259286 // sendMu for the complete capture/compose/emit transaction.
260287 func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool {
@@ -302,11 +329,21 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
302329 }
303330 if sendErr == nil {
304331 // Publish only after output preparation and transport emission both
305- // succeed. Swap the old committed cache back as the next compose target.
332+ // succeed. Receipt acknowledgement follows after releasing the attachment
333+ // session guard, while sendMu still owns this transaction.
306334 ac.pipelineScratch = ac.pipelineCache
307335 ac.pipelineCache = composed.cache
308336 }
309337 ac.sess.mu.Unlock()
338+ if sendErr == nil {
339+ // A successful no-byte emission also commits: its renderer shadow still
340+ // represents the captured frame. Lock panes only under sendMu and with no
341+ // session guard held.
342+ commitDamageReceipts(state.receipts)
343+ if ac.renderStages.emit != nil {
344+ ac.renderStages.emit()
345+ }
346+ }
310347 ac.sendMu.Unlock()
311348 if sendErr != nil {
312349 d.detachOnSendError(sess, ac, sendTr)
@@ -16,6 +16,17 @@ import (
16 16
17 // composeCacheInput is an attachment-local value snapshot. composeFrame never17 // composeCacheInput is an attachment-local value snapshot. composeFrame never
18 // retains or accesses the attachment that owns it.18 // retains or accesses the attachment that owns it.
19+// renderStageHooks are optional observability hooks. They are invoked only at
20+// completed production boundaries: capture and compose after their result is
21+// available, and emit after prepare plus transport success (including a
22+// successful no-byte emission). Failed attempts count for capture/compose but
23+// never for emit.
24+type renderStageHooks struct {
25+ capture func()
26+ compose func()
27+ emit func()
28+}
29+
19 type composeCacheInput struct {30 type composeCacheInput struct {
20 valid bool31 valid bool
21 frame renderer.Frame32 frame renderer.Frame
@@ -255,6 +266,22 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
255 return b266 return b
256 }267 }
257 268
269+// commitDamageReceipts acknowledges only snapshots that reached the client.
270+// sendMu serializes attachment transactions; panes are locked individually so
271+// no pane lock is held while another is acquired. A stale generation is
272+// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
273+// the next transaction.
274+func commitDamageReceipts(receipts []damageReceipt) {
275+ for _, receipt := range receipts {
276+ if receipt.pane == nil {
277+ continue
278+ }
279+ receipt.pane.mu.Lock()
280+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
281+ receipt.pane.mu.Unlock()
282+ }
283+}
284+
258 // emitFrame is the sole side-effecting half of the pipeline. The caller holds285 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
259 // sendMu for the complete capture/compose/emit transaction.286 // sendMu for the complete capture/compose/emit transaction.
260 func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool {287 func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool {
@@ -302,11 +329,21 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
302 }329 }
303 if sendErr == nil {330 if sendErr == nil {
304 // Publish only after output preparation and transport emission both331 // Publish only after output preparation and transport emission both
305- // succeed. Swap the old committed cache back as the next compose target.332+ // succeed. Receipt acknowledgement follows after releasing the attachment
333+ // session guard, while sendMu still owns this transaction.
306 ac.pipelineScratch = ac.pipelineCache334 ac.pipelineScratch = ac.pipelineCache
307 ac.pipelineCache = composed.cache335 ac.pipelineCache = composed.cache
308 }336 }
309 ac.sess.mu.Unlock()337 ac.sess.mu.Unlock()
338+ if sendErr == nil {
339+ // A successful no-byte emission also commits: its renderer shadow still
340+ // represents the captured frame. Lock panes only under sendMu and with no
341+ // session guard held.
342+ commitDamageReceipts(state.receipts)
343+ if ac.renderStages.emit != nil {
344+ ac.renderStages.emit()
345+ }
346+ }
310 ac.sendMu.Unlock()347 ac.sendMu.Unlock()
311 if sendErr != nil {348 if sendErr != nil {
312 d.detachOnSendError(sess, ac, sendTr)349 d.detachOnSendError(sess, ac, sendTr)
M internal/usecase/daemon/render_pipeline_test.go +3 -3
@@ -60,7 +60,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
6060 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
6161 }
6262
63-func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) {
63+func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
6464 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
6565 p.screen.ClearDamage()
6666 p.screen.Write([]byte("old"))
@@ -71,7 +71,7 @@ func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) {
7171
7272 require.Equal(t, 3, captured.frame.Width)
7373 require.Equal(t, 1, captured.frame.Height)
74- require.Empty(t, p.screen.Damage())
74+ require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
7575 p.mu.Lock()
7676 p.screen.Write([]byte("new"))
7777 p.mu.Unlock()
@@ -114,7 +114,7 @@ func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
114114
115115 require.Zero(t, captured.frame.Width)
116116 require.Zero(t, captured.frame.Height)
117- require.Empty(t, p.screen.Damage(), "capture owner cleans pending hidden-pane damage")
117+ require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
118118 }
119119
120120 func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
@@ -60,7 +60,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
60 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")60 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
61 }61 }
62 62
63-func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) {63+func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
64 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})64 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
65 p.screen.ClearDamage()65 p.screen.ClearDamage()
66 p.screen.Write([]byte("old"))66 p.screen.Write([]byte("old"))
@@ -71,7 +71,7 @@ func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) {
71 71
72 require.Equal(t, 3, captured.frame.Width)72 require.Equal(t, 3, captured.frame.Width)
73 require.Equal(t, 1, captured.frame.Height)73 require.Equal(t, 1, captured.frame.Height)
74- require.Empty(t, p.screen.Damage())74+ require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
75 p.mu.Lock()75 p.mu.Lock()
76 p.screen.Write([]byte("new"))76 p.screen.Write([]byte("new"))
77 p.mu.Unlock()77 p.mu.Unlock()
@@ -114,7 +114,7 @@ func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
114 114
115 require.Zero(t, captured.frame.Width)115 require.Zero(t, captured.frame.Width)
116 require.Zero(t, captured.frame.Height)116 require.Zero(t, captured.frame.Height)
117- require.Empty(t, p.screen.Damage(), "capture owner cleans pending hidden-pane damage")117+ require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
118 }118 }
119 119
120 func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {120 func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {

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