perf(ansi): reuse renderer-owned frame buffers instead of cloning per Prepare
Prepare used to Clone the whole source frame (cells, rows, styles, cloned style map, payloads) on every draw that had work to commit, which was ~99% of allocated bytes. The renderer now owns two private frames: committed and scratch. Prepare copies the source into scratch with Frame.CopyFrom (reusing capacity; never aliasing caller storage, so callers may keep mutating one Frame in place). Commit either swaps scratch and committed (snapshot plans) or applies the span/scroll delta from scratch into committed. This double buffer is the simplest design that keeps the transactional contract: discarding a PreparedDraw leaves committed untouched because only scratch is written, and PreparedDraw.Bytes never references frame storage. A generation counter makes a draw superseded by a later Prepare a no-op on Commit, since its snapshot has been overwritten (at most one prepared draw may be outstanding). Reset keeps the buffers and only clears hasCommitted. PlanDelta remains public with identical semantics: it still returns a candidate owning a fresh snapshot (internal planDelta takes the scratch). buildDirtyLinePlan uses core.RowsEqual when both sides are core.Frame or *core.Frame, falling back to the generic Cell().Equal path for other CellSource implementations. Output bytes are unchanged. Adds ownership tests: caller mutation after commit / between Prepare and Commit, discard then re-Prepare, retained Bytes after later draws, mixed resize/reset/scroll sequences, and a steady-state allocation bound.
- commit
- 42b4cc247d46731c49c60cf320f025514cf6af11
- author
- brice · · 2026-10-02 09:27 UTC
- parent
- d71ada9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +308 -32
M ansi/delta_plan.go +38 -17
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | + | ||
| 3 | 5 | // Span identifies a changed horizontal range in a logical frame row. | |
| 4 | 6 | type Span struct { | |
| 5 | 7 | Y int | |
| @@ -31,35 +33,45 @@ type DeltaCandidate struct { | |||
| 31 | 33 | frame Frame | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | - | func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate { | |
| 36 | + | // newDeltaCandidate builds a candidate whose snapshot, when the plan has work to | |
| 37 | + | // commit, is copied into scratch (reusing its storage) or into a fresh frame | |
| 38 | + | // when scratch is nil. | |
| 39 | + | func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate { | |
| 35 | 40 | candidate := DeltaCandidate{Plan: plan} | |
| 36 | 41 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |
| 37 | - | candidate.frame = cloneCellSource(frame) | |
| 42 | + | candidate.frame = snapshotCellSource(scratch, frame) | |
| 38 | 43 | } | |
| 39 | 44 | return candidate | |
| 40 | 45 | } | |
| 41 | 46 | ||
| 42 | - | // PlanDelta prepares a non-mutating update from committed to frame. | |
| 47 | + | // PlanDelta prepares a non-mutating update from committed to frame. The | |
| 48 | + | // returned candidate owns an independent snapshot of frame. | |
| 43 | 49 | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | |
| 50 | + | return planDelta(frame, damage, committed, reset, nil) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // planDelta implements PlanDelta. A non-nil scratch receives the candidate's | |
| 54 | + | // snapshot in place of a fresh allocation; the caller then owns its lifetime. | |
| 55 | + | func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) { | |
| 44 | 56 | if err := validateCellSource(frame); err != nil { | |
| 45 | 57 | return DeltaCandidate{}, err | |
| 46 | 58 | } | |
| 47 | 59 | ||
| 48 | 60 | if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() { | |
| 49 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 61 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 50 | 62 | } | |
| 51 | 63 | if err := committed.Validate(); err != nil { | |
| 52 | 64 | return DeltaCandidate{}, err | |
| 53 | 65 | } | |
| 54 | 66 | ||
| 55 | 67 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 56 | - | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil | |
| 68 | + | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil | |
| 57 | 69 | } | |
| 58 | 70 | ||
| 59 | 71 | if needsFull(damage) { | |
| 60 | 72 | dirty, full := buildDirtyLinePlan(frame, committed) | |
| 61 | 73 | if len(dirty) > 0 || full { | |
| 62 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil | |
| 74 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil | |
| 63 | 75 | } | |
| 64 | 76 | return DeltaCandidate{}, nil | |
| 65 | 77 | } | |
| @@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||
| 70 | 82 | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | |
| 71 | 83 | skip = &scroll | |
| 72 | 84 | } else if hasScrollDamage(damage) { | |
| 73 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 85 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 74 | 86 | } | |
| 75 | 87 | ||
| 76 | 88 | var spans []Span | |
| @@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||
| 81 | 93 | spans, full = buildDamagePlan(frame, damage, skip) | |
| 82 | 94 | } | |
| 83 | 95 | if full { | |
| 84 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 96 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 85 | 97 | } | |
| 86 | 98 | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | |
| 87 | 99 | if plan.Scroll.Height != 0 { | |
| 88 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 100 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 89 | 101 | } | |
| 90 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil | |
| 102 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil | |
| 91 | 103 | } | |
| 92 | 104 | plan.Spans = spans | |
| 93 | - | return newDeltaCandidate(frame, plan), nil | |
| 105 | + | return newDeltaCandidate(frame, plan, scratch), nil | |
| 94 | 106 | } | |
| 95 | 107 | ||
| 96 | 108 | func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | |
| @@ -107,12 +119,17 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | |||
| 107 | 119 | ||
| 108 | 120 | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | |
| 109 | 121 | var spans []Span | |
| 122 | + | fast, fastOK := compactFrames(frame, committed) | |
| 110 | 123 | for y := range frame.Rows() { | |
| 111 | 124 | dirty := false | |
| 112 | - | for x := range frame.Columns() { | |
| 113 | - | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |
| 114 | - | dirty = true | |
| 115 | - | break | |
| 125 | + | if fastOK { | |
| 126 | + | dirty = !core.RowsEqual(fast.a, fast.b, y) | |
| 127 | + | } else { | |
| 128 | + | for x := range frame.Columns() { | |
| 129 | + | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |
| 130 | + | dirty = true | |
| 131 | + | break | |
| 132 | + | } | |
| 116 | 133 | } | |
| 117 | 134 | } | |
| 118 | 135 | if dirty { | |
| @@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 198 | 215 | return | |
| 199 | 216 | } | |
| 200 | 217 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | |
| 201 | - | replaceFrame(dst, c.frame) | |
| 218 | + | // dst may be shared with the caller, so give it a fresh page rather | |
| 219 | + | // than overwriting storage other Frame values might still reference. | |
| 220 | + | dst.Replace(c.frame) | |
| 202 | 221 | return | |
| 203 | 222 | } | |
| 204 | 223 | if c.Plan.Scroll.Height != 0 { | |
| @@ -216,6 +235,8 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 216 | 235 | } | |
| 217 | 236 | } | |
| 218 | 237 | ||
| 238 | + | // replaceFrame overwrites dst's own storage with a copy of src, reusing | |
| 239 | + | // capacity. It is for frames the renderer owns exclusively. | |
| 219 | 240 | func replaceFrame(dst *Frame, src Frame) { | |
| 220 | - | dst.Replace(src) | |
| 241 | + | dst.CopyFrom(src) | |
| 221 | 242 | } | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package ansi | 1 | package ansi | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/bnema/vev-vt/core" | |||
| 4 | + | ||||
| 3 | // Span identifies a changed horizontal range in a logical frame row. | 5 | // Span identifies a changed horizontal range in a logical frame row. | ||
| 4 | type Span struct { | 6 | type Span struct { | ||
| 5 | Y int | 7 | Y int | ||
| @@ -31,35 +33,45 @@ type DeltaCandidate struct { | |||||
| 31 | frame Frame | 33 | frame Frame | ||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 34 | - | func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate { | 36 | + | // newDeltaCandidate builds a candidate whose snapshot, when the plan has work to |
| 37 | + | // commit, is copied into scratch (reusing its storage) or into a fresh frame | |||
| 38 | + | // when scratch is nil. | |||
| 39 | + | func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate { | |||
| 35 | candidate := DeltaCandidate{Plan: plan} | 40 | candidate := DeltaCandidate{Plan: plan} | ||
| 36 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | 41 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | ||
| 37 | - | candidate.frame = cloneCellSource(frame) | 42 | + | candidate.frame = snapshotCellSource(scratch, frame) |
| 38 | } | 43 | } | ||
| 39 | return candidate | 44 | return candidate | ||
| 40 | } | 45 | } | ||
| 41 | 46 | ||||
| 42 | - | // PlanDelta prepares a non-mutating update from committed to frame. | 47 | + | // PlanDelta prepares a non-mutating update from committed to frame. The |
| 48 | + | // returned candidate owns an independent snapshot of frame. | |||
| 43 | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | 49 | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | ||
| 50 | + | return planDelta(frame, damage, committed, reset, nil) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // planDelta implements PlanDelta. A non-nil scratch receives the candidate's | |||
| 54 | + | // snapshot in place of a fresh allocation; the caller then owns its lifetime. | |||
| 55 | + | func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) { | |||
| 44 | if err := validateCellSource(frame); err != nil { | 56 | if err := validateCellSource(frame); err != nil { | ||
| 45 | return DeltaCandidate{}, err | 57 | return DeltaCandidate{}, err | ||
| 46 | } | 58 | } | ||
| 47 | 59 | ||||
| 48 | if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() { | 60 | if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() { | ||
| 49 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 61 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 50 | } | 62 | } | ||
| 51 | if err := committed.Validate(); err != nil { | 63 | if err := committed.Validate(); err != nil { | ||
| 52 | return DeltaCandidate{}, err | 64 | return DeltaCandidate{}, err | ||
| 53 | } | 65 | } | ||
| 54 | 66 | ||||
| 55 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | 67 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | ||
| 56 | - | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil | 68 | + | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil |
| 57 | } | 69 | } | ||
| 58 | 70 | ||||
| 59 | if needsFull(damage) { | 71 | if needsFull(damage) { | ||
| 60 | dirty, full := buildDirtyLinePlan(frame, committed) | 72 | dirty, full := buildDirtyLinePlan(frame, committed) | ||
| 61 | if len(dirty) > 0 || full { | 73 | if len(dirty) > 0 || full { | ||
| 62 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil | 74 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil |
| 63 | } | 75 | } | ||
| 64 | return DeltaCandidate{}, nil | 76 | return DeltaCandidate{}, nil | ||
| 65 | } | 77 | } | ||
| @@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||||
| 70 | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | 82 | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | ||
| 71 | skip = &scroll | 83 | skip = &scroll | ||
| 72 | } else if hasScrollDamage(damage) { | 84 | } else if hasScrollDamage(damage) { | ||
| 73 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 85 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 74 | } | 86 | } | ||
| 75 | 87 | ||||
| 76 | var spans []Span | 88 | var spans []Span | ||
| @@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||||
| 81 | spans, full = buildDamagePlan(frame, damage, skip) | 93 | spans, full = buildDamagePlan(frame, damage, skip) | ||
| 82 | } | 94 | } | ||
| 83 | if full { | 95 | if full { | ||
| 84 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 96 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 85 | } | 97 | } | ||
| 86 | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | 98 | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | ||
| 87 | if plan.Scroll.Height != 0 { | 99 | if plan.Scroll.Height != 0 { | ||
| 88 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 100 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 89 | } | 101 | } | ||
| 90 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil | 102 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil |
| 91 | } | 103 | } | ||
| 92 | plan.Spans = spans | 104 | plan.Spans = spans | ||
| 93 | - | return newDeltaCandidate(frame, plan), nil | 105 | + | return newDeltaCandidate(frame, plan, scratch), nil |
| 94 | } | 106 | } | ||
| 95 | 107 | ||||
| 96 | func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | 108 | func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | ||
| @@ -107,12 +119,17 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | |||||
| 107 | 119 | ||||
| 108 | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | 120 | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | ||
| 109 | var spans []Span | 121 | var spans []Span | ||
| 122 | + | fast, fastOK := compactFrames(frame, committed) | |||
| 110 | for y := range frame.Rows() { | 123 | for y := range frame.Rows() { | ||
| 111 | dirty := false | 124 | dirty := false | ||
| 112 | - | for x := range frame.Columns() { | 125 | + | if fastOK { |
| 113 | - | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | 126 | + | dirty = !core.RowsEqual(fast.a, fast.b, y) |
| 114 | - | dirty = true | 127 | + | } else { |
| 115 | - | break | 128 | + | for x := range frame.Columns() { |
| 129 | + | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |||
| 130 | + | dirty = true | |||
| 131 | + | break | |||
| 132 | + | } | |||
| 116 | } | 133 | } | ||
| 117 | } | 134 | } | ||
| 118 | if dirty { | 135 | if dirty { | ||
| @@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||||
| 198 | return | 215 | return | ||
| 199 | } | 216 | } | ||
| 200 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | 217 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | ||
| 201 | - | replaceFrame(dst, c.frame) | 218 | + | // dst may be shared with the caller, so give it a fresh page rather |
| 219 | + | // than overwriting storage other Frame values might still reference. | |||
| 220 | + | dst.Replace(c.frame) | |||
| 202 | return | 221 | return | ||
| 203 | } | 222 | } | ||
| 204 | if c.Plan.Scroll.Height != 0 { | 223 | if c.Plan.Scroll.Height != 0 { | ||
| @@ -216,6 +235,8 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||||
| 216 | } | 235 | } | ||
| 217 | } | 236 | } | ||
| 218 | 237 | ||||
| 238 | + | // replaceFrame overwrites dst's own storage with a copy of src, reusing | |||
| 239 | + | // capacity. It is for frames the renderer owns exclusively. | |||
| 219 | func replaceFrame(dst *Frame, src Frame) { | 240 | func replaceFrame(dst *Frame, src Frame) { | ||
| 220 | - | dst.Replace(src) | 241 | + | dst.CopyFrom(src) |
| 221 | } | 242 | } | ||
M ansi/frame.go +57 -0
| @@ -39,6 +39,63 @@ func validateCellSource(source CellSource) error { | |||
| 39 | 39 | return nil | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // snapshotCellSource returns an independent Frame holding source's cells. When | |
| 43 | + | // scratch is non-nil the snapshot is written into scratch's existing storage | |
| 44 | + | // (allocating only to grow or on first use) and the returned Frame shares that | |
| 45 | + | // storage; the caller owns scratch and must not hand it to anyone else while | |
| 46 | + | // the snapshot is live. A nil scratch allocates a fresh frame. | |
| 47 | + | func snapshotCellSource(scratch *Frame, source CellSource) Frame { | |
| 48 | + | if scratch == nil { | |
| 49 | + | return cloneCellSource(source) | |
| 50 | + | } | |
| 51 | + | switch frame := source.(type) { | |
| 52 | + | case Frame: | |
| 53 | + | scratch.CopyFrom(frame) | |
| 54 | + | return *scratch | |
| 55 | + | case *Frame: | |
| 56 | + | scratch.CopyFrom(*frame) | |
| 57 | + | return *scratch | |
| 58 | + | } | |
| 59 | + | columns, rows := source.Columns(), source.Rows() | |
| 60 | + | if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil { | |
| 61 | + | *scratch = NewFrame(columns, rows) | |
| 62 | + | } | |
| 63 | + | for y := range rows { | |
| 64 | + | for x := range columns { | |
| 65 | + | scratch.Set(x, y, source.Cell(x, y)) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | return *scratch | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // compactFrames reports whether both sources are core.Frame values (directly or | |
| 72 | + | // by pointer), enabling stored-cell comparison without the CellSource interface. | |
| 73 | + | type framePair struct{ a, b Frame } | |
| 74 | + | ||
| 75 | + | func compactFrames(a, b CellSource) (framePair, bool) { | |
| 76 | + | fa, ok := asFrame(a) | |
| 77 | + | if !ok { | |
| 78 | + | return framePair{}, false | |
| 79 | + | } | |
| 80 | + | fb, ok := asFrame(b) | |
| 81 | + | if !ok { | |
| 82 | + | return framePair{}, false | |
| 83 | + | } | |
| 84 | + | return framePair{fa, fb}, true | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func asFrame(source CellSource) (Frame, bool) { | |
| 88 | + | switch frame := source.(type) { | |
| 89 | + | case Frame: | |
| 90 | + | return frame, true | |
| 91 | + | case *Frame: | |
| 92 | + | if frame != nil { | |
| 93 | + | return *frame, true | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return Frame{}, false | |
| 97 | + | } | |
| 98 | + | ||
| 42 | 99 | func cloneCellSource(source CellSource) Frame { | |
| 43 | 100 | switch frame := source.(type) { | |
| 44 | 101 | case Frame: | |
| @@ -39,6 +39,63 @@ func validateCellSource(source CellSource) error { | |||||
| 39 | return nil | 39 | return nil | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // snapshotCellSource returns an independent Frame holding source's cells. When | |||
| 43 | + | // scratch is non-nil the snapshot is written into scratch's existing storage | |||
| 44 | + | // (allocating only to grow or on first use) and the returned Frame shares that | |||
| 45 | + | // storage; the caller owns scratch and must not hand it to anyone else while | |||
| 46 | + | // the snapshot is live. A nil scratch allocates a fresh frame. | |||
| 47 | + | func snapshotCellSource(scratch *Frame, source CellSource) Frame { | |||
| 48 | + | if scratch == nil { | |||
| 49 | + | return cloneCellSource(source) | |||
| 50 | + | } | |||
| 51 | + | switch frame := source.(type) { | |||
| 52 | + | case Frame: | |||
| 53 | + | scratch.CopyFrom(frame) | |||
| 54 | + | return *scratch | |||
| 55 | + | case *Frame: | |||
| 56 | + | scratch.CopyFrom(*frame) | |||
| 57 | + | return *scratch | |||
| 58 | + | } | |||
| 59 | + | columns, rows := source.Columns(), source.Rows() | |||
| 60 | + | if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil { | |||
| 61 | + | *scratch = NewFrame(columns, rows) | |||
| 62 | + | } | |||
| 63 | + | for y := range rows { | |||
| 64 | + | for x := range columns { | |||
| 65 | + | scratch.Set(x, y, source.Cell(x, y)) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | return *scratch | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // compactFrames reports whether both sources are core.Frame values (directly or | |||
| 72 | + | // by pointer), enabling stored-cell comparison without the CellSource interface. | |||
| 73 | + | type framePair struct{ a, b Frame } | |||
| 74 | + | ||||
| 75 | + | func compactFrames(a, b CellSource) (framePair, bool) { | |||
| 76 | + | fa, ok := asFrame(a) | |||
| 77 | + | if !ok { | |||
| 78 | + | return framePair{}, false | |||
| 79 | + | } | |||
| 80 | + | fb, ok := asFrame(b) | |||
| 81 | + | if !ok { | |||
| 82 | + | return framePair{}, false | |||
| 83 | + | } | |||
| 84 | + | return framePair{fa, fb}, true | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func asFrame(source CellSource) (Frame, bool) { | |||
| 88 | + | switch frame := source.(type) { | |||
| 89 | + | case Frame: | |||
| 90 | + | return frame, true | |||
| 91 | + | case *Frame: | |||
| 92 | + | if frame != nil { | |||
| 93 | + | return *frame, true | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return Frame{}, false | |||
| 97 | + | } | |||
| 98 | + | ||||
| 42 | func cloneCellSource(source CellSource) Frame { | 99 | func cloneCellSource(source CellSource) Frame { | ||
| 43 | switch frame := source.(type) { | 100 | switch frame := source.(type) { | ||
| 44 | case Frame: | 101 | case Frame: | ||
M ansi/prepared_ownership_test.go +176 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "runtime" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| @@ -23,3 +24,178 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) { | |||
| 23 | 24 | require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells") | |
| 24 | 25 | require.Equal(t, encoded, alias.Bytes()) | |
| 25 | 26 | } | |
| 27 | + | ||
| 28 | + | func TestRendererNeverAliasesCallerFrame(t *testing.T) { | |
| 29 | + | r := New(Capabilities{}) | |
| 30 | + | frame := NewFrame(6, 3) | |
| 31 | + | markFrame(&frame) | |
| 32 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | ||
| 35 | + | // Mutate the caller's frame in place after commit without drawing, then | |
| 36 | + | // restore it: the renderer's committed state must not have followed it. | |
| 37 | + | original := frame.Cell(2, 1) | |
| 38 | + | frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}}) | |
| 39 | + | require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune) | |
| 40 | + | frame.Set(2, 1, original) | |
| 41 | + | prepared, err := r.Prepare(frame, nil, false) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | require.Empty(t, prepared.Bytes(), "committed state followed caller mutation") | |
| 44 | + | prepared.Commit() | |
| 45 | + | ||
| 46 | + | // Mutate after Prepare and before Commit: the commit keeps the prepared | |
| 47 | + | // content, so the next draw re-emits the difference. | |
| 48 | + | frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()}) | |
| 49 | + | prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()}) | |
| 52 | + | prepared.Commit() | |
| 53 | + | require.Equal(t, 'P', r.committed.Cell(0, 0).Rune) | |
| 54 | + | out, err := r.Draw(frame, nil) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.Contains(t, string(out), "R") | |
| 57 | + | require.Equal(t, 'R', r.committed.Cell(0, 0).Rune) | |
| 58 | + | require.NoError(t, r.committed.CheckInvariants()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) { | |
| 62 | + | r := New(Capabilities{}) | |
| 63 | + | frame := NewFrame(5, 3) | |
| 64 | + | markFrame(&frame) | |
| 65 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | committed := r.committed.Clone() | |
| 68 | + | ||
| 69 | + | for _, damage := range [][]Damage{ | |
| 70 | + | {FullRedraw()}, | |
| 71 | + | {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}}, | |
| 72 | + | nil, | |
| 73 | + | } { | |
| 74 | + | frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}}) | |
| 75 | + | discarded, err := r.Prepare(frame, damage, false) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.NotEmpty(t, discarded.Bytes()) | |
| 78 | + | for y := range committed.Height { | |
| 79 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |
| 80 | + | } | |
| 81 | + | // Preparing again after a discard produces the same bytes. | |
| 82 | + | again, err := r.Prepare(frame, damage, false) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | require.Equal(t, discarded.Bytes(), again.Bytes()) | |
| 85 | + | discarded.Commit() // superseded by a later Prepare: must not apply | |
| 86 | + | for y := range committed.Height { | |
| 87 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |
| 88 | + | } | |
| 89 | + | again.Commit() | |
| 90 | + | require.Equal(t, '#', r.committed.Cell(1, 1).Rune) | |
| 91 | + | require.NoError(t, r.committed.CheckInvariants()) | |
| 92 | + | ||
| 93 | + | // Restore for the next scenario. | |
| 94 | + | frame.Set(1, 1, committed.Cell(1, 1)) | |
| 95 | + | _, err = r.Draw(frame, nil) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestPreparedBytesSurviveLaterDraws(t *testing.T) { | |
| 102 | + | r := New(Capabilities{}) | |
| 103 | + | frame := NewFrame(8, 2) | |
| 104 | + | markFrame(&frame) | |
| 105 | + | first, err := r.Prepare(frame, []Damage{FullRedraw()}, false) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | want := append([]byte(nil), first.Bytes()...) | |
| 108 | + | first.Commit() | |
| 109 | + | for i := range 5 { | |
| 110 | + | frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()}) | |
| 111 | + | _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}}) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | } | |
| 114 | + | require.Equal(t, want, first.Bytes()) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) { | |
| 118 | + | // A long mixed sequence on one renderer (buffer reuse, resizes, reset, | |
| 119 | + | // scroll) must keep emitting exactly what a renderer with an exact | |
| 120 | + | // reference committed frame would: full redraw after each step equals | |
| 121 | + | // the fresh-renderer snapshot, and no-damage diffs are empty. | |
| 122 | + | r := New(Capabilities{}) | |
| 123 | + | frame := NewFrame(7, 4) | |
| 124 | + | markFrame(&frame) | |
| 125 | + | steps := []func(){ | |
| 126 | + | func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) }, | |
| 127 | + | func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) }, | |
| 128 | + | func() { frame = NewFrame(9, 5); markFrame(&frame) }, | |
| 129 | + | func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) }, | |
| 130 | + | func() { | |
| 131 | + | frame = NewFrame(7, 4) | |
| 132 | + | markFrame(&frame) | |
| 133 | + | frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()}) | |
| 134 | + | }, | |
| 135 | + | } | |
| 136 | + | for i, step := range steps { | |
| 137 | + | step() | |
| 138 | + | if i == 3 { | |
| 139 | + | r.Reset() | |
| 140 | + | } | |
| 141 | + | _, err := r.Draw(frame, nil) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | for y := range frame.Height { | |
| 144 | + | require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y) | |
| 145 | + | } | |
| 146 | + | out, err := r.Draw(frame, nil) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | require.Empty(t, out, "step %d", i) | |
| 149 | + | // Reset keeps equivalence with a brand new renderer. | |
| 150 | + | r2 := New(Capabilities{}) | |
| 151 | + | want, err := r2.Draw(frame, nil) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | r.Reset() | |
| 154 | + | got, err := r.Draw(frame, nil) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | require.Equal(t, want, got, "step %d", i) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) { | |
| 161 | + | r := New(Capabilities{}) | |
| 162 | + | frame := NewFrame(120, 40) | |
| 163 | + | markFrame(&frame) | |
| 164 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | _, err = r.Draw(frame, []Damage{FullRedraw()}) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | ||
| 169 | + | // A whole frame is ~76 KB; steady-state draws must stay far below it. | |
| 170 | + | toggled := false | |
| 171 | + | damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}} | |
| 172 | + | allocs := testing.AllocsPerRun(20, func() { | |
| 173 | + | toggled = !toggled | |
| 174 | + | ch := 'X' | |
| 175 | + | if toggled { | |
| 176 | + | ch = 'Y' | |
| 177 | + | } | |
| 178 | + | frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()}) | |
| 179 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 180 | + | if err != nil { | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | prepared.Commit() | |
| 184 | + | }) | |
| 185 | + | require.LessOrEqual(t, allocs, 8.0) | |
| 186 | + | var m1, m2 runtime.MemStats | |
| 187 | + | runtime.ReadMemStats(&m1) | |
| 188 | + | for range 50 { | |
| 189 | + | toggled = !toggled | |
| 190 | + | frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()}) | |
| 191 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | prepared.Commit() | |
| 194 | + | frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()}) | |
| 195 | + | prepared, err = r.Prepare(frame, damage, false) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | prepared.Commit() | |
| 198 | + | } | |
| 199 | + | runtime.ReadMemStats(&m2) | |
| 200 | + | require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated") | |
| 201 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ansi | 1 | package ansi | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "runtime" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| @@ -23,3 +24,178 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) { | |||||
| 23 | require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells") | 24 | require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells") | ||
| 24 | require.Equal(t, encoded, alias.Bytes()) | 25 | require.Equal(t, encoded, alias.Bytes()) | ||
| 25 | } | 26 | } | ||
| 27 | + | ||||
| 28 | + | func TestRendererNeverAliasesCallerFrame(t *testing.T) { | |||
| 29 | + | r := New(Capabilities{}) | |||
| 30 | + | frame := NewFrame(6, 3) | |||
| 31 | + | markFrame(&frame) | |||
| 32 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | ||||
| 35 | + | // Mutate the caller's frame in place after commit without drawing, then | |||
| 36 | + | // restore it: the renderer's committed state must not have followed it. | |||
| 37 | + | original := frame.Cell(2, 1) | |||
| 38 | + | frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}}) | |||
| 39 | + | require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune) | |||
| 40 | + | frame.Set(2, 1, original) | |||
| 41 | + | prepared, err := r.Prepare(frame, nil, false) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | require.Empty(t, prepared.Bytes(), "committed state followed caller mutation") | |||
| 44 | + | prepared.Commit() | |||
| 45 | + | ||||
| 46 | + | // Mutate after Prepare and before Commit: the commit keeps the prepared | |||
| 47 | + | // content, so the next draw re-emits the difference. | |||
| 48 | + | frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()}) | |||
| 49 | + | prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()}) | |||
| 52 | + | prepared.Commit() | |||
| 53 | + | require.Equal(t, 'P', r.committed.Cell(0, 0).Rune) | |||
| 54 | + | out, err := r.Draw(frame, nil) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.Contains(t, string(out), "R") | |||
| 57 | + | require.Equal(t, 'R', r.committed.Cell(0, 0).Rune) | |||
| 58 | + | require.NoError(t, r.committed.CheckInvariants()) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) { | |||
| 62 | + | r := New(Capabilities{}) | |||
| 63 | + | frame := NewFrame(5, 3) | |||
| 64 | + | markFrame(&frame) | |||
| 65 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | committed := r.committed.Clone() | |||
| 68 | + | ||||
| 69 | + | for _, damage := range [][]Damage{ | |||
| 70 | + | {FullRedraw()}, | |||
| 71 | + | {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}}, | |||
| 72 | + | nil, | |||
| 73 | + | } { | |||
| 74 | + | frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}}) | |||
| 75 | + | discarded, err := r.Prepare(frame, damage, false) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.NotEmpty(t, discarded.Bytes()) | |||
| 78 | + | for y := range committed.Height { | |||
| 79 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |||
| 80 | + | } | |||
| 81 | + | // Preparing again after a discard produces the same bytes. | |||
| 82 | + | again, err := r.Prepare(frame, damage, false) | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | require.Equal(t, discarded.Bytes(), again.Bytes()) | |||
| 85 | + | discarded.Commit() // superseded by a later Prepare: must not apply | |||
| 86 | + | for y := range committed.Height { | |||
| 87 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |||
| 88 | + | } | |||
| 89 | + | again.Commit() | |||
| 90 | + | require.Equal(t, '#', r.committed.Cell(1, 1).Rune) | |||
| 91 | + | require.NoError(t, r.committed.CheckInvariants()) | |||
| 92 | + | ||||
| 93 | + | // Restore for the next scenario. | |||
| 94 | + | frame.Set(1, 1, committed.Cell(1, 1)) | |||
| 95 | + | _, err = r.Draw(frame, nil) | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestPreparedBytesSurviveLaterDraws(t *testing.T) { | |||
| 102 | + | r := New(Capabilities{}) | |||
| 103 | + | frame := NewFrame(8, 2) | |||
| 104 | + | markFrame(&frame) | |||
| 105 | + | first, err := r.Prepare(frame, []Damage{FullRedraw()}, false) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | want := append([]byte(nil), first.Bytes()...) | |||
| 108 | + | first.Commit() | |||
| 109 | + | for i := range 5 { | |||
| 110 | + | frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()}) | |||
| 111 | + | _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}}) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | } | |||
| 114 | + | require.Equal(t, want, first.Bytes()) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) { | |||
| 118 | + | // A long mixed sequence on one renderer (buffer reuse, resizes, reset, | |||
| 119 | + | // scroll) must keep emitting exactly what a renderer with an exact | |||
| 120 | + | // reference committed frame would: full redraw after each step equals | |||
| 121 | + | // the fresh-renderer snapshot, and no-damage diffs are empty. | |||
| 122 | + | r := New(Capabilities{}) | |||
| 123 | + | frame := NewFrame(7, 4) | |||
| 124 | + | markFrame(&frame) | |||
| 125 | + | steps := []func(){ | |||
| 126 | + | func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) }, | |||
| 127 | + | func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) }, | |||
| 128 | + | func() { frame = NewFrame(9, 5); markFrame(&frame) }, | |||
| 129 | + | func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) }, | |||
| 130 | + | func() { | |||
| 131 | + | frame = NewFrame(7, 4) | |||
| 132 | + | markFrame(&frame) | |||
| 133 | + | frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()}) | |||
| 134 | + | }, | |||
| 135 | + | } | |||
| 136 | + | for i, step := range steps { | |||
| 137 | + | step() | |||
| 138 | + | if i == 3 { | |||
| 139 | + | r.Reset() | |||
| 140 | + | } | |||
| 141 | + | _, err := r.Draw(frame, nil) | |||
| 142 | + | require.NoError(t, err) | |||
| 143 | + | for y := range frame.Height { | |||
| 144 | + | require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y) | |||
| 145 | + | } | |||
| 146 | + | out, err := r.Draw(frame, nil) | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | require.Empty(t, out, "step %d", i) | |||
| 149 | + | // Reset keeps equivalence with a brand new renderer. | |||
| 150 | + | r2 := New(Capabilities{}) | |||
| 151 | + | want, err := r2.Draw(frame, nil) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | r.Reset() | |||
| 154 | + | got, err := r.Draw(frame, nil) | |||
| 155 | + | require.NoError(t, err) | |||
| 156 | + | require.Equal(t, want, got, "step %d", i) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) { | |||
| 161 | + | r := New(Capabilities{}) | |||
| 162 | + | frame := NewFrame(120, 40) | |||
| 163 | + | markFrame(&frame) | |||
| 164 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | _, err = r.Draw(frame, []Damage{FullRedraw()}) | |||
| 167 | + | require.NoError(t, err) | |||
| 168 | + | ||||
| 169 | + | // A whole frame is ~76 KB; steady-state draws must stay far below it. | |||
| 170 | + | toggled := false | |||
| 171 | + | damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}} | |||
| 172 | + | allocs := testing.AllocsPerRun(20, func() { | |||
| 173 | + | toggled = !toggled | |||
| 174 | + | ch := 'X' | |||
| 175 | + | if toggled { | |||
| 176 | + | ch = 'Y' | |||
| 177 | + | } | |||
| 178 | + | frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()}) | |||
| 179 | + | prepared, err := r.Prepare(frame, damage, false) | |||
| 180 | + | if err != nil { | |||
| 181 | + | t.Fatal(err) | |||
| 182 | + | } | |||
| 183 | + | prepared.Commit() | |||
| 184 | + | }) | |||
| 185 | + | require.LessOrEqual(t, allocs, 8.0) | |||
| 186 | + | var m1, m2 runtime.MemStats | |||
| 187 | + | runtime.ReadMemStats(&m1) | |||
| 188 | + | for range 50 { | |||
| 189 | + | toggled = !toggled | |||
| 190 | + | frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()}) | |||
| 191 | + | prepared, err := r.Prepare(frame, damage, false) | |||
| 192 | + | require.NoError(t, err) | |||
| 193 | + | prepared.Commit() | |||
| 194 | + | frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()}) | |||
| 195 | + | prepared, err = r.Prepare(frame, damage, false) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | prepared.Commit() | |||
| 198 | + | } | |||
| 199 | + | runtime.ReadMemStats(&m2) | |||
| 200 | + | require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated") | |||
| 201 | + | } | |||
M ansi/renderer.go +37 -15
| @@ -24,6 +24,12 @@ type Renderer struct { | |||
| 24 | 24 | height int | |
| 25 | 25 | hasCommitted bool | |
| 26 | 26 | committed Frame | |
| 27 | + | // scratch receives the snapshot of the frame being prepared. It never | |
| 28 | + | // aliases committed or caller storage: a snapshot commit swaps the two so | |
| 29 | + | // both buffers are reused across draws without allocating. | |
| 30 | + | scratch Frame | |
| 31 | + | // generation identifies the latest prepared draw that owns scratch. | |
| 32 | + | generation uint64 | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | // PreparedDraw owns encoded output and its transactional delta until Commit. | |
| @@ -32,6 +38,7 @@ type PreparedDraw struct { | |||
| 32 | 38 | renderer *Renderer | |
| 33 | 39 | candidate DeltaCandidate | |
| 34 | 40 | data []byte | |
| 41 | + | generation uint64 | |
| 35 | 42 | commitOnce *sync.Once | |
| 36 | 43 | } | |
| 37 | 44 | ||
| @@ -48,7 +55,7 @@ func (r *Renderer) Reset() { | |||
| 48 | 55 | r.width = 0 | |
| 49 | 56 | r.height = 0 | |
| 50 | 57 | r.hasCommitted = false | |
| 51 | - | r.committed = Frame{} | |
| 58 | + | // The frame buffers are kept for reuse; hasCommitted gates their content. | |
| 52 | 59 | } | |
| 53 | 60 | ||
| 54 | 61 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | |
| @@ -62,41 +69,56 @@ func (p *PreparedDraw) Commit() { | |||
| 62 | 69 | return | |
| 63 | 70 | } | |
| 64 | 71 | p.commitOnce.Do(func() { | |
| 65 | - | committed := p.renderer.committedFrame() | |
| 66 | - | if p.candidate.Plan.Snapshot { | |
| 67 | - | // PreparedDraw privately owns this snapshot. Its shared Once makes | |
| 68 | - | // transfer safe even when the caller retains copies of the draw. | |
| 69 | - | committed = p.candidate.frame | |
| 72 | + | r := p.renderer | |
| 73 | + | plan := p.candidate.Plan | |
| 74 | + | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | if p.generation != r.generation { | |
| 78 | + | // A later Prepare reused the scratch frame this draw's snapshot | |
| 79 | + | // lives in, so it was discarded. | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | if plan.Snapshot || !r.hasCommitted { | |
| 83 | + | // The snapshot lives in the renderer's scratch buffer; promote it | |
| 84 | + | // and recycle the previous committed buffer as the next scratch. | |
| 85 | + | r.committed, r.scratch = p.candidate.frame, r.committed | |
| 70 | 86 | } else { | |
| 71 | - | p.candidate.Commit(&committed) | |
| 87 | + | p.candidate.Commit(&r.committed) | |
| 72 | 88 | } | |
| 73 | - | p.renderer.setCommittedFrame(committed) | |
| 89 | + | r.width = r.committed.Width | |
| 90 | + | r.height = r.committed.Height | |
| 91 | + | r.hasCommitted = true | |
| 74 | 92 | }) | |
| 75 | 93 | } | |
| 76 | 94 | ||
| 77 | 95 | // Prepare plans and encodes a transactional draw. The renderer advances only | |
| 78 | 96 | // when the returned draw is committed. Keep at most one prepared draw | |
| 79 | - | // outstanding; commit or discard it before calling Prepare again. | |
| 97 | + | // outstanding; commit or discard it before calling Prepare again. A draw | |
| 98 | + | // superseded by a later Prepare is discarded and its Commit does nothing. | |
| 99 | + | // | |
| 100 | + | // The renderer copies frame into reusable private storage, so the caller may | |
| 101 | + | // keep mutating frame between draws. | |
| 80 | 102 | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { | |
| 81 | 103 | var candidate DeltaCandidate | |
| 82 | 104 | var err error | |
| 83 | 105 | if err = validateCellSource(frame); err != nil { | |
| 84 | 106 | return PreparedDraw{}, err | |
| 85 | 107 | } | |
| 108 | + | // Any earlier prepared draw loses its claim on the scratch snapshot, even | |
| 109 | + | // if this Prepare fails after partially rewriting it. | |
| 110 | + | r.generation++ | |
| 86 | 111 | columns, rows := frame.Columns(), frame.Rows() | |
| 87 | 112 | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 88 | 113 | plan := planSingleDamage(frame, damage[0]) | |
| 89 | - | candidate = DeltaCandidate{Plan: plan} | |
| 90 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |
| 91 | - | candidate.frame = cloneCellSource(frame) | |
| 92 | - | } | |
| 114 | + | candidate = newDeltaCandidate(frame, plan, &r.scratch) | |
| 93 | 115 | } else { | |
| 94 | - | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted) | |
| 116 | + | candidate, err = planDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted, &r.scratch) | |
| 95 | 117 | } | |
| 96 | 118 | if err != nil { | |
| 97 | 119 | return PreparedDraw{}, err | |
| 98 | 120 | } | |
| 99 | - | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} | |
| 121 | + | prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)} | |
| 100 | 122 | plan := candidate.Plan | |
| 101 | 123 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | |
| 102 | 124 | return prepared, nil | |
| @@ -24,6 +24,12 @@ type Renderer struct { | |||||
| 24 | height int | 24 | height int | ||
| 25 | hasCommitted bool | 25 | hasCommitted bool | ||
| 26 | committed Frame | 26 | committed Frame | ||
| 27 | + | // scratch receives the snapshot of the frame being prepared. It never | |||
| 28 | + | // aliases committed or caller storage: a snapshot commit swaps the two so | |||
| 29 | + | // both buffers are reused across draws without allocating. | |||
| 30 | + | scratch Frame | |||
| 31 | + | // generation identifies the latest prepared draw that owns scratch. | |||
| 32 | + | generation uint64 | |||
| 27 | } | 33 | } | ||
| 28 | 34 | ||||
| 29 | // PreparedDraw owns encoded output and its transactional delta until Commit. | 35 | // PreparedDraw owns encoded output and its transactional delta until Commit. | ||
| @@ -32,6 +38,7 @@ type PreparedDraw struct { | |||||
| 32 | renderer *Renderer | 38 | renderer *Renderer | ||
| 33 | candidate DeltaCandidate | 39 | candidate DeltaCandidate | ||
| 34 | data []byte | 40 | data []byte | ||
| 41 | + | generation uint64 | |||
| 35 | commitOnce *sync.Once | 42 | commitOnce *sync.Once | ||
| 36 | } | 43 | } | ||
| 37 | 44 | ||||
| @@ -48,7 +55,7 @@ func (r *Renderer) Reset() { | |||||
| 48 | r.width = 0 | 55 | r.width = 0 | ||
| 49 | r.height = 0 | 56 | r.height = 0 | ||
| 50 | r.hasCommitted = false | 57 | r.hasCommitted = false | ||
| 51 | - | r.committed = Frame{} | 58 | + | // The frame buffers are kept for reuse; hasCommitted gates their content. |
| 52 | } | 59 | } | ||
| 53 | 60 | ||||
| 54 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | 61 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | ||
| @@ -62,41 +69,56 @@ func (p *PreparedDraw) Commit() { | |||||
| 62 | return | 69 | return | ||
| 63 | } | 70 | } | ||
| 64 | p.commitOnce.Do(func() { | 71 | p.commitOnce.Do(func() { | ||
| 65 | - | committed := p.renderer.committedFrame() | 72 | + | r := p.renderer |
| 66 | - | if p.candidate.Plan.Snapshot { | 73 | + | plan := p.candidate.Plan |
| 67 | - | // PreparedDraw privately owns this snapshot. Its shared Once makes | 74 | + | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { |
| 68 | - | // transfer safe even when the caller retains copies of the draw. | 75 | + | return |
| 69 | - | committed = p.candidate.frame | 76 | + | } |
| 77 | + | if p.generation != r.generation { | |||
| 78 | + | // A later Prepare reused the scratch frame this draw's snapshot | |||
| 79 | + | // lives in, so it was discarded. | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | if plan.Snapshot || !r.hasCommitted { | |||
| 83 | + | // The snapshot lives in the renderer's scratch buffer; promote it | |||
| 84 | + | // and recycle the previous committed buffer as the next scratch. | |||
| 85 | + | r.committed, r.scratch = p.candidate.frame, r.committed | |||
| 70 | } else { | 86 | } else { | ||
| 71 | - | p.candidate.Commit(&committed) | 87 | + | p.candidate.Commit(&r.committed) |
| 72 | } | 88 | } | ||
| 73 | - | p.renderer.setCommittedFrame(committed) | 89 | + | r.width = r.committed.Width |
| 90 | + | r.height = r.committed.Height | |||
| 91 | + | r.hasCommitted = true | |||
| 74 | }) | 92 | }) | ||
| 75 | } | 93 | } | ||
| 76 | 94 | ||||
| 77 | // Prepare plans and encodes a transactional draw. The renderer advances only | 95 | // Prepare plans and encodes a transactional draw. The renderer advances only | ||
| 78 | // when the returned draw is committed. Keep at most one prepared draw | 96 | // when the returned draw is committed. Keep at most one prepared draw | ||
| 79 | - | // outstanding; commit or discard it before calling Prepare again. | 97 | + | // outstanding; commit or discard it before calling Prepare again. A draw |
| 98 | + | // superseded by a later Prepare is discarded and its Commit does nothing. | |||
| 99 | + | // | |||
| 100 | + | // The renderer copies frame into reusable private storage, so the caller may | |||
| 101 | + | // keep mutating frame between draws. | |||
| 80 | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { | 102 | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { | ||
| 81 | var candidate DeltaCandidate | 103 | var candidate DeltaCandidate | ||
| 82 | var err error | 104 | var err error | ||
| 83 | if err = validateCellSource(frame); err != nil { | 105 | if err = validateCellSource(frame); err != nil { | ||
| 84 | return PreparedDraw{}, err | 106 | return PreparedDraw{}, err | ||
| 85 | } | 107 | } | ||
| 108 | + | // Any earlier prepared draw loses its claim on the scratch snapshot, even | |||
| 109 | + | // if this Prepare fails after partially rewriting it. | |||
| 110 | + | r.generation++ | |||
| 86 | columns, rows := frame.Columns(), frame.Rows() | 111 | columns, rows := frame.Columns(), frame.Rows() | ||
| 87 | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | 112 | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | ||
| 88 | plan := planSingleDamage(frame, damage[0]) | 113 | plan := planSingleDamage(frame, damage[0]) | ||
| 89 | - | candidate = DeltaCandidate{Plan: plan} | 114 | + | candidate = newDeltaCandidate(frame, plan, &r.scratch) |
| 90 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |||
| 91 | - | candidate.frame = cloneCellSource(frame) | |||
| 92 | - | } | |||
| 93 | } else { | 115 | } else { | ||
| 94 | - | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted) | 116 | + | candidate, err = planDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted, &r.scratch) |
| 95 | } | 117 | } | ||
| 96 | if err != nil { | 118 | if err != nil { | ||
| 97 | return PreparedDraw{}, err | 119 | return PreparedDraw{}, err | ||
| 98 | } | 120 | } | ||
| 99 | - | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} | 121 | + | prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)} |
| 100 | plan := candidate.Plan | 122 | plan := candidate.Plan | ||
| 101 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | 123 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | ||
| 102 | return prepared, nil | 124 | return prepared, nil | ||