fix(daemon): isolate committed render cache
- commit
- cbdec07343f8cd623f73ab7f46c42b5f11585653
- author
- brice · · 2026-07-12 06:49 UTC
- parent
- bac7c66
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +132 -13
M internal/usecase/daemon/client.go +5 -1
| @@ -36,7 +36,11 @@ type attachedClient struct { | |||
| 36 | 36 | // exact coordinator incarnation. paint rechecks it under sendMu. | |
| 37 | 37 | coordinatorReadyEpoch atomic.Uint64 | |
| 38 | 38 | echoAck atomic.Uint64 | |
| 39 | - | pipelineCache composeCacheInput // only touched while sendMu is held | |
| 39 | + | // pipelineCache is the last successfully emitted composition. pipelineScratch | |
| 40 | + | // is its attachment-owned alternate buffer; both are only touched under | |
| 41 | + | // sendMu and must never share mutable backing storage. | |
| 42 | + | pipelineCache composeCacheInput | |
| 43 | + | pipelineScratch composeCacheInput | |
| 40 | 44 | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 41 | 45 | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | |
| 42 | 46 | resizePaint pendingByteTimer // guarded by sendMu | |
| @@ -36,7 +36,11 @@ type attachedClient struct { | |||||
| 36 | // exact coordinator incarnation. paint rechecks it under sendMu. | 36 | // exact coordinator incarnation. paint rechecks it under sendMu. | ||
| 37 | coordinatorReadyEpoch atomic.Uint64 | 37 | coordinatorReadyEpoch atomic.Uint64 | ||
| 38 | echoAck atomic.Uint64 | 38 | echoAck atomic.Uint64 | ||
| 39 | - | pipelineCache composeCacheInput // only touched while sendMu is held | 39 | + | // pipelineCache is the last successfully emitted composition. pipelineScratch |
| 40 | + | // is its attachment-owned alternate buffer; both are only touched under | |||
| 41 | + | // sendMu and must never share mutable backing storage. | |||
| 42 | + | pipelineCache composeCacheInput | |||
| 43 | + | pipelineScratch composeCacheInput | |||
| 40 | renderScratch renderCaptureScratch // only touched while sendMu is held | 44 | renderScratch renderCaptureScratch // only touched while sendMu is held | ||
| 41 | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | 45 | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | ||
| 42 | resizePaint pendingByteTimer // guarded by sendMu | 46 | resizePaint pendingByteTimer // guarded by sendMu | ||
M internal/usecase/daemon/picker.go +1 -1
| @@ -540,7 +540,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview { | |||
| 540 | 540 | } | |
| 541 | 541 | ||
| 542 | 542 | func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { | |
| 543 | - | composed := composeFrame(state, composeCacheInput{}).frame | |
| 543 | + | composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame | |
| 544 | 544 | if composed.Height < 2 { | |
| 545 | 545 | return pickerPreviewFromFrame(composed) | |
| 546 | 546 | } | |
| @@ -540,7 +540,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview { | |||||
| 540 | } | 540 | } | ||
| 541 | 541 | ||||
| 542 | func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { | 542 | func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { | ||
| 543 | - | composed := composeFrame(state, composeCacheInput{}).frame | 543 | + | composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame |
| 544 | if composed.Height < 2 { | 544 | if composed.Height < 2 { | ||
| 545 | return pickerPreviewFromFrame(composed) | 545 | return pickerPreviewFromFrame(composed) | ||
| 546 | } | 546 | } | ||
M internal/usecase/daemon/render.go +1 -1
| @@ -328,7 +328,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 328 | 328 | state.preview = pickerPreviewFromCapturedRender(previewState) | |
| 329 | 329 | } | |
| 330 | 330 | captureOverlayLayers(state, overlays, paletteCfg) | |
| 331 | - | composed := composeFrame(*state, ac.pipelineCache) | |
| 331 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 332 | 332 | d.emitFrame(sess, ac, state, composed) | |
| 333 | 333 | } | |
| 334 | 334 | ||
| @@ -328,7 +328,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 328 | state.preview = pickerPreviewFromCapturedRender(previewState) | 328 | state.preview = pickerPreviewFromCapturedRender(previewState) | ||
| 329 | } | 329 | } | ||
| 330 | captureOverlayLayers(state, overlays, paletteCfg) | 330 | captureOverlayLayers(state, overlays, paletteCfg) | ||
| 331 | - | composed := composeFrame(*state, ac.pipelineCache) | 331 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) |
| 332 | d.emitFrame(sess, ac, state, composed) | 332 | d.emitFrame(sess, ac, state, composed) | ||
| 333 | } | 333 | } | ||
| 334 | 334 | ||||
M internal/usecase/daemon/render_pipeline.go +22 -10
| @@ -38,20 +38,29 @@ type composedRenderFrame struct { | |||
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | 40 | // composeFrame is pure with respect to daemon ownership: it consumes only the | |
| 41 | - | // capture and an attachment-local cache value, and returns a replacement cache. | |
| 42 | - | func composeFrame(state capturedRenderState, in composeCacheInput) composedRenderFrame { | |
| 41 | + | // capture, the last committed cache, and an independent attachment-owned | |
| 42 | + | // scratch cache. It returns a replacement cache without mutating committed. | |
| 43 | + | func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { | |
| 44 | + | scratch := composeCacheInput{} | |
| 45 | + | if len(scratchIn) > 0 { | |
| 46 | + | scratch = scratchIn[0] | |
| 47 | + | } | |
| 43 | 48 | width, rows := state.layout.area.Width, state.layout.area.Height | |
| 44 | 49 | if width <= 0 || rows < 0 { | |
| 45 | 50 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | |
| 46 | 51 | } | |
| 47 | - | // The attachment-owned cache is safe to reuse while sendMu serializes this | |
| 48 | - | // transaction. It contains only prior composed cells, never VT-owned cells. | |
| 49 | - | // Reusing it avoids cloning a client-sized frame on every incremental paint. | |
| 50 | - | canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint | |
| 51 | - | frame := in.frame | |
| 52 | + | // Compose into the alternate attachment-owned buffer. The committed frame is | |
| 53 | + | // copied before incremental drawing so prepare/send failures cannot change it. | |
| 54 | + | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2 | |
| 55 | + | frame := scratch.frame | |
| 52 | 56 | if !canReuseFrame { | |
| 53 | 57 | frame = renderer.NewFrame(width, rows+2) | |
| 54 | 58 | } | |
| 59 | + | if in.frame.Width == width && in.frame.Height == rows+2 { | |
| 60 | + | for y := 0; y < frame.Height; y++ { | |
| 61 | + | copy(frame.Row(y), in.frame.Row(y)) | |
| 62 | + | } | |
| 63 | + | } | |
| 55 | 64 | styles := newThemeStyles(state.theme) | |
| 56 | 65 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 57 | 66 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| @@ -61,11 +70,11 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||
| 61 | 70 | } | |
| 62 | 71 | ||
| 63 | 72 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme | |
| 64 | - | titles := in.titleGenerations | |
| 73 | + | titles := scratch.titleGenerations | |
| 65 | 74 | if titles == nil { | |
| 66 | 75 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| 67 | 76 | } | |
| 68 | - | damage := in.damage[:0] | |
| 77 | + | damage := scratch.damage[:0] | |
| 69 | 78 | for _, pane := range state.panes { | |
| 70 | 79 | pl := offsetPlacement(pane.placement, 0, 1) | |
| 71 | 80 | if pl.TitleBar.Height > 0 { | |
| @@ -114,7 +123,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||
| 114 | 123 | cursorInputs := state.cursor | |
| 115 | 124 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 116 | 125 | cursor := desiredCapturedCursor(cursorInputs) | |
| 117 | - | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: in.bars} | |
| 126 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 118 | 127 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 119 | 128 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 120 | 129 | } | |
| @@ -292,6 +301,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 292 | 301 | } | |
| 293 | 302 | } | |
| 294 | 303 | if sendErr == nil { | |
| 304 | + | // Publish only after output preparation and transport emission both | |
| 305 | + | // succeed. Swap the old committed cache back as the next compose target. | |
| 306 | + | ac.pipelineScratch = ac.pipelineCache | |
| 295 | 307 | ac.pipelineCache = composed.cache | |
| 296 | 308 | } | |
| 297 | 309 | ac.sess.mu.Unlock() | |
| @@ -38,20 +38,29 @@ type composedRenderFrame struct { | |||||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | // composeFrame is pure with respect to daemon ownership: it consumes only the | 40 | // composeFrame is pure with respect to daemon ownership: it consumes only the | ||
| 41 | - | // capture and an attachment-local cache value, and returns a replacement cache. | 41 | + | // capture, the last committed cache, and an independent attachment-owned |
| 42 | - | func composeFrame(state capturedRenderState, in composeCacheInput) composedRenderFrame { | 42 | + | // scratch cache. It returns a replacement cache without mutating committed. |
| 43 | + | func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { | |||
| 44 | + | scratch := composeCacheInput{} | |||
| 45 | + | if len(scratchIn) > 0 { | |||
| 46 | + | scratch = scratchIn[0] | |||
| 47 | + | } | |||
| 43 | width, rows := state.layout.area.Width, state.layout.area.Height | 48 | width, rows := state.layout.area.Width, state.layout.area.Height | ||
| 44 | if width <= 0 || rows < 0 { | 49 | if width <= 0 || rows < 0 { | ||
| 45 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | 50 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | ||
| 46 | } | 51 | } | ||
| 47 | - | // The attachment-owned cache is safe to reuse while sendMu serializes this | 52 | + | // Compose into the alternate attachment-owned buffer. The committed frame is |
| 48 | - | // transaction. It contains only prior composed cells, never VT-owned cells. | 53 | + | // copied before incremental drawing so prepare/send failures cannot change it. |
| 49 | - | // Reusing it avoids cloning a client-sized frame on every incremental paint. | 54 | + | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2 |
| 50 | - | canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint | 55 | + | frame := scratch.frame |
| 51 | - | frame := in.frame | |||
| 52 | if !canReuseFrame { | 56 | if !canReuseFrame { | ||
| 53 | frame = renderer.NewFrame(width, rows+2) | 57 | frame = renderer.NewFrame(width, rows+2) | ||
| 54 | } | 58 | } | ||
| 59 | + | if in.frame.Width == width && in.frame.Height == rows+2 { | |||
| 60 | + | for y := 0; y < frame.Height; y++ { | |||
| 61 | + | copy(frame.Row(y), in.frame.Row(y)) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 55 | styles := newThemeStyles(state.theme) | 64 | styles := newThemeStyles(state.theme) | ||
| 56 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 65 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | ||
| 57 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 66 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | ||
| @@ -61,11 +70,11 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||||
| 61 | } | 70 | } | ||
| 62 | 71 | ||||
| 63 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme | 72 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme | ||
| 64 | - | titles := in.titleGenerations | 73 | + | titles := scratch.titleGenerations |
| 65 | if titles == nil { | 74 | if titles == nil { | ||
| 66 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | 75 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | ||
| 67 | } | 76 | } | ||
| 68 | - | damage := in.damage[:0] | 77 | + | damage := scratch.damage[:0] |
| 69 | for _, pane := range state.panes { | 78 | for _, pane := range state.panes { | ||
| 70 | pl := offsetPlacement(pane.placement, 0, 1) | 79 | pl := offsetPlacement(pane.placement, 0, 1) | ||
| 71 | if pl.TitleBar.Height > 0 { | 80 | if pl.TitleBar.Height > 0 { | ||
| @@ -114,7 +123,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende | |||||
| 114 | cursorInputs := state.cursor | 123 | cursorInputs := state.cursor | ||
| 115 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | 124 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | ||
| 116 | cursor := desiredCapturedCursor(cursorInputs) | 125 | cursor := desiredCapturedCursor(cursorInputs) | ||
| 117 | - | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: in.bars} | 126 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} |
| 118 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | 127 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | ||
| 119 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 128 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 120 | } | 129 | } | ||
| @@ -292,6 +301,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||||
| 292 | } | 301 | } | ||
| 293 | } | 302 | } | ||
| 294 | if sendErr == nil { | 303 | if sendErr == nil { | ||
| 304 | + | // Publish only after output preparation and transport emission both | |||
| 305 | + | // succeed. Swap the old committed cache back as the next compose target. | |||
| 306 | + | ac.pipelineScratch = ac.pipelineCache | |||
| 295 | ac.pipelineCache = composed.cache | 307 | ac.pipelineCache = composed.cache | ||
| 296 | } | 308 | } | ||
| 297 | ac.sess.mu.Unlock() | 309 | ac.sess.mu.Unlock() | ||
A internal/usecase/daemon/render_pipeline_cache_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type cacheFailTransport struct{} | |
| 16 | + | ||
| 17 | + | func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 18 | + | func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 19 | + | func (cacheFailTransport) Close() error { return nil } | |
| 20 | + | ||
| 21 | + | func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |
| 22 | + | for _, failure := range []struct { | |
| 23 | + | name string | |
| 24 | + | apply func(*composedRenderFrame, *attachedClient) | |
| 25 | + | }{ | |
| 26 | + | { | |
| 27 | + | name: "prepare", | |
| 28 | + | apply: func(composed *composedRenderFrame, _ *attachedClient) { | |
| 29 | + | // Frame validation fails in output.prepare after composition. | |
| 30 | + | composed.frame = renderer.Frame{Width: 1} | |
| 31 | + | }, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "send", | |
| 35 | + | apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) }, | |
| 36 | + | }, | |
| 37 | + | } { | |
| 38 | + | t.Run(failure.name, func(t *testing.T) { | |
| 39 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |
| 40 | + | healthy := ac.transport() | |
| 41 | + | committed := composeFrame(cacheState("committed", 1), composeCacheInput{}) | |
| 42 | + | ac.pipelineCache = committed.cache | |
| 43 | + | before := cloneComposeCache(ac.pipelineCache) | |
| 44 | + | pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch) | |
| 45 | + | failure.apply(&pending, ac) | |
| 46 | + | ||
| 47 | + | state := cacheState("next", 2) | |
| 48 | + | state.attachment = ac | |
| 49 | + | ac.sendMu.Lock() | |
| 50 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 51 | + | require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage") | |
| 52 | + | ||
| 53 | + | // A send error detaches the failed link. Re-own this test attachment with | |
| 54 | + | // its original healthy transport, then retry the pending state. | |
| 55 | + | if failure.name == "send" { | |
| 56 | + | sess.mu.Lock() | |
| 57 | + | sess.client = ac | |
| 58 | + | sess.mu.Unlock() | |
| 59 | + | ac.setSession(sess) | |
| 60 | + | ac.replaceTransport(healthy) | |
| 61 | + | } | |
| 62 | + | pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch) | |
| 63 | + | state = cacheState("next", 2) | |
| 64 | + | state.attachment = ac | |
| 65 | + | ac.sendMu.Lock() | |
| 66 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 67 | + | require.Equal(t, pending.cache, ac.pipelineCache) | |
| 68 | + | frame := <-sends | |
| 69 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission") | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func cacheState(title string, generation uint64) capturedRenderState { | |
| 77 | + | pane := renderer.NewFrame(6, 1) | |
| 78 | + | for x, r := range title[:min(len(title), 6)] { | |
| 79 | + | pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 80 | + | } | |
| 81 | + | floating := renderer.NewFrame(2, 1) | |
| 82 | + | floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()}) | |
| 83 | + | return capturedRenderState{ | |
| 84 | + | reset: false, | |
| 85 | + | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |
| 86 | + | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 87 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 88 | + | bars: barState{topRight: title, bottomRight: title}, | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func cloneComposeCache(in composeCacheInput) composeCacheInput { | |
| 93 | + | out := in | |
| 94 | + | out.frame = in.frame.Clone() | |
| 95 | + | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | |
| 96 | + | for id, generation := range in.titleGenerations { | |
| 97 | + | out.titleGenerations[id] = generation | |
| 98 | + | } | |
| 99 | + | out.damage = append([]renderer.Damage(nil), in.damage...) | |
| 100 | + | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | |
| 101 | + | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | |
| 102 | + | return out | |
| 103 | + | } | |
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "io" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type cacheFailTransport struct{} | |||
| 16 | + | ||||
| 17 | + | func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") } | |||
| 18 | + | func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 19 | + | func (cacheFailTransport) Close() error { return nil } | |||
| 20 | + | ||||
| 21 | + | func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||
| 22 | + | for _, failure := range []struct { | |||
| 23 | + | name string | |||
| 24 | + | apply func(*composedRenderFrame, *attachedClient) | |||
| 25 | + | }{ | |||
| 26 | + | { | |||
| 27 | + | name: "prepare", | |||
| 28 | + | apply: func(composed *composedRenderFrame, _ *attachedClient) { | |||
| 29 | + | // Frame validation fails in output.prepare after composition. | |||
| 30 | + | composed.frame = renderer.Frame{Width: 1} | |||
| 31 | + | }, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "send", | |||
| 35 | + | apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) }, | |||
| 36 | + | }, | |||
| 37 | + | } { | |||
| 38 | + | t.Run(failure.name, func(t *testing.T) { | |||
| 39 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |||
| 40 | + | healthy := ac.transport() | |||
| 41 | + | committed := composeFrame(cacheState("committed", 1), composeCacheInput{}) | |||
| 42 | + | ac.pipelineCache = committed.cache | |||
| 43 | + | before := cloneComposeCache(ac.pipelineCache) | |||
| 44 | + | pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch) | |||
| 45 | + | failure.apply(&pending, ac) | |||
| 46 | + | ||||
| 47 | + | state := cacheState("next", 2) | |||
| 48 | + | state.attachment = ac | |||
| 49 | + | ac.sendMu.Lock() | |||
| 50 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |||
| 51 | + | require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage") | |||
| 52 | + | ||||
| 53 | + | // A send error detaches the failed link. Re-own this test attachment with | |||
| 54 | + | // its original healthy transport, then retry the pending state. | |||
| 55 | + | if failure.name == "send" { | |||
| 56 | + | sess.mu.Lock() | |||
| 57 | + | sess.client = ac | |||
| 58 | + | sess.mu.Unlock() | |||
| 59 | + | ac.setSession(sess) | |||
| 60 | + | ac.replaceTransport(healthy) | |||
| 61 | + | } | |||
| 62 | + | pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch) | |||
| 63 | + | state = cacheState("next", 2) | |||
| 64 | + | state.attachment = ac | |||
| 65 | + | ac.sendMu.Lock() | |||
| 66 | + | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |||
| 67 | + | require.Equal(t, pending.cache, ac.pipelineCache) | |||
| 68 | + | frame := <-sends | |||
| 69 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission") | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func cacheState(title string, generation uint64) capturedRenderState { | |||
| 77 | + | pane := renderer.NewFrame(6, 1) | |||
| 78 | + | for x, r := range title[:min(len(title), 6)] { | |||
| 79 | + | pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 80 | + | } | |||
| 81 | + | floating := renderer.NewFrame(2, 1) | |||
| 82 | + | floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()}) | |||
| 83 | + | return capturedRenderState{ | |||
| 84 | + | reset: false, | |||
| 85 | + | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |||
| 86 | + | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |||
| 87 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |||
| 88 | + | bars: barState{topRight: title, bottomRight: title}, | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func cloneComposeCache(in composeCacheInput) composeCacheInput { | |||
| 93 | + | out := in | |||
| 94 | + | out.frame = in.frame.Clone() | |||
| 95 | + | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | |||
| 96 | + | for id, generation := range in.titleGenerations { | |||
| 97 | + | out.titleGenerations[id] = generation | |||
| 98 | + | } | |||
| 99 | + | out.damage = append([]renderer.Damage(nil), in.damage...) | |||
| 100 | + | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | |||
| 101 | + | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | |||
| 102 | + | return out | |||
| 103 | + | } | |||