fix(daemon): commit render damage after emission
- commit
- 5e5cdfbc2b3c68194b611596768d344e5f31ebf6
- author
- brice · · 2026-07-12 07:00 UTC
- parent
- cbdec07
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 54 | 54 | theme themeui.Theme | |
| 55 | 55 | clientTheme themeui.Theme | |
| 56 | 56 | lastCursor cursorOut | |
| 57 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 57 | 58 | // previousSession is guarded independently. It is retained through temporary | |
| 58 | 59 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 59 | 60 | previousSession Guarded[*session] | |
| @@ -54,6 +54,7 @@ type attachedClient struct { | |||||
| 54 | theme themeui.Theme | 54 | theme themeui.Theme | ||
| 55 | clientTheme themeui.Theme | 55 | clientTheme themeui.Theme | ||
| 56 | lastCursor cursorOut | 56 | lastCursor cursorOut | ||
| 57 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |||
| 57 | // previousSession is guarded independently. It is retained through temporary | 58 | // 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 * | |||
| 35 | 35 | previousCapture = cache.floatingCaptured | |
| 36 | 36 | } | |
| 37 | 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 | 41 | if cache != nil { | |
| 39 | 42 | cache.floatingCaptured = captured | |
| 40 | 43 | } | |
| @@ -35,6 +35,9 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 35 | previousCapture = cache.floatingCaptured | 35 | 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 = captured | 42 | 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 | |||
| 342 | 342 | // The fixture uses the production coordinator. stubClock's inert timer is | |
| 343 | 343 | // completed synchronously by the coordinator, keeping benchmark operations | |
| 344 | 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 | 350 | d.attachCoordinator(sess, nil, ac, true) | |
| 346 | 351 | ||
| 347 | 352 | // Prime the real renderer shadow before measurements. Subsequent paints use | |
| @@ -408,11 +413,6 @@ func (f *performanceFixture) paintLive() { | |||
| 408 | 413 | f.d.paint(f.sess, f.ac, false) | |
| 409 | 414 | } | |
| 410 | 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 | 418 | 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 is | 342 | // The fixture uses the production coordinator. stubClock's inert timer is | ||
| 343 | // completed synchronously by the coordinator, keeping benchmark operations | 343 | // 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 use | 352 | // 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 | |||
| 321 | 321 | ac.sendMu.Unlock() | |
| 322 | 322 | return | |
| 323 | 323 | } | |
| 324 | + | if ac.renderStages.capture != nil { | |
| 325 | + | ac.renderStages.capture() | |
| 326 | + | } | |
| 324 | 327 | if overlays.pickerActive && overlays.previewTab == tb { | |
| 325 | 328 | previewState := *state | |
| 326 | 329 | previewState.overlays = capturedOverlayRenderState{} | |
| @@ -329,6 +332,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 329 | 332 | } | |
| 330 | 333 | captureOverlayLayers(state, overlays, paletteCfg) | |
| 331 | 334 | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 335 | + | if ac.renderStages.compose != nil { | |
| 336 | + | ac.renderStages.compose() | |
| 337 | + | } | |
| 332 | 338 | d.emitFrame(sess, ac, state, composed) | |
| 333 | 339 | } | |
| 334 | 340 | ||
| @@ -321,6 +321,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 321 | ac.sendMu.Unlock() | 321 | ac.sendMu.Unlock() | ||
| 322 | return | 322 | 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 := *state | 328 | 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 { | |||
| 36 | 36 | titleIDs []layout.PaneID | |
| 37 | 37 | treeNodes []layout.Node | |
| 38 | 38 | treeUsed int | |
| 39 | + | receipts []damageReceipt | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type damageReceipt struct { | |
| 43 | + | pane *pane | |
| 44 | + | generation uint64 | |
| 39 | 45 | } | |
| 40 | 46 | ||
| 41 | 47 | type capturedRenderState struct { | |
| @@ -52,6 +58,7 @@ type capturedRenderState struct { | |||
| 52 | 58 | cursor capturedCursorInputs | |
| 53 | 59 | tabGeneration uint64 | |
| 54 | 60 | floatingGeneration uint64 | |
| 61 | + | receipts []damageReceipt // private capture receipts; compose must not inspect these | |
| 55 | 62 | } | |
| 56 | 63 | ||
| 57 | 64 | type capturedTabLayout struct { | |
| @@ -116,7 +123,7 @@ func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptu | |||
| 116 | 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 | 127 | out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation | |
| 121 | 128 | damage := p.screen.CaptureDamage() | |
| 122 | 129 | out.damageGeneration = damage.Generation | |
| @@ -142,11 +149,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC | |||
| 142 | 149 | } else { | |
| 143 | 150 | out.damage = append(out.damage[:0], damage.Damage...) | |
| 144 | 151 | } | |
| 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. | |
| 150 | 154 | return out | |
| 151 | 155 | } | |
| 152 | 156 | ||
| @@ -213,6 +217,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 213 | 217 | overlays: overlays, preview: preview, | |
| 214 | 218 | layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 215 | 219 | floatingGeneration: tb.floating.generation, | |
| 220 | + | receipts: scratch.receipts[:0], | |
| 216 | 221 | } | |
| 217 | 222 | state.panes = scratch.panes[:0] | |
| 218 | 223 | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | |
| @@ -230,6 +235,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 230 | 235 | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |
| 231 | 236 | } | |
| 232 | 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 | 241 | captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus | |
| 234 | 242 | if captured.focused { | |
| 235 | 243 | state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) | |
| @@ -252,11 +260,24 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 252 | 260 | ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState) | |
| 253 | 261 | } | |
| 254 | 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 | 275 | ac.captureFrames[captured.id] = captured | |
| 256 | 276 | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 257 | 277 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 258 | 278 | p.mu.Unlock() | |
| 259 | 279 | } | |
| 280 | + | scratch.receipts = state.receipts | |
| 260 | 281 | return state, true | |
| 261 | 282 | } | |
| 262 | 283 | ||
| @@ -36,6 +36,12 @@ type renderCaptureScratch struct { | |||||
| 36 | titleIDs []layout.PaneID | 36 | titleIDs []layout.PaneID | ||
| 37 | treeNodes []layout.Node | 37 | treeNodes []layout.Node | ||
| 38 | treeUsed int | 38 | 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 capturedCursorInputs | 58 | cursor capturedCursorInputs | ||
| 53 | tabGeneration uint64 | 59 | tabGeneration uint64 | ||
| 54 | floatingGeneration uint64 | 60 | 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.generation | 127 | 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.Generation | 129 | 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 out | 154 | 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.focus | 241 | 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] = captured | 275 | 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, true | 281 | 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 | |||
| 208 | 208 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |
| 209 | 209 | if consumeDamage { | |
| 210 | 210 | p.mu.Lock() | |
| 211 | - | _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode) | |
| 211 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode) | |
| 212 | + | p.screen.AcknowledgeDamage(captured.damageGeneration) | |
| 212 | 213 | p.mu.Unlock() | |
| 213 | 214 | } | |
| 214 | 215 | continue | |
| @@ -218,6 +219,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||
| 218 | 219 | if consumeDamage { | |
| 219 | 220 | p.mu.Lock() | |
| 220 | 221 | captured := capturePaneRenderStateLocked(p, pl.Content, mode) | |
| 222 | + | p.screen.AcknowledgeDamage(captured.damageGeneration) | |
| 221 | 223 | p.mu.Unlock() | |
| 222 | 224 | paneFrame, paneDamage = captured.frame, captured.damage | |
| 223 | 225 | } 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 | continue | 215 | 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.damage | 224 | paneFrame, paneDamage = captured.frame, captured.damage | ||
| 223 | } else { | 225 | } else { | ||
M internal/usecase/daemon/render_pipeline.go +38 -1
| @@ -16,6 +16,17 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | |
| 18 | 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 | 30 | type composeCacheInput struct { | |
| 20 | 31 | valid bool | |
| 21 | 32 | frame renderer.Frame | |
| @@ -255,6 +266,22 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||
| 255 | 266 | 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 | 285 | // emitFrame is the sole side-effecting half of the pipeline. The caller holds | |
| 259 | 286 | // sendMu for the complete capture/compose/emit transaction. | |
| 260 | 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 | 330 | if sendErr == nil { | |
| 304 | 331 | // 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 | 334 | ac.pipelineScratch = ac.pipelineCache | |
| 307 | 335 | ac.pipelineCache = composed.cache | |
| 308 | 336 | } | |
| 309 | 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 | 347 | ac.sendMu.Unlock() | |
| 311 | 348 | if sendErr != nil { | |
| 312 | 349 | d.detachOnSendError(sess, ac, sendTr) | |
| @@ -16,6 +16,17 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | 17 | // 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 bool | 31 | valid bool | ||
| 21 | frame renderer.Frame | 32 | frame renderer.Frame | ||
| @@ -255,6 +266,22 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||||
| 255 | return b | 266 | 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 holds | 285 | // 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 both | 331 | // 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.pipelineCache | 334 | ac.pipelineScratch = ac.pipelineCache | ||
| 307 | ac.pipelineCache = composed.cache | 335 | 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) { | |||
| 60 | 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 | 64 | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |
| 65 | 65 | p.screen.ClearDamage() | |
| 66 | 66 | p.screen.Write([]byte("old")) | |
| @@ -71,7 +71,7 @@ func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) { | |||
| 71 | 71 | ||
| 72 | 72 | require.Equal(t, 3, captured.frame.Width) | |
| 73 | 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 | 75 | p.mu.Lock() | |
| 76 | 76 | p.screen.Write([]byte("new")) | |
| 77 | 77 | p.mu.Unlock() | |
| @@ -114,7 +114,7 @@ func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) { | |||
| 114 | 114 | ||
| 115 | 115 | require.Zero(t, captured.frame.Width) | |
| 116 | 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 | 120 | 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) { | ||