detached cc11117

fix(core): release CopyFrom storage when the source shrinks

CopyFrom replaces a destination page more than four times larger than the
source with a clone, so a renderer scratch frame no longer pins its largest
size after a resize. Document the CopyFrom aliasing rule and RowsEqualAt in
the ownership guide, merge the duplicate RowsEqualAt tests, flatten the
payload index reset and fix two misplaced test comments.
commit
cc11117e532f5564e0d2f5c2cfc88bb4fa4c298e
author
brice · · 2026-10-02 16:09 UTC
parent
a41ec87
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +47 -37
M core/frame_copy.go +15 -16
@@ -5,12 +5,14 @@ import "maps"
55 // CopyFrom makes f an independent structural copy of src, like Replace, but
66 // reuses f's existing page storage (cells, row descriptors, style and payload
77 // tables and dictionaries) when f already owns a page. It therefore performs no
8-// allocation once f's page has grown to src's size.
8+// allocation once f's page has grown to src's size. A page more than four times
9+// larger than src is replaced by a clone, so shrinking a terminal releases the
10+// old storage.
911 //
1012 // Page-local style and payload IDs and physical row rotation are copied
11-// verbatim, exactly as Clone does. Every Frame value sharing f's page observes
12-// the overwrite. An invalid or empty source leaves f unchanged. Copying a frame
13-// onto itself only normalizes the dimensions.
13+// verbatim, exactly as Clone does. When the page is reused, every Frame value
14+// sharing f's page observes the overwrite. An invalid or empty source leaves f
15+// unchanged. Copying a frame onto itself only normalizes the dimensions.
1416 func (f *Frame) CopyFrom(src Frame) {
1517 if f == nil {
1618 return
@@ -18,15 +20,16 @@ func (f *Frame) CopyFrom(src Frame) {
1820 if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
1921 return
2022 }
21- if f.page == nil {
23+ if f.page == src.page {
24+ f.Width, f.Height = src.Width, src.Height
25+ return
26+ }
27+ if f.page == nil || cap(f.page.cells) > 4*len(src.page.cells) {
2228 *f = src.Clone()
2329 return
2430 }
2531 f.Width, f.Height = src.Width, src.Height
2632 dst, from := f.page, src.page
27- if dst == from {
28- return
29- }
3033 dst.cells = append(dst.cells[:0], from.cells...)
3134 dst.rows = append(dst.rows[:0], from.rows...)
3235 dst.styles = append(dst.styles[:0], from.styles...)
@@ -45,16 +48,12 @@ func (f *Frame) CopyFrom(src Frame) {
4548 dst.payloads = append(dst.payloads[:0], from.payloads...)
4649 dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
4750 dst.payloadBytes = from.payloadBytes
48- if len(from.payloadIndex) == 0 {
49- clear(dst.payloadIndex)
51+ if dst.payloadIndex == nil && len(from.payloadIndex) > 0 {
52+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
5053 } else {
51- if dst.payloadIndex == nil {
52- dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
53- } else {
54- clear(dst.payloadIndex)
55- }
56- maps.Copy(dst.payloadIndex, from.payloadIndex)
54+ clear(dst.payloadIndex)
5755 }
56+ maps.Copy(dst.payloadIndex, from.payloadIndex)
5857 }
5958
6059 // RowsEqualAt reports whether logical row ya of a and logical row yb of b hold
@@ -5,12 +5,14 @@ import "maps"
5 // CopyFrom makes f an independent structural copy of src, like Replace, but5 // CopyFrom makes f an independent structural copy of src, like Replace, but
6 // reuses f's existing page storage (cells, row descriptors, style and payload6 // reuses f's existing page storage (cells, row descriptors, style and payload
7 // tables and dictionaries) when f already owns a page. It therefore performs no7 // tables and dictionaries) when f already owns a page. It therefore performs no
8-// allocation once f's page has grown to src's size.8+// allocation once f's page has grown to src's size. A page more than four times
9+// larger than src is replaced by a clone, so shrinking a terminal releases the
10+// old storage.
9 //11 //
10 // Page-local style and payload IDs and physical row rotation are copied12 // Page-local style and payload IDs and physical row rotation are copied
11-// verbatim, exactly as Clone does. Every Frame value sharing f's page observes13+// verbatim, exactly as Clone does. When the page is reused, every Frame value
12-// the overwrite. An invalid or empty source leaves f unchanged. Copying a frame14+// sharing f's page observes the overwrite. An invalid or empty source leaves f
13-// onto itself only normalizes the dimensions.15+// unchanged. Copying a frame onto itself only normalizes the dimensions.
14 func (f *Frame) CopyFrom(src Frame) {16 func (f *Frame) CopyFrom(src Frame) {
15 if f == nil {17 if f == nil {
16 return18 return
@@ -18,15 +20,16 @@ func (f *Frame) CopyFrom(src Frame) {
18 if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {20 if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
19 return21 return
20 }22 }
21- if f.page == nil {23+ if f.page == src.page {
24+ f.Width, f.Height = src.Width, src.Height
25+ return
26+ }
27+ if f.page == nil || cap(f.page.cells) > 4*len(src.page.cells) {
22 *f = src.Clone()28 *f = src.Clone()
23 return29 return
24 }30 }
25 f.Width, f.Height = src.Width, src.Height31 f.Width, f.Height = src.Width, src.Height
26 dst, from := f.page, src.page32 dst, from := f.page, src.page
27- if dst == from {
28- return
29- }
30 dst.cells = append(dst.cells[:0], from.cells...)33 dst.cells = append(dst.cells[:0], from.cells...)
31 dst.rows = append(dst.rows[:0], from.rows...)34 dst.rows = append(dst.rows[:0], from.rows...)
32 dst.styles = append(dst.styles[:0], from.styles...)35 dst.styles = append(dst.styles[:0], from.styles...)
@@ -45,16 +48,12 @@ func (f *Frame) CopyFrom(src Frame) {
45 dst.payloads = append(dst.payloads[:0], from.payloads...)48 dst.payloads = append(dst.payloads[:0], from.payloads...)
46 dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)49 dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
47 dst.payloadBytes = from.payloadBytes50 dst.payloadBytes = from.payloadBytes
48- if len(from.payloadIndex) == 0 {51+ if dst.payloadIndex == nil && len(from.payloadIndex) > 0 {
49- clear(dst.payloadIndex)52+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
50 } else {53 } else {
51- if dst.payloadIndex == nil {54+ clear(dst.payloadIndex)
52- dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
53- } else {
54- clear(dst.payloadIndex)
55- }
56- maps.Copy(dst.payloadIndex, from.payloadIndex)
57 }55 }
56+ maps.Copy(dst.payloadIndex, from.payloadIndex)
58 }57 }
59 58
60 // RowsEqualAt reports whether logical row ya of a and logical row yb of b hold59 // RowsEqualAt reports whether logical row ya of a and logical row yb of b hold
M core/frame_copy_test.go +25 -17
@@ -122,7 +122,21 @@ func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
122122 }
123123 }
124124
125-func TestRowsEqual(t *testing.T) {
125+func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) {
126+ dst := copyTestFrame(t, 240, 80, 'a')
127+ small := copyTestFrame(t, 20, 5, 'b')
128+ dst.CopyFrom(small)
129+ if got := cap(dst.page.cells); got > 4*len(small.page.cells) {
130+ t.Fatalf("CopyFrom kept %d cells for a %d-cell source", got, len(small.page.cells))
131+ }
132+ for y := range small.Height {
133+ if !RowsEqualAt(dst, y, small, y) {
134+ t.Fatalf("row %d differs after shrinking copy", y)
135+ }
136+ }
137+}
138+
139+func TestRowsEqualAt(t *testing.T) {
126140 a := copyTestFrame(t, 10, 4, 'a')
127141 b := a.Clone()
128142 for y := range a.Height {
@@ -195,13 +209,22 @@ func TestRowsEqual(t *testing.T) {
195209 t.Fatal("equivalent styles reported different")
196210 }
197211
212+ // A row compares equal at a different index after scrolling.
213+ shifted := a.Clone()
214+ shifted.ScrollUp(0, a.Height-1, 1)
215+ for y := range a.Height - 1 {
216+ if !RowsEqualAt(a, y+1, shifted, y) {
217+ t.Fatalf("row %d of a != row %d of scrolled copy", y+1, y)
218+ }
219+ }
220+
198221 // Mismatched shape or range is unequal and never panics.
199222 if RowsEqualAt(a, 0, NewFrame(a.Width+1, a.Height), 0) || RowsEqualAt(a, -1, b, -1) || RowsEqualAt(a, a.Height, b, a.Height) || RowsEqualAt(Frame{}, 0, Frame{}, 0) {
200223 t.Fatal("invalid comparison reported equal")
201224 }
202225 }
203226
204-func TestRowsEqualDoesNotAllocate(t *testing.T) {
227+func TestRowsEqualAtDoesNotAllocate(t *testing.T) {
205228 a := copyTestFrame(t, 80, 4, 'a')
206229 b := a.Clone()
207230 if n := testing.AllocsPerRun(20, func() { RowsEqualAt(a, 2, b, 2) }); n != 0 {
@@ -283,21 +306,6 @@ func TestCopyFromDropsStaleStyleCache(t *testing.T) {
283306 }
284307 }
285308
286-func TestRowsEqualAt(t *testing.T) {
287- a := copyTestFrame(t, 10, 5, 'a')
288- b := a.Clone()
289- // b row y+1 shifted: compare a row y+1 against b row y after scrolling b.
290- b.ScrollUp(0, 4, 1)
291- for y := range 4 {
292- if !RowsEqualAt(a, y+1, b, y) {
293- t.Fatalf("row %d of a != row %d of scrolled b", y+1, y)
294- }
295- }
296- if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(a, 0, b, 5) || RowsEqualAt(a, -1, b, 0) {
297- t.Fatal("unequal or out-of-range rows reported equal")
298- }
299-}
300-
301309 func TestRowsEqualAtRejectsCorruptPayloadIDs(t *testing.T) {
302310 a := copyTestFrame(t, 4, 1, 'a')
303311 b := a.Clone()
@@ -122,7 +122,21 @@ func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
122 }122 }
123 }123 }
124 124
125-func TestRowsEqual(t *testing.T) {125+func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) {
126+ dst := copyTestFrame(t, 240, 80, 'a')
127+ small := copyTestFrame(t, 20, 5, 'b')
128+ dst.CopyFrom(small)
129+ if got := cap(dst.page.cells); got > 4*len(small.page.cells) {
130+ t.Fatalf("CopyFrom kept %d cells for a %d-cell source", got, len(small.page.cells))
131+ }
132+ for y := range small.Height {
133+ if !RowsEqualAt(dst, y, small, y) {
134+ t.Fatalf("row %d differs after shrinking copy", y)
135+ }
136+ }
137+}
138+
139+func TestRowsEqualAt(t *testing.T) {
126 a := copyTestFrame(t, 10, 4, 'a')140 a := copyTestFrame(t, 10, 4, 'a')
127 b := a.Clone()141 b := a.Clone()
128 for y := range a.Height {142 for y := range a.Height {
@@ -195,13 +209,22 @@ func TestRowsEqual(t *testing.T) {
195 t.Fatal("equivalent styles reported different")209 t.Fatal("equivalent styles reported different")
196 }210 }
197 211
212+ // A row compares equal at a different index after scrolling.
213+ shifted := a.Clone()
214+ shifted.ScrollUp(0, a.Height-1, 1)
215+ for y := range a.Height - 1 {
216+ if !RowsEqualAt(a, y+1, shifted, y) {
217+ t.Fatalf("row %d of a != row %d of scrolled copy", y+1, y)
218+ }
219+ }
220+
198 // Mismatched shape or range is unequal and never panics.221 // Mismatched shape or range is unequal and never panics.
199 if RowsEqualAt(a, 0, NewFrame(a.Width+1, a.Height), 0) || RowsEqualAt(a, -1, b, -1) || RowsEqualAt(a, a.Height, b, a.Height) || RowsEqualAt(Frame{}, 0, Frame{}, 0) {222 if RowsEqualAt(a, 0, NewFrame(a.Width+1, a.Height), 0) || RowsEqualAt(a, -1, b, -1) || RowsEqualAt(a, a.Height, b, a.Height) || RowsEqualAt(Frame{}, 0, Frame{}, 0) {
200 t.Fatal("invalid comparison reported equal")223 t.Fatal("invalid comparison reported equal")
201 }224 }
202 }225 }
203 226
204-func TestRowsEqualDoesNotAllocate(t *testing.T) {227+func TestRowsEqualAtDoesNotAllocate(t *testing.T) {
205 a := copyTestFrame(t, 80, 4, 'a')228 a := copyTestFrame(t, 80, 4, 'a')
206 b := a.Clone()229 b := a.Clone()
207 if n := testing.AllocsPerRun(20, func() { RowsEqualAt(a, 2, b, 2) }); n != 0 {230 if n := testing.AllocsPerRun(20, func() { RowsEqualAt(a, 2, b, 2) }); n != 0 {
@@ -283,21 +306,6 @@ func TestCopyFromDropsStaleStyleCache(t *testing.T) {
283 }306 }
284 }307 }
285 308
286-func TestRowsEqualAt(t *testing.T) {
287- a := copyTestFrame(t, 10, 5, 'a')
288- b := a.Clone()
289- // b row y+1 shifted: compare a row y+1 against b row y after scrolling b.
290- b.ScrollUp(0, 4, 1)
291- for y := range 4 {
292- if !RowsEqualAt(a, y+1, b, y) {
293- t.Fatalf("row %d of a != row %d of scrolled b", y+1, y)
294- }
295- }
296- if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(a, 0, b, 5) || RowsEqualAt(a, -1, b, 0) {
297- t.Fatal("unequal or out-of-range rows reported equal")
298- }
299-}
300-
301 func TestRowsEqualAtRejectsCorruptPayloadIDs(t *testing.T) {309 func TestRowsEqualAtRejectsCorruptPayloadIDs(t *testing.T) {
302 a := copyTestFrame(t, 4, 1, 'a')310 a := copyTestFrame(t, 4, 1, 'a')
303 b := a.Clone()311 b := a.Clone()
M docs/ownership.md +3 -1
@@ -19,7 +19,9 @@ should use these methods rather than assuming a particular memory layout.
1919 Rows returned by public extraction methods are copies. For a writable
2020 `core.Frame`, use `Set`, `WriteRow`, `FillRow`, `CopyRow` and the scroll operations.
2121 A plain Go assignment of a `Frame` shares its storage; use `Clone()` when you need
22-an independent grid.
22+an independent grid. `CopyFrom` reuses the destination's storage, so every
23+`Frame` assigned from it sees the overwrite: call it only on a frame you own
24+exclusively. `core.RowsEqualAt` compares rows of two frames without copying.
2325
2426 ## Callbacks
2527
@@ -19,7 +19,9 @@ should use these methods rather than assuming a particular memory layout.
19 Rows returned by public extraction methods are copies. For a writable19 Rows returned by public extraction methods are copies. For a writable
20 `core.Frame`, use `Set`, `WriteRow`, `FillRow`, `CopyRow` and the scroll operations.20 `core.Frame`, use `Set`, `WriteRow`, `FillRow`, `CopyRow` and the scroll operations.
21 A plain Go assignment of a `Frame` shares its storage; use `Clone()` when you need21 A plain Go assignment of a `Frame` shares its storage; use `Clone()` when you need
22-an independent grid.22+an independent grid. `CopyFrom` reuses the destination's storage, so every
23+`Frame` assigned from it sees the overwrite: call it only on a frame you own
24+exclusively. `core.RowsEqualAt` compares rows of two frames without copying.
23 25
24 ## Callbacks26 ## Callbacks
25 27
M screen_evict_scratch_test.go +2 -1
@@ -7,7 +7,8 @@ import (
77 )
88
99 // OnLineEvicted callers may retain the slice: successive evictions must not
10-// share the Screen's internal scratch row.
10+// share the Screen's internal scratch row. The oracle checks the same rule
11+// across scenarios but is skipped under -short; this is the -short guard.
1112 func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) {
1213 s := NewScreen(4, 2)
1314 var rows [][]renderer.Cell
@@ -7,7 +7,8 @@ import (
7 )7 )
8 8
9 // OnLineEvicted callers may retain the slice: successive evictions must not9 // OnLineEvicted callers may retain the slice: successive evictions must not
10-// share the Screen's internal scratch row.10+// share the Screen's internal scratch row. The oracle checks the same rule
11+// across scenarios but is skipped under -short; this is the -short guard.
11 func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) {12 func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) {
12 s := NewScreen(4, 2)13 s := NewScreen(4, 2)
13 var rows [][]renderer.Cell14 var rows [][]renderer.Cell
M screen_graphics_test.go +2 -2
@@ -407,8 +407,6 @@ func TestScreenWriteReentrantFromCallbackKeepsOuterStreamIntact(t *testing.T) {
407407 })
408408 }
409409
410-// The Screen borrows the caller's Write buffer while parsing; nothing it
411-// retains may alias that buffer once Write returns.
412410 // Base64 payloads padded with CR/LF decode into a buffer sized for the raw
413411 // input. The scene must not retain that slack beyond its accounted bytes,
414412 // otherwise untrusted output could hold memory past MaxEncodedBytes.
@@ -434,6 +432,8 @@ func TestScreenKittyNewlinePaddedPayloadRetainsOnlyAccountedBytes(t *testing.T)
434432 runtime.KeepAlive(screen)
435433 }
436434
435+// The Screen borrows the caller's Write buffer while parsing; nothing it
436+// retains may alias that buffer once Write returns.
437437 func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {
438438 scribble := func(b []byte) {
439439 for i := range b {
@@ -407,8 +407,6 @@ func TestScreenWriteReentrantFromCallbackKeepsOuterStreamIntact(t *testing.T) {
407 })407 })
408 }408 }
409 409
410-// The Screen borrows the caller's Write buffer while parsing; nothing it
411-// retains may alias that buffer once Write returns.
412 // Base64 payloads padded with CR/LF decode into a buffer sized for the raw410 // Base64 payloads padded with CR/LF decode into a buffer sized for the raw
413 // input. The scene must not retain that slack beyond its accounted bytes,411 // input. The scene must not retain that slack beyond its accounted bytes,
414 // otherwise untrusted output could hold memory past MaxEncodedBytes.412 // otherwise untrusted output could hold memory past MaxEncodedBytes.
@@ -434,6 +432,8 @@ func TestScreenKittyNewlinePaddedPayloadRetainsOnlyAccountedBytes(t *testing.T)
434 runtime.KeepAlive(screen)432 runtime.KeepAlive(screen)
435 }433 }
436 434
435+// The Screen borrows the caller's Write buffer while parsing; nothing it
436+// retains may alias that buffer once Write returns.
437 func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {437 func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {
438 scribble := func(b []byte) {438 scribble := func(b []byte) {
439 for i := range b {439 for i := range b {

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close