fix(vt): preserve row identities through restore
- commit
- cc001e48951f6aa223f263240eb3e1f6a4d086bd
- author
- brice · · 2026-08-02 20:53 UTC
- committer
- brice · · 2026-08-03 17:54 UTC
- parent
- 7560131
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +195 -61
M internal/usecase/copy/mode.go +18 -4
| @@ -25,14 +25,28 @@ type Snapshot struct { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot { | |
| 28 | - | var history vt.HistoryView | |
| 29 | - | if historySource != nil { | |
| 30 | - | history = historySource.SealAndView() | |
| 31 | - | } | |
| 32 | 28 | var screenRowIDs []vt.RowID | |
| 33 | 29 | if len(rowIDs) > 0 { | |
| 34 | 30 | screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...) | |
| 35 | 31 | } | |
| 32 | + | return newSnapshot(historySource, screen, bounds, screenRowIDs) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // NewSnapshotFromScreen captures a screen while its owner holds the Screen | |
| 36 | + | // lock. Screen.RowIDs already returns owned storage, so the snapshot can take | |
| 37 | + | // that copy directly without allocating a duplicate metadata slice. | |
| 38 | + | func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot { | |
| 39 | + | if screen == nil { | |
| 40 | + | return NewSnapshot(historySource, renderer.Frame{}, nil) | |
| 41 | + | } | |
| 42 | + | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot { | |
| 46 | + | var history vt.HistoryView | |
| 47 | + | if historySource != nil { | |
| 48 | + | history = historySource.SealAndView() | |
| 49 | + | } | |
| 36 | 50 | return Snapshot{ | |
| 37 | 51 | history: history, | |
| 38 | 52 | screen: screen.Clone(), | |
| @@ -25,14 +25,28 @@ type Snapshot struct { | |||||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot { | 27 | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot { | ||
| 28 | - | var history vt.HistoryView | |||
| 29 | - | if historySource != nil { | |||
| 30 | - | history = historySource.SealAndView() | |||
| 31 | - | } | |||
| 32 | var screenRowIDs []vt.RowID | 28 | var screenRowIDs []vt.RowID | ||
| 33 | if len(rowIDs) > 0 { | 29 | if len(rowIDs) > 0 { | ||
| 34 | screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...) | 30 | screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...) | ||
| 35 | } | 31 | } | ||
| 32 | + | return newSnapshot(historySource, screen, bounds, screenRowIDs) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // NewSnapshotFromScreen captures a screen while its owner holds the Screen | |||
| 36 | + | // lock. Screen.RowIDs already returns owned storage, so the snapshot can take | |||
| 37 | + | // that copy directly without allocating a duplicate metadata slice. | |||
| 38 | + | func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot { | |||
| 39 | + | if screen == nil { | |||
| 40 | + | return NewSnapshot(historySource, renderer.Frame{}, nil) | |||
| 41 | + | } | |||
| 42 | + | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot { | |||
| 46 | + | var history vt.HistoryView | |||
| 47 | + | if historySource != nil { | |||
| 48 | + | history = historySource.SealAndView() | |||
| 49 | + | } | |||
| 36 | return Snapshot{ | 50 | return Snapshot{ | ||
| 37 | history: history, | 51 | history: history, | ||
| 38 | screen: screen.Clone(), | 52 | screen: screen.Clone(), | ||
M internal/usecase/daemon/copy_mouse_input.go +1 -1
| @@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab | |||
| 68 | 68 | geometry.pane.mu.Lock() | |
| 69 | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 70 | 70 | altScreen := geometry.pane.screen.AltScreenActive() | |
| 71 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds(), geometry.pane.screen.RowIDs()) | |
| 71 | + | snapshot := scopy.NewSnapshotFromScreen(geometry.pane.history, geometry.pane.screen) | |
| 72 | 72 | geometry.pane.mu.Unlock() | |
| 73 | 73 | if mouseMode != 0 || altScreen { | |
| 74 | 74 | return false // child forwarding retains its existing raw-byte path. | |
| @@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab | |||||
| 68 | geometry.pane.mu.Lock() | 68 | geometry.pane.mu.Lock() | ||
| 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | ||
| 70 | altScreen := geometry.pane.screen.AltScreenActive() | 70 | altScreen := geometry.pane.screen.AltScreenActive() | ||
| 71 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds(), geometry.pane.screen.RowIDs()) | 71 | + | snapshot := scopy.NewSnapshotFromScreen(geometry.pane.history, geometry.pane.screen) |
| 72 | geometry.pane.mu.Unlock() | 72 | geometry.pane.mu.Unlock() | ||
| 73 | if mouseMode != 0 || altScreen { | 73 | if mouseMode != 0 || altScreen { | ||
| 74 | return false // child forwarding retains its existing raw-byte path. | 74 | return false // child forwarding retains its existing raw-byte path. | ||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 74 | 74 | return | |
| 75 | 75 | } | |
| 76 | 76 | p.mu.Lock() | |
| 77 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), p.screen.RowIDs()) | |
| 77 | + | snapshot := scopy.NewSnapshotFromScreen(p.history, p.screen) | |
| 78 | 78 | p.mu.Unlock() | |
| 79 | 79 | cfg := d.currentCopyConfig() | |
| 80 | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 74 | return | 74 | return | ||
| 75 | } | 75 | } | ||
| 76 | p.mu.Lock() | 76 | p.mu.Lock() | ||
| 77 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), p.screen.RowIDs()) | 77 | + | snapshot := scopy.NewSnapshotFromScreen(p.history, p.screen) |
| 78 | p.mu.Unlock() | 78 | p.mu.Unlock() | ||
| 79 | cfg := d.currentCopyConfig() | 79 | cfg := d.currentCopyConfig() | ||
| 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | ||
M pkg/vt/buffer.go +26 -3
| @@ -260,7 +260,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | points := b.cursorReflowPoints(active, saved) | |
| 263 | - | rows := b.layoutReflow(width, &points, nil, 0, nil, nil) | |
| 263 | + | rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil) | |
| 264 | 264 | anchor := 0 | |
| 265 | 265 | if active != nil { | |
| 266 | 266 | anchor = points[0].row | |
| @@ -283,7 +283,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||
| 283 | 283 | evicted[y] = evictedCells[y*width : (y+1)*width] | |
| 284 | 284 | } | |
| 285 | 285 | } | |
| 286 | - | b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds) | |
| 286 | + | b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs) | |
| 287 | 287 | for i, cur := range [2]*bufferCursor{active, saved} { | |
| 288 | 288 | if cur != nil { | |
| 289 | 289 | cur.row = clamp(points[i].row-shift, 0, height-1) | |
| @@ -297,8 +297,21 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||
| 297 | 297 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| 298 | 298 | // [shift, shift+dst.height). Rows before shift are written straight into the | |
| 299 | 299 | // contiguous eviction backing store. The return is the logical output height. | |
| 300 | - | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int { | |
| 300 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, sourceIDs []RowID, evicted []renderer.Cell, evictedBounds []LineBound, evictedIDs []RowID) int { | |
| 301 | 301 | row, col := 0, 0 | |
| 302 | + | setRowID := func(outputRow int, id RowID) { | |
| 303 | + | if id == 0 { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | switch { | |
| 307 | + | case outputRow < shift: | |
| 308 | + | if outputRow < len(evictedIDs) { | |
| 309 | + | evictedIDs[outputRow] = id | |
| 310 | + | } | |
| 311 | + | case dst != nil && outputRow < shift+dst.frame.Height: | |
| 312 | + | dst.rowIDs[outputRow-shift] = id | |
| 313 | + | } | |
| 314 | + | } | |
| 302 | 315 | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 303 | 316 | if outputRow < shift { | |
| 304 | 317 | out := evicted[outputRow*width : (outputRow+1)*width] | |
| @@ -356,6 +369,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 356 | 369 | } | |
| 357 | 370 | reflow := end > start | |
| 358 | 371 | offset := 0 | |
| 372 | + | sourceID := RowID(0) | |
| 373 | + | if start < len(sourceIDs) { | |
| 374 | + | sourceID = sourceIDs[start] | |
| 375 | + | } | |
| 376 | + | // A source row that starts in a partially filled output row has already | |
| 377 | + | // inherited that row's identity. Continuation rows created by wrapping | |
| 378 | + | // receive fresh IDs from the owning Screen after this pass. | |
| 379 | + | if col == 0 { | |
| 380 | + | setRowID(row, sourceID) | |
| 381 | + | } | |
| 359 | 382 | truncated := false | |
| 360 | 383 | for y := start; y <= end && !truncated; y++ { | |
| 361 | 384 | cells := b.frame.Row(y) | |
| @@ -260,7 +260,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||||
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | points := b.cursorReflowPoints(active, saved) | 262 | points := b.cursorReflowPoints(active, saved) | ||
| 263 | - | rows := b.layoutReflow(width, &points, nil, 0, nil, nil) | 263 | + | rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil) |
| 264 | anchor := 0 | 264 | anchor := 0 | ||
| 265 | if active != nil { | 265 | if active != nil { | ||
| 266 | anchor = points[0].row | 266 | anchor = points[0].row | ||
| @@ -283,7 +283,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||||
| 283 | evicted[y] = evictedCells[y*width : (y+1)*width] | 283 | evicted[y] = evictedCells[y*width : (y+1)*width] | ||
| 284 | } | 284 | } | ||
| 285 | } | 285 | } | ||
| 286 | - | b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds) | 286 | + | b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs) |
| 287 | for i, cur := range [2]*bufferCursor{active, saved} { | 287 | for i, cur := range [2]*bufferCursor{active, saved} { | ||
| 288 | if cur != nil { | 288 | if cur != nil { | ||
| 289 | cur.row = clamp(points[i].row-shift, 0, height-1) | 289 | cur.row = clamp(points[i].row-shift, 0, height-1) | ||
| @@ -297,8 +297,21 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||||
| 297 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | 297 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | ||
| 298 | // [shift, shift+dst.height). Rows before shift are written straight into the | 298 | // [shift, shift+dst.height). Rows before shift are written straight into the | ||
| 299 | // contiguous eviction backing store. The return is the logical output height. | 299 | // contiguous eviction backing store. The return is the logical output height. | ||
| 300 | - | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int { | 300 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, sourceIDs []RowID, evicted []renderer.Cell, evictedBounds []LineBound, evictedIDs []RowID) int { |
| 301 | row, col := 0, 0 | 301 | row, col := 0, 0 | ||
| 302 | + | setRowID := func(outputRow int, id RowID) { | |||
| 303 | + | if id == 0 { | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | switch { | |||
| 307 | + | case outputRow < shift: | |||
| 308 | + | if outputRow < len(evictedIDs) { | |||
| 309 | + | evictedIDs[outputRow] = id | |||
| 310 | + | } | |||
| 311 | + | case dst != nil && outputRow < shift+dst.frame.Height: | |||
| 312 | + | dst.rowIDs[outputRow-shift] = id | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 302 | blankEvicted := func(outputRow int) []renderer.Cell { | 315 | blankEvicted := func(outputRow int) []renderer.Cell { | ||
| 303 | if outputRow < shift { | 316 | if outputRow < shift { | ||
| 304 | out := evicted[outputRow*width : (outputRow+1)*width] | 317 | out := evicted[outputRow*width : (outputRow+1)*width] | ||
| @@ -356,6 +369,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 356 | } | 369 | } | ||
| 357 | reflow := end > start | 370 | reflow := end > start | ||
| 358 | offset := 0 | 371 | offset := 0 | ||
| 372 | + | sourceID := RowID(0) | |||
| 373 | + | if start < len(sourceIDs) { | |||
| 374 | + | sourceID = sourceIDs[start] | |||
| 375 | + | } | |||
| 376 | + | // A source row that starts in a partially filled output row has already | |||
| 377 | + | // inherited that row's identity. Continuation rows created by wrapping | |||
| 378 | + | // receive fresh IDs from the owning Screen after this pass. | |||
| 379 | + | if col == 0 { | |||
| 380 | + | setRowID(row, sourceID) | |||
| 381 | + | } | |||
| 359 | truncated := false | 382 | truncated := false | ||
| 360 | for y := start; y <= end && !truncated; y++ { | 383 | for y := start; y <= end && !truncated; y++ { | ||
| 361 | cells := b.frame.Row(y) | 384 | cells := b.frame.Row(y) | ||
M pkg/vt/history.go +29 -21
| @@ -3,6 +3,7 @@ package vt | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 9 | ) | |
| @@ -107,7 +108,11 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err | |||
| 107 | 108 | id = ids[0] | |
| 108 | 109 | } | |
| 109 | 110 | if id == 0 || h.hasRowID(id) { | |
| 110 | - | id = h.allocateRowID() | |
| 111 | + | var err error | |
| 112 | + | id, err = h.allocateRowID() | |
| 113 | + | if err != nil { | |
| 114 | + | return err | |
| 115 | + | } | |
| 111 | 116 | } | |
| 112 | 117 | return h.appendRow(row, bound, id) | |
| 113 | 118 | } | |
| @@ -118,17 +123,20 @@ func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) | |||
| 118 | 123 | if h == nil || h.maxRows == 0 { | |
| 119 | 124 | return nil | |
| 120 | 125 | } | |
| 121 | - | if id == 0 || id == ^RowID(0) || h.hasRowID(id) { | |
| 126 | + | if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) { | |
| 122 | 127 | return errInvalidHistoryRowID | |
| 123 | 128 | } | |
| 124 | 129 | return h.appendRow(row, bound, id) | |
| 125 | 130 | } | |
| 126 | 131 | ||
| 127 | 132 | func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error { | |
| 133 | + | if h.nextRowID == ^RowID(0) { | |
| 134 | + | return errInvalidHistoryRowID | |
| 135 | + | } | |
| 128 | 136 | if len(row) > h.maxCells { | |
| 129 | 137 | return ErrHistoryRowTooWide | |
| 130 | 138 | } | |
| 131 | - | if id == ^RowID(0) { | |
| 139 | + | if id >= ^RowID(0)-1 { | |
| 132 | 140 | return errInvalidHistoryRowID | |
| 133 | 141 | } | |
| 134 | 142 | if id >= h.nextRowID { | |
| @@ -148,38 +156,31 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro | |||
| 148 | 156 | return nil | |
| 149 | 157 | } | |
| 150 | 158 | ||
| 151 | - | func (h *History) allocateRowID() RowID { | |
| 159 | + | func (h *History) allocateRowID() (RowID, error) { | |
| 152 | 160 | if h.nextRowID == 0 { | |
| 153 | 161 | h.nextRowID = 1 | |
| 154 | 162 | } | |
| 155 | 163 | for h.hasRowID(h.nextRowID) { | |
| 156 | - | if h.nextRowID == ^RowID(0) { | |
| 157 | - | panic("vt: history row ID space exhausted") | |
| 164 | + | if h.nextRowID >= ^RowID(0)-1 { | |
| 165 | + | return 0, errInvalidHistoryRowID | |
| 158 | 166 | } | |
| 159 | 167 | h.nextRowID++ | |
| 160 | 168 | } | |
| 161 | 169 | id := h.nextRowID | |
| 162 | - | if h.nextRowID == ^RowID(0) { | |
| 163 | - | panic("vt: history row ID space exhausted") | |
| 170 | + | if h.nextRowID >= ^RowID(0)-1 { | |
| 171 | + | return 0, errInvalidHistoryRowID | |
| 164 | 172 | } | |
| 165 | 173 | h.nextRowID++ | |
| 166 | - | return id | |
| 174 | + | return id, nil | |
| 167 | 175 | } | |
| 168 | 176 | ||
| 169 | 177 | func (h *History) hasRowID(id RowID) bool { | |
| 170 | 178 | for _, chunk := range h.chunks { | |
| 171 | - | for _, candidate := range chunk.rowIDs { | |
| 172 | - | if candidate == id { | |
| 173 | - | return true | |
| 174 | - | } | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | for _, candidate := range h.tailIDs { | |
| 178 | - | if candidate == id { | |
| 179 | + | if slices.Contains(chunk.rowIDs, id) { | |
| 179 | 180 | return true | |
| 180 | 181 | } | |
| 181 | 182 | } | |
| 182 | - | return false | |
| 183 | + | return slices.Contains(h.tailIDs, id) | |
| 183 | 184 | } | |
| 184 | 185 | ||
| 185 | 186 | // clampBound keeps End inside the row it describes so a persisted bound can be | |
| @@ -209,6 +210,12 @@ func growRowIDs(ids []RowID, rows int) []RowID { | |||
| 209 | 210 | return out | |
| 210 | 211 | } | |
| 211 | 212 | ||
| 213 | + | func cloneRowIDs(ids []RowID, rows int) []RowID { | |
| 214 | + | out := make([]RowID, rows) | |
| 215 | + | copy(out, ids) | |
| 216 | + | return out | |
| 217 | + | } | |
| 218 | + | ||
| 212 | 219 | func (h *History) sealTail() { | |
| 213 | 220 | if len(h.tail) == 0 { | |
| 214 | 221 | return | |
| @@ -307,12 +314,13 @@ func (h *History) View() HistoryView { | |||
| 307 | 314 | if h.rows == 0 { | |
| 308 | 315 | return HistoryView{nextRowID: h.nextRowID} | |
| 309 | 316 | } | |
| 310 | - | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 317 | + | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | |
| 318 | + | copy(chunks, h.chunks) | |
| 311 | 319 | if len(h.tail) > 0 { | |
| 312 | 320 | chunks = append(chunks, &HistoryChunk{ | |
| 313 | 321 | rows: cloneHistoryRows(h.tail), | |
| 314 | 322 | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 315 | - | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | |
| 323 | + | rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |
| 316 | 324 | }) | |
| 317 | 325 | } | |
| 318 | 326 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} | |
| @@ -331,7 +339,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 331 | 339 | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 332 | 340 | tail: cloneHistoryRows(h.tail), | |
| 333 | 341 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 334 | - | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | |
| 342 | + | tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |
| 335 | 343 | rows: h.rows, | |
| 336 | 344 | cells: h.cells, | |
| 337 | 345 | nextRowID: h.nextRowID, | |
| @@ -3,6 +3,7 @@ package vt | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | + | "slices" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| 8 | ) | 9 | ) | ||
| @@ -107,7 +108,11 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err | |||||
| 107 | id = ids[0] | 108 | id = ids[0] | ||
| 108 | } | 109 | } | ||
| 109 | if id == 0 || h.hasRowID(id) { | 110 | if id == 0 || h.hasRowID(id) { | ||
| 110 | - | id = h.allocateRowID() | 111 | + | var err error |
| 112 | + | id, err = h.allocateRowID() | |||
| 113 | + | if err != nil { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 111 | } | 116 | } | ||
| 112 | return h.appendRow(row, bound, id) | 117 | return h.appendRow(row, bound, id) | ||
| 113 | } | 118 | } | ||
| @@ -118,17 +123,20 @@ func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) | |||||
| 118 | if h == nil || h.maxRows == 0 { | 123 | if h == nil || h.maxRows == 0 { | ||
| 119 | return nil | 124 | return nil | ||
| 120 | } | 125 | } | ||
| 121 | - | if id == 0 || id == ^RowID(0) || h.hasRowID(id) { | 126 | + | if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) { |
| 122 | return errInvalidHistoryRowID | 127 | return errInvalidHistoryRowID | ||
| 123 | } | 128 | } | ||
| 124 | return h.appendRow(row, bound, id) | 129 | return h.appendRow(row, bound, id) | ||
| 125 | } | 130 | } | ||
| 126 | 131 | ||||
| 127 | func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error { | 132 | func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error { | ||
| 133 | + | if h.nextRowID == ^RowID(0) { | |||
| 134 | + | return errInvalidHistoryRowID | |||
| 135 | + | } | |||
| 128 | if len(row) > h.maxCells { | 136 | if len(row) > h.maxCells { | ||
| 129 | return ErrHistoryRowTooWide | 137 | return ErrHistoryRowTooWide | ||
| 130 | } | 138 | } | ||
| 131 | - | if id == ^RowID(0) { | 139 | + | if id >= ^RowID(0)-1 { |
| 132 | return errInvalidHistoryRowID | 140 | return errInvalidHistoryRowID | ||
| 133 | } | 141 | } | ||
| 134 | if id >= h.nextRowID { | 142 | if id >= h.nextRowID { | ||
| @@ -148,38 +156,31 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro | |||||
| 148 | return nil | 156 | return nil | ||
| 149 | } | 157 | } | ||
| 150 | 158 | ||||
| 151 | - | func (h *History) allocateRowID() RowID { | 159 | + | func (h *History) allocateRowID() (RowID, error) { |
| 152 | if h.nextRowID == 0 { | 160 | if h.nextRowID == 0 { | ||
| 153 | h.nextRowID = 1 | 161 | h.nextRowID = 1 | ||
| 154 | } | 162 | } | ||
| 155 | for h.hasRowID(h.nextRowID) { | 163 | for h.hasRowID(h.nextRowID) { | ||
| 156 | - | if h.nextRowID == ^RowID(0) { | 164 | + | if h.nextRowID >= ^RowID(0)-1 { |
| 157 | - | panic("vt: history row ID space exhausted") | 165 | + | return 0, errInvalidHistoryRowID |
| 158 | } | 166 | } | ||
| 159 | h.nextRowID++ | 167 | h.nextRowID++ | ||
| 160 | } | 168 | } | ||
| 161 | id := h.nextRowID | 169 | id := h.nextRowID | ||
| 162 | - | if h.nextRowID == ^RowID(0) { | 170 | + | if h.nextRowID >= ^RowID(0)-1 { |
| 163 | - | panic("vt: history row ID space exhausted") | 171 | + | return 0, errInvalidHistoryRowID |
| 164 | } | 172 | } | ||
| 165 | h.nextRowID++ | 173 | h.nextRowID++ | ||
| 166 | - | return id | 174 | + | return id, nil |
| 167 | } | 175 | } | ||
| 168 | 176 | ||||
| 169 | func (h *History) hasRowID(id RowID) bool { | 177 | func (h *History) hasRowID(id RowID) bool { | ||
| 170 | for _, chunk := range h.chunks { | 178 | for _, chunk := range h.chunks { | ||
| 171 | - | for _, candidate := range chunk.rowIDs { | 179 | + | if slices.Contains(chunk.rowIDs, id) { |
| 172 | - | if candidate == id { | |||
| 173 | - | return true | |||
| 174 | - | } | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | for _, candidate := range h.tailIDs { | |||
| 178 | - | if candidate == id { | |||
| 179 | return true | 180 | return true | ||
| 180 | } | 181 | } | ||
| 181 | } | 182 | } | ||
| 182 | - | return false | 183 | + | return slices.Contains(h.tailIDs, id) |
| 183 | } | 184 | } | ||
| 184 | 185 | ||||
| 185 | // clampBound keeps End inside the row it describes so a persisted bound can be | 186 | // clampBound keeps End inside the row it describes so a persisted bound can be | ||
| @@ -209,6 +210,12 @@ func growRowIDs(ids []RowID, rows int) []RowID { | |||||
| 209 | return out | 210 | return out | ||
| 210 | } | 211 | } | ||
| 211 | 212 | ||||
| 213 | + | func cloneRowIDs(ids []RowID, rows int) []RowID { | |||
| 214 | + | out := make([]RowID, rows) | |||
| 215 | + | copy(out, ids) | |||
| 216 | + | return out | |||
| 217 | + | } | |||
| 218 | + | ||||
| 212 | func (h *History) sealTail() { | 219 | func (h *History) sealTail() { | ||
| 213 | if len(h.tail) == 0 { | 220 | if len(h.tail) == 0 { | ||
| 214 | return | 221 | return | ||
| @@ -307,12 +314,13 @@ func (h *History) View() HistoryView { | |||||
| 307 | if h.rows == 0 { | 314 | if h.rows == 0 { | ||
| 308 | return HistoryView{nextRowID: h.nextRowID} | 315 | return HistoryView{nextRowID: h.nextRowID} | ||
| 309 | } | 316 | } | ||
| 310 | - | chunks := append([]*HistoryChunk(nil), h.chunks...) | 317 | + | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) |
| 318 | + | copy(chunks, h.chunks) | |||
| 311 | if len(h.tail) > 0 { | 319 | if len(h.tail) > 0 { | ||
| 312 | chunks = append(chunks, &HistoryChunk{ | 320 | chunks = append(chunks, &HistoryChunk{ | ||
| 313 | rows: cloneHistoryRows(h.tail), | 321 | rows: cloneHistoryRows(h.tail), | ||
| 314 | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | 322 | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | ||
| 315 | - | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | 323 | + | rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)), |
| 316 | }) | 324 | }) | ||
| 317 | } | 325 | } | ||
| 318 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} | 326 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} | ||
| @@ -331,7 +339,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||||
| 331 | chunks: append([]*HistoryChunk(nil), h.chunks...), | 339 | chunks: append([]*HistoryChunk(nil), h.chunks...), | ||
| 332 | tail: cloneHistoryRows(h.tail), | 340 | tail: cloneHistoryRows(h.tail), | ||
| 333 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | 341 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | ||
| 334 | - | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | 342 | + | tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)), |
| 335 | rows: h.rows, | 343 | rows: h.rows, | ||
| 336 | cells: h.cells, | 344 | cells: h.cells, | ||
| 337 | nextRowID: h.nextRowID, | 345 | nextRowID: h.nextRowID, | ||
M pkg/vt/history_codec.go +3 -3
| @@ -53,7 +53,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 53 | 53 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 54 | 54 | } | |
| 55 | 55 | id := chunk.rowIDs[i] | |
| 56 | - | if id == 0 || id == ^RowID(0) { | |
| 56 | + | if id == 0 || id >= ^RowID(0)-1 { | |
| 57 | 57 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 58 | 58 | } | |
| 59 | 59 | if _, duplicate := seen[id]; duplicate { | |
| @@ -109,7 +109,7 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||
| 109 | 109 | return 0, false | |
| 110 | 110 | } | |
| 111 | 111 | for _, id := range chunk.rowIDs { | |
| 112 | - | if id == 0 || id == ^RowID(0) { | |
| 112 | + | if id == 0 || id >= ^RowID(0)-1 { | |
| 113 | 113 | return 0, false | |
| 114 | 114 | } | |
| 115 | 115 | if _, duplicate := seen[id]; duplicate { | |
| @@ -285,7 +285,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 285 | 285 | } | |
| 286 | 286 | for range rowCount { | |
| 287 | 287 | id, ok := p.uint64() | |
| 288 | - | if !ok || id == 0 || id == ^uint64(0) { | |
| 288 | + | if !ok || id == 0 || id >= uint64(^RowID(0)-1) { | |
| 289 | 289 | return HistoryView{}, historyDecodeStats{}, false | |
| 290 | 290 | } | |
| 291 | 291 | rowID := RowID(id) | |
| @@ -53,7 +53,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||||
| 53 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 53 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 54 | } | 54 | } | ||
| 55 | id := chunk.rowIDs[i] | 55 | id := chunk.rowIDs[i] | ||
| 56 | - | if id == 0 || id == ^RowID(0) { | 56 | + | if id == 0 || id >= ^RowID(0)-1 { |
| 57 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 57 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 58 | } | 58 | } | ||
| 59 | if _, duplicate := seen[id]; duplicate { | 59 | if _, duplicate := seen[id]; duplicate { | ||
| @@ -109,7 +109,7 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||||
| 109 | return 0, false | 109 | return 0, false | ||
| 110 | } | 110 | } | ||
| 111 | for _, id := range chunk.rowIDs { | 111 | for _, id := range chunk.rowIDs { | ||
| 112 | - | if id == 0 || id == ^RowID(0) { | 112 | + | if id == 0 || id >= ^RowID(0)-1 { |
| 113 | return 0, false | 113 | return 0, false | ||
| 114 | } | 114 | } | ||
| 115 | if _, duplicate := seen[id]; duplicate { | 115 | if _, duplicate := seen[id]; duplicate { | ||
| @@ -285,7 +285,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 285 | } | 285 | } | ||
| 286 | for range rowCount { | 286 | for range rowCount { | ||
| 287 | id, ok := p.uint64() | 287 | id, ok := p.uint64() | ||
| 288 | - | if !ok || id == 0 || id == ^uint64(0) { | 288 | + | if !ok || id == 0 || id >= uint64(^RowID(0)-1) { |
| 289 | return HistoryView{}, historyDecodeStats{}, false | 289 | return HistoryView{}, historyDecodeStats{}, false | ||
| 290 | } | 290 | } | ||
| 291 | rowID := RowID(id) | 291 | rowID := RowID(id) | ||
M pkg/vt/recovery_transcript.go +3 -3
| @@ -27,9 +27,9 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||
| 27 | 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | nextRowID := s.nextRowID + 1 | |
| 31 | - | if nextRowID == 0 { | |
| 32 | - | nextRowID = ^RowID(0) | |
| 30 | + | nextRowID := s.nextRowID | |
| 31 | + | if nextRowID < ^RowID(0) { | |
| 32 | + | nextRowID++ | |
| 33 | 33 | } | |
| 34 | 34 | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | |
| 35 | 35 | for _, b := range buffers { | |
| @@ -27,9 +27,9 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||||
| 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | - | nextRowID := s.nextRowID + 1 | 30 | + | nextRowID := s.nextRowID |
| 31 | - | if nextRowID == 0 { | 31 | + | if nextRowID < ^RowID(0) { |
| 32 | - | nextRowID = ^RowID(0) | 32 | + | nextRowID++ |
| 33 | } | 33 | } | ||
| 34 | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | 34 | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | ||
| 35 | for _, b := range buffers { | 35 | for _, b := range buffers { | ||
M pkg/vt/row_id.go +1 -1
| @@ -5,7 +5,7 @@ package vt | |||
| 5 | 5 | type RowID uint64 | |
| 6 | 6 | ||
| 7 | 7 | func (s *Screen) nextRowIDValue() RowID { | |
| 8 | - | if s.nextRowID == ^RowID(0) { | |
| 8 | + | if s.nextRowID >= ^RowID(0)-1 { | |
| 9 | 9 | panic("vt: row ID space exhausted") | |
| 10 | 10 | } | |
| 11 | 11 | s.nextRowID++ | |
| @@ -5,7 +5,7 @@ package vt | |||||
| 5 | type RowID uint64 | 5 | type RowID uint64 | ||
| 6 | 6 | ||||
| 7 | func (s *Screen) nextRowIDValue() RowID { | 7 | func (s *Screen) nextRowIDValue() RowID { | ||
| 8 | - | if s.nextRowID == ^RowID(0) { | 8 | + | if s.nextRowID >= ^RowID(0)-1 { |
| 9 | panic("vt: row ID space exhausted") | 9 | panic("vt: row ID space exhausted") | ||
| 10 | } | 10 | } | ||
| 11 | s.nextRowID++ | 11 | s.nextRowID++ | ||
M pkg/vt/row_id_persistence_test.go +28 -0
| @@ -24,6 +24,7 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||
| 24 | 24 | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | |
| 25 | 25 | {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }}, | |
| 26 | 26 | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | |
| 27 | + | {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }}, | |
| 27 | 28 | } { | |
| 28 | 29 | t.Run(test.name, func(t *testing.T) { | |
| 29 | 30 | data := append([]byte(nil), encoded...) | |
| @@ -36,6 +37,12 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||
| 36 | 37 | } | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 40 | + | func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) { | |
| 41 | + | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |
| 42 | + | history.nextRowID = ^RowID(0) | |
| 43 | + | require.Error(t, history.Append(historyRow("row"), LineBound{End: 3})) | |
| 44 | + | } | |
| 45 | + | ||
| 39 | 46 | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |
| 40 | 47 | _, err := MarshalHistory(HistoryView{ | |
| 41 | 48 | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | |
| @@ -68,6 +75,27 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||
| 68 | 75 | require.Greater(t, screen.RowID(0), before) | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 78 | + | func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) { | |
| 79 | + | sealedView := HistoryView{ | |
| 80 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}}, | |
| 81 | + | rows: 1, | |
| 82 | + | nextRowID: 8, | |
| 83 | + | } | |
| 84 | + | sealed, err := MarshalHistoryChunk(sealedView.Chunk(0)) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | tail, err := MarshalEmptyHistoryTail() | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | transcript, err := MarshalHistory(HistoryView{ | |
| 89 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}}, | |
| 90 | + | rows: 1, | |
| 91 | + | nextRowID: 8, | |
| 92 | + | }) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | ||
| 95 | + | _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript) | |
| 96 | + | require.Error(t, err) | |
| 97 | + | } | |
| 98 | + | ||
| 71 | 99 | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | |
| 72 | 100 | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 73 | 101 | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| @@ -24,6 +24,7 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||||
| 24 | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | 24 | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | ||
| 25 | {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }}, | 25 | {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }}, | ||
| 26 | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | 26 | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | ||
| 27 | + | {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }}, | |||
| 27 | } { | 28 | } { | ||
| 28 | t.Run(test.name, func(t *testing.T) { | 29 | t.Run(test.name, func(t *testing.T) { | ||
| 29 | data := append([]byte(nil), encoded...) | 30 | data := append([]byte(nil), encoded...) | ||
| @@ -36,6 +37,12 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||||
| 36 | } | 37 | } | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| 40 | + | func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) { | |||
| 41 | + | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |||
| 42 | + | history.nextRowID = ^RowID(0) | |||
| 43 | + | require.Error(t, history.Append(historyRow("row"), LineBound{End: 3})) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 39 | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | 46 | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | ||
| 40 | _, err := MarshalHistory(HistoryView{ | 47 | _, err := MarshalHistory(HistoryView{ | ||
| 41 | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | 48 | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | ||
| @@ -68,6 +75,27 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||||
| 68 | require.Greater(t, screen.RowID(0), before) | 75 | require.Greater(t, screen.RowID(0), before) | ||
| 69 | } | 76 | } | ||
| 70 | 77 | ||||
| 78 | + | func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) { | |||
| 79 | + | sealedView := HistoryView{ | |||
| 80 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}}, | |||
| 81 | + | rows: 1, | |||
| 82 | + | nextRowID: 8, | |||
| 83 | + | } | |||
| 84 | + | sealed, err := MarshalHistoryChunk(sealedView.Chunk(0)) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | tail, err := MarshalEmptyHistoryTail() | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | transcript, err := MarshalHistory(HistoryView{ | |||
| 89 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}}, | |||
| 90 | + | rows: 1, | |||
| 91 | + | nextRowID: 8, | |||
| 92 | + | }) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | ||||
| 95 | + | _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript) | |||
| 96 | + | require.Error(t, err) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 71 | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | 99 | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | ||
| 72 | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 100 | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
| 73 | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 101 | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
M pkg/vt/row_id_test.go +21 -0
| @@ -71,6 +71,27 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |||
| 71 | 71 | } | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | |
| 75 | + | b := newBuffer(8, 3) | |
| 76 | + | copy(b.frame.Row(0), historyRow("abcdefgh")) | |
| 77 | + | copy(b.frame.Row(1), historyRow("ijkl")) | |
| 78 | + | copy(b.frame.Row(2), historyRow("mnop")) | |
| 79 | + | b.boundaries[0] = LineBound{End: 8, Soft: true} | |
| 80 | + | b.boundaries[1] = LineBound{End: 4} | |
| 81 | + | b.boundaries[2] = LineBound{End: 4} | |
| 82 | + | b.rowIDs = []RowID{11, 12, 13} | |
| 83 | + | ||
| 84 | + | active := &bufferCursor{row: 2, col: 4} | |
| 85 | + | evicted, bounds, ids := b.resize(4, 2, active, nil) | |
| 86 | + | ||
| 87 | + | if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 { | |
| 88 | + | t.Fatalf("reflow eviction = rows=%d bound=%v IDs=%v, want two rows with source ID 11 then a generated ID", len(evicted), bounds, ids) | |
| 89 | + | } | |
| 90 | + | if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) { | |
| 91 | + | t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1]) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 74 | 95 | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |
| 75 | 96 | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | |
| 76 | 97 | for i, text := range []string{"one", "two"} { | |
| @@ -71,6 +71,27 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |||||
| 71 | } | 71 | } | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | + | func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | |||
| 75 | + | b := newBuffer(8, 3) | |||
| 76 | + | copy(b.frame.Row(0), historyRow("abcdefgh")) | |||
| 77 | + | copy(b.frame.Row(1), historyRow("ijkl")) | |||
| 78 | + | copy(b.frame.Row(2), historyRow("mnop")) | |||
| 79 | + | b.boundaries[0] = LineBound{End: 8, Soft: true} | |||
| 80 | + | b.boundaries[1] = LineBound{End: 4} | |||
| 81 | + | b.boundaries[2] = LineBound{End: 4} | |||
| 82 | + | b.rowIDs = []RowID{11, 12, 13} | |||
| 83 | + | ||||
| 84 | + | active := &bufferCursor{row: 2, col: 4} | |||
| 85 | + | evicted, bounds, ids := b.resize(4, 2, active, nil) | |||
| 86 | + | ||||
| 87 | + | if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 { | |||
| 88 | + | t.Fatalf("reflow eviction = rows=%d bound=%v IDs=%v, want two rows with source ID 11 then a generated ID", len(evicted), bounds, ids) | |||
| 89 | + | } | |||
| 90 | + | if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) { | |||
| 91 | + | t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1]) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 74 | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | 95 | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | ||
| 75 | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | 96 | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | ||
| 76 | for i, text := range []string{"one", "two"} { | 97 | for i, text := range []string{"one", "two"} { | ||
M pkg/vt/screen_snapshot.go +5 -1
| @@ -43,11 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 43 | 43 | } | |
| 44 | 44 | cursorStyle, cursorStyleSet := s.CursorStyle() | |
| 45 | 45 | mouseTracking, mouseSGR := s.MouseMode() | |
| 46 | + | nextRowID := s.nextRowID | |
| 47 | + | if nextRowID < ^RowID(0) { | |
| 48 | + | nextRowID++ | |
| 49 | + | } | |
| 46 | 50 | return ScreenSnapshot{ | |
| 47 | 51 | frame: s.Frame.Clone(), | |
| 48 | 52 | bounds: s.LineBounds(), | |
| 49 | 53 | rowIDs: s.RowIDs(), | |
| 50 | - | nextRowID: s.nextRowID + 1, | |
| 54 | + | nextRowID: nextRowID, | |
| 51 | 55 | cursor: CursorSnapshot{ | |
| 52 | 56 | Row: s.CursorRow(), | |
| 53 | 57 | Col: s.CursorCol(), | |
| @@ -43,11 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||||
| 43 | } | 43 | } | ||
| 44 | cursorStyle, cursorStyleSet := s.CursorStyle() | 44 | cursorStyle, cursorStyleSet := s.CursorStyle() | ||
| 45 | mouseTracking, mouseSGR := s.MouseMode() | 45 | mouseTracking, mouseSGR := s.MouseMode() | ||
| 46 | + | nextRowID := s.nextRowID | |||
| 47 | + | if nextRowID < ^RowID(0) { | |||
| 48 | + | nextRowID++ | |||
| 49 | + | } | |||
| 46 | return ScreenSnapshot{ | 50 | return ScreenSnapshot{ | ||
| 47 | frame: s.Frame.Clone(), | 51 | frame: s.Frame.Clone(), | ||
| 48 | bounds: s.LineBounds(), | 52 | bounds: s.LineBounds(), | ||
| 49 | rowIDs: s.RowIDs(), | 53 | rowIDs: s.RowIDs(), | ||
| 50 | - | nextRowID: s.nextRowID + 1, | 54 | + | nextRowID: nextRowID, |
| 51 | cursor: CursorSnapshot{ | 55 | cursor: CursorSnapshot{ | ||
| 52 | Row: s.CursorRow(), | 56 | Row: s.CursorRow(), | ||
| 53 | Col: s.CursorCol(), | 57 | Col: s.CursorCol(), | ||
M pkg/vt/snapshot.go +54 -23
| @@ -76,55 +76,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||
| 76 | 76 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | |
| 77 | 77 | // and the mandatory tail blob. It never feeds decoded rows through Append. | |
| 78 | 78 | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | |
| 79 | - | h := NewHistory(config) | |
| 79 | + | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail) | |
| 80 | + | if err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 83 | + | return restoreHistoryViews(config, sealedViews, tailView), nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) { | |
| 80 | 87 | seen := make(map[RowID]struct{}) | |
| 81 | - | for _, blob := range sealed { | |
| 88 | + | sealedViews := make([]HistoryView, len(sealed)) | |
| 89 | + | for i, blob := range sealed { | |
| 82 | 90 | view, err := UnmarshalHistory(blob) | |
| 83 | 91 | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { | |
| 84 | - | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |
| 92 | + | return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |
| 85 | 93 | } | |
| 86 | - | chunk := view.chunks[0] | |
| 94 | + | sealedViews[i] = view | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | tailView, err := UnmarshalHistory(tail) | |
| 98 | + | if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) { | |
| 99 | + | return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |
| 100 | + | } | |
| 101 | + | for _, view := range extra { | |
| 102 | + | if !validateRestoredHistoryView(view, seen) { | |
| 103 | + | return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | return sealedViews, tailView, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History { | |
| 110 | + | h := NewHistory(config) | |
| 111 | + | for _, view := range sealed { | |
| 87 | 112 | h.nextRowID = max(h.nextRowID, view.nextRowID) | |
| 88 | - | h.evictUntil(len(chunk.rows), view.Cells()) | |
| 113 | + | if h.maxRows == 0 { | |
| 114 | + | continue | |
| 115 | + | } | |
| 116 | + | chunk := view.chunks[0] | |
| 89 | 117 | h.chunks = append(h.chunks, chunk) | |
| 90 | 118 | h.rows += len(chunk.rows) | |
| 91 | 119 | h.cells += view.Cells() | |
| 120 | + | h.evict() | |
| 92 | 121 | } | |
| 93 | - | view, err := UnmarshalHistory(tail) | |
| 94 | - | if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) { | |
| 95 | - | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |
| 96 | - | } | |
| 97 | - | h.nextRowID = max(h.nextRowID, view.nextRowID) | |
| 98 | - | if len(view.chunks) == 1 { | |
| 99 | - | chunk := view.chunks[0] | |
| 100 | - | h.evictUntil(len(chunk.rows), view.Cells()) | |
| 101 | - | h.tail = chunk.rows | |
| 102 | - | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | |
| 103 | - | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | |
| 104 | - | h.rows += len(h.tail) | |
| 105 | - | h.cells += view.Cells() | |
| 122 | + | h.nextRowID = max(h.nextRowID, tail.nextRowID) | |
| 123 | + | if h.maxRows == 0 || len(tail.chunks) == 0 { | |
| 124 | + | return h | |
| 106 | 125 | } | |
| 126 | + | chunk := tail.chunks[0] | |
| 127 | + | h.tail = chunk.rows | |
| 128 | + | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | |
| 129 | + | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | |
| 130 | + | h.rows += len(h.tail) | |
| 131 | + | h.cells += tail.Cells() | |
| 107 | 132 | h.evict() | |
| 108 | 133 | h.normalizeTail() | |
| 109 | - | return h, nil | |
| 134 | + | return h | |
| 110 | 135 | } | |
| 111 | 136 | ||
| 112 | 137 | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | |
| 138 | + | if view.nextRowID == 0 || view.nextRowID == ^RowID(0) { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | var maxID RowID | |
| 113 | 142 | for _, chunk := range view.chunks { | |
| 114 | 143 | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | |
| 115 | 144 | return false | |
| 116 | 145 | } | |
| 117 | 146 | for _, id := range chunk.rowIDs { | |
| 118 | - | if id == 0 || id == ^RowID(0) { | |
| 147 | + | if id == 0 || id >= ^RowID(0)-1 { | |
| 119 | 148 | return false | |
| 120 | 149 | } | |
| 121 | 150 | if _, duplicate := seen[id]; duplicate { | |
| 122 | 151 | return false | |
| 123 | 152 | } | |
| 124 | 153 | seen[id] = struct{}{} | |
| 154 | + | maxID = max(maxID, id) | |
| 125 | 155 | } | |
| 126 | 156 | } | |
| 127 | - | return true | |
| 157 | + | return view.nextRowID > maxID | |
| 128 | 158 | } | |
| 129 | 159 | ||
| 130 | 160 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |
| @@ -136,10 +166,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 136 | 166 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 137 | 167 | } | |
| 138 | 168 | ||
| 139 | - | history, err := HistoryFromBlobs(config, sealed, tail) | |
| 169 | + | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView) | |
| 140 | 170 | if err != nil { | |
| 141 | 171 | return nil, err | |
| 142 | 172 | } | |
| 173 | + | history := restoreHistoryViews(config, sealedViews, tailView) | |
| 143 | 174 | remainingRows := transcriptView.rows | |
| 144 | 175 | for _, chunk := range transcriptView.chunks { | |
| 145 | 176 | for i, row := range chunk.rows { | |
| @@ -157,7 +188,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 157 | 188 | screen := NewScreenWithHistory(width, height, config) | |
| 158 | 189 | screen.history = history | |
| 159 | 190 | counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1)) | |
| 160 | - | if counter == ^RowID(0) { | |
| 191 | + | if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) { | |
| 161 | 192 | return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |
| 162 | 193 | } | |
| 163 | 194 | screen.nextRowID = counter - 1 | |
| @@ -76,55 +76,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||||
| 76 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | 76 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | ||
| 77 | // and the mandatory tail blob. It never feeds decoded rows through Append. | 77 | // and the mandatory tail blob. It never feeds decoded rows through Append. | ||
| 78 | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | 78 | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | ||
| 79 | - | h := NewHistory(config) | 79 | + | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail) |
| 80 | + | if err != nil { | |||
| 81 | + | return nil, err | |||
| 82 | + | } | |||
| 83 | + | return restoreHistoryViews(config, sealedViews, tailView), nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) { | |||
| 80 | seen := make(map[RowID]struct{}) | 87 | seen := make(map[RowID]struct{}) | ||
| 81 | - | for _, blob := range sealed { | 88 | + | sealedViews := make([]HistoryView, len(sealed)) |
| 89 | + | for i, blob := range sealed { | |||
| 82 | view, err := UnmarshalHistory(blob) | 90 | view, err := UnmarshalHistory(blob) | ||
| 83 | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { | 91 | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { | ||
| 84 | - | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | 92 | + | return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory) |
| 85 | } | 93 | } | ||
| 86 | - | chunk := view.chunks[0] | 94 | + | sealedViews[i] = view |
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | tailView, err := UnmarshalHistory(tail) | |||
| 98 | + | if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) { | |||
| 99 | + | return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |||
| 100 | + | } | |||
| 101 | + | for _, view := range extra { | |||
| 102 | + | if !validateRestoredHistoryView(view, seen) { | |||
| 103 | + | return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | return sealedViews, tailView, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History { | |||
| 110 | + | h := NewHistory(config) | |||
| 111 | + | for _, view := range sealed { | |||
| 87 | h.nextRowID = max(h.nextRowID, view.nextRowID) | 112 | h.nextRowID = max(h.nextRowID, view.nextRowID) | ||
| 88 | - | h.evictUntil(len(chunk.rows), view.Cells()) | 113 | + | if h.maxRows == 0 { |
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | chunk := view.chunks[0] | |||
| 89 | h.chunks = append(h.chunks, chunk) | 117 | h.chunks = append(h.chunks, chunk) | ||
| 90 | h.rows += len(chunk.rows) | 118 | h.rows += len(chunk.rows) | ||
| 91 | h.cells += view.Cells() | 119 | h.cells += view.Cells() | ||
| 120 | + | h.evict() | |||
| 92 | } | 121 | } | ||
| 93 | - | view, err := UnmarshalHistory(tail) | 122 | + | h.nextRowID = max(h.nextRowID, tail.nextRowID) |
| 94 | - | if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) { | 123 | + | if h.maxRows == 0 || len(tail.chunks) == 0 { |
| 95 | - | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | 124 | + | return h |
| 96 | - | } | |||
| 97 | - | h.nextRowID = max(h.nextRowID, view.nextRowID) | |||
| 98 | - | if len(view.chunks) == 1 { | |||
| 99 | - | chunk := view.chunks[0] | |||
| 100 | - | h.evictUntil(len(chunk.rows), view.Cells()) | |||
| 101 | - | h.tail = chunk.rows | |||
| 102 | - | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | |||
| 103 | - | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | |||
| 104 | - | h.rows += len(h.tail) | |||
| 105 | - | h.cells += view.Cells() | |||
| 106 | } | 125 | } | ||
| 126 | + | chunk := tail.chunks[0] | |||
| 127 | + | h.tail = chunk.rows | |||
| 128 | + | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | |||
| 129 | + | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | |||
| 130 | + | h.rows += len(h.tail) | |||
| 131 | + | h.cells += tail.Cells() | |||
| 107 | h.evict() | 132 | h.evict() | ||
| 108 | h.normalizeTail() | 133 | h.normalizeTail() | ||
| 109 | - | return h, nil | 134 | + | return h |
| 110 | } | 135 | } | ||
| 111 | 136 | ||||
| 112 | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | 137 | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | ||
| 138 | + | if view.nextRowID == 0 || view.nextRowID == ^RowID(0) { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | var maxID RowID | |||
| 113 | for _, chunk := range view.chunks { | 142 | for _, chunk := range view.chunks { | ||
| 114 | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | 143 | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | ||
| 115 | return false | 144 | return false | ||
| 116 | } | 145 | } | ||
| 117 | for _, id := range chunk.rowIDs { | 146 | for _, id := range chunk.rowIDs { | ||
| 118 | - | if id == 0 || id == ^RowID(0) { | 147 | + | if id == 0 || id >= ^RowID(0)-1 { |
| 119 | return false | 148 | return false | ||
| 120 | } | 149 | } | ||
| 121 | if _, duplicate := seen[id]; duplicate { | 150 | if _, duplicate := seen[id]; duplicate { | ||
| 122 | return false | 151 | return false | ||
| 123 | } | 152 | } | ||
| 124 | seen[id] = struct{}{} | 153 | seen[id] = struct{}{} | ||
| 154 | + | maxID = max(maxID, id) | |||
| 125 | } | 155 | } | ||
| 126 | } | 156 | } | ||
| 127 | - | return true | 157 | + | return view.nextRowID > maxID |
| 128 | } | 158 | } | ||
| 129 | 159 | ||||
| 130 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | 160 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | ||
| @@ -136,10 +166,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 136 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | 166 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | ||
| 137 | } | 167 | } | ||
| 138 | 168 | ||||
| 139 | - | history, err := HistoryFromBlobs(config, sealed, tail) | 169 | + | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView) |
| 140 | if err != nil { | 170 | if err != nil { | ||
| 141 | return nil, err | 171 | return nil, err | ||
| 142 | } | 172 | } | ||
| 173 | + | history := restoreHistoryViews(config, sealedViews, tailView) | |||
| 143 | remainingRows := transcriptView.rows | 174 | remainingRows := transcriptView.rows | ||
| 144 | for _, chunk := range transcriptView.chunks { | 175 | for _, chunk := range transcriptView.chunks { | ||
| 145 | for i, row := range chunk.rows { | 176 | for i, row := range chunk.rows { | ||
| @@ -157,7 +188,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 157 | screen := NewScreenWithHistory(width, height, config) | 188 | screen := NewScreenWithHistory(width, height, config) | ||
| 158 | screen.history = history | 189 | screen.history = history | ||
| 159 | counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1)) | 190 | counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1)) | ||
| 160 | - | if counter == ^RowID(0) { | 191 | + | if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) { |
| 161 | return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | 192 | return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | ||
| 162 | } | 193 | } | ||
| 163 | screen.nextRowID = counter - 1 | 194 | screen.nextRowID = counter - 1 | ||
M pkg/vt/snapshot_test.go +5 -0
| @@ -34,6 +34,11 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||
| 34 | 34 | if got, want := view.Cells(), 12; got != want { | |
| 35 | 35 | t.Fatalf("snapshot cells = %d, want %d", got, want) | |
| 36 | 36 | } | |
| 37 | + | wantID := view.Tail().RowID(0) | |
| 38 | + | history.tailIDs[0] = 99 | |
| 39 | + | if got := view.Tail().RowID(0); got != wantID { | |
| 40 | + | t.Fatalf("snapshot tail ID = %d after live mutation, want %d", got, wantID) | |
| 41 | + | } | |
| 37 | 42 | ||
| 38 | 43 | tail, err := MarshalHistoryTail(view) | |
| 39 | 44 | require.NoError(t, err) | |
| @@ -34,6 +34,11 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||||
| 34 | if got, want := view.Cells(), 12; got != want { | 34 | if got, want := view.Cells(), 12; got != want { | ||
| 35 | t.Fatalf("snapshot cells = %d, want %d", got, want) | 35 | t.Fatalf("snapshot cells = %d, want %d", got, want) | ||
| 36 | } | 36 | } | ||
| 37 | + | wantID := view.Tail().RowID(0) | |||
| 38 | + | history.tailIDs[0] = 99 | |||
| 39 | + | if got := view.Tail().RowID(0); got != wantID { | |||
| 40 | + | t.Fatalf("snapshot tail ID = %d after live mutation, want %d", got, wantID) | |||
| 41 | + | } | |||
| 37 | 42 | ||||
| 38 | tail, err := MarshalHistoryTail(view) | 43 | tail, err := MarshalHistoryTail(view) | ||
| 39 | require.NoError(t, err) | 44 | require.NoError(t, err) | ||