feat(vt): persist stable row identities
- commit
- 8a3d00abe103934b5fbec2a736133545a4aa7a89
- author
- brice · · 2026-08-02 20:07 UTC
- committer
- brice · · 2026-08-03 17:54 UTC
- parent
- 6f5dceb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +418 -121
M internal/usecase/daemon/daemon_test.go +1 -1
| @@ -458,7 +458,7 @@ func testRow(text string) []renderer.Cell { | |||
| 458 | 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | |
| 459 | 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | |
| 460 | 460 | tb.Helper() | |
| 461 | - | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)})) | |
| 461 | + | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1))) | |
| 462 | 462 | } | |
| 463 | 463 | ||
| 464 | 464 | func tabCount(sess *session) int { | |
| @@ -458,7 +458,7 @@ func testRow(text string) []renderer.Cell { | |||||
| 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | ||
| 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | ||
| 460 | tb.Helper() | 460 | tb.Helper() | ||
| 461 | - | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)})) | 461 | + | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1))) |
| 462 | } | 462 | } | ||
| 463 | 463 | ||||
| 464 | func tabCount(sess *session) int { | 464 | func tabCount(sess *session) int { | ||
M internal/usecase/daemon/snapshot_incremental.go +0 -3
| @@ -169,9 +169,6 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 169 | 169 | // general history encoding. An empty tail is represented by its dedicated | |
| 170 | 170 | // canonical blob; a non-empty copied tail retains its rows. | |
| 171 | 171 | func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) { | |
| 172 | - | if tail.Len() == 0 { | |
| 173 | - | return vt.MarshalEmptyHistoryTail() | |
| 174 | - | } | |
| 175 | 172 | return vt.MarshalHistory(tail) | |
| 176 | 173 | } | |
| 177 | 174 | ||
| @@ -169,9 +169,6 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||||
| 169 | // general history encoding. An empty tail is represented by its dedicated | 169 | // general history encoding. An empty tail is represented by its dedicated | ||
| 170 | // canonical blob; a non-empty copied tail retains its rows. | 170 | // canonical blob; a non-empty copied tail retains its rows. | ||
| 171 | func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) { | 171 | func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) { | ||
| 172 | - | if tail.Len() == 0 { | |||
| 173 | - | return vt.MarshalEmptyHistoryTail() | |||
| 174 | - | } | |||
| 175 | return vt.MarshalHistory(tail) | 172 | return vt.MarshalHistory(tail) | ||
| 176 | } | 173 | } | ||
| 177 | 174 | ||||
M internal/usecase/snapshot/codec_test.go +5 -4
| @@ -227,10 +227,11 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 227 | 227 | if len(sealed) == 0 { | |
| 228 | 228 | t.Fatal("expected a sealed chunk") | |
| 229 | 229 | } | |
| 230 | - | multiChunk := append([]byte(nil), sealed[0][:5]...) | |
| 231 | - | multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2) | |
| 232 | - | multiChunk = append(multiChunk, sealed[0][9:]...) | |
| 233 | - | multiChunk = append(multiChunk, sealed[0][9:]...) | |
| 230 | + | multiChunk := append([]byte(nil), sealed[0][:17]...) | |
| 231 | + | binary.BigEndian.PutUint32(multiChunk[5:9], 2) | |
| 232 | + | binary.BigEndian.PutUint64(multiChunk[9:17], 4) | |
| 233 | + | multiChunk = append(multiChunk, sealed[0][17:]...) | |
| 234 | + | multiChunk = append(multiChunk, sealed[1][17:]...) | |
| 234 | 235 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 235 | 236 | emptyTranscript := transcriptBlob(t, nil) | |
| 236 | 237 | ||
| @@ -227,10 +227,11 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 227 | if len(sealed) == 0 { | 227 | if len(sealed) == 0 { | ||
| 228 | t.Fatal("expected a sealed chunk") | 228 | t.Fatal("expected a sealed chunk") | ||
| 229 | } | 229 | } | ||
| 230 | - | multiChunk := append([]byte(nil), sealed[0][:5]...) | 230 | + | multiChunk := append([]byte(nil), sealed[0][:17]...) |
| 231 | - | multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2) | 231 | + | binary.BigEndian.PutUint32(multiChunk[5:9], 2) |
| 232 | - | multiChunk = append(multiChunk, sealed[0][9:]...) | 232 | + | binary.BigEndian.PutUint64(multiChunk[9:17], 4) |
| 233 | - | multiChunk = append(multiChunk, sealed[0][9:]...) | 233 | + | multiChunk = append(multiChunk, sealed[0][17:]...) |
| 234 | + | multiChunk = append(multiChunk, sealed[1][17:]...) | |||
| 234 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 235 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | ||
| 235 | emptyTranscript := transcriptBlob(t, nil) | 236 | emptyTranscript := transcriptBlob(t, nil) | ||
| 236 | 237 | ||||
M pkg/vt/history.go +74 -25
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | // cell budget. The history is not modified when this error is returned. | |
| 12 | 12 | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |
| 13 | 13 | ||
| 14 | + | var errInvalidHistoryRowID = errors.New("invalid history row ID") | |
| 15 | + | ||
| 14 | 16 | // HistoryConfig controls the bounded terminal history retained by a Screen. | |
| 15 | 17 | type HistoryConfig struct { | |
| 16 | 18 | MaxRows int | |
| @@ -46,9 +48,10 @@ type HistoryChunk struct { | |||
| 46 | 48 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| 47 | 49 | // storage behind a sealed chunk remains owned by VT. | |
| 48 | 50 | type HistoryView struct { | |
| 49 | - | chunks []*HistoryChunk | |
| 50 | - | rows int | |
| 51 | - | cells int | |
| 51 | + | chunks []*HistoryChunk | |
| 52 | + | rows int | |
| 53 | + | cells int | |
| 54 | + | nextRowID RowID | |
| 52 | 55 | } | |
| 53 | 56 | ||
| 54 | 57 | // HistorySnapshotView captures sealed history chunks and the mutable tail | |
| @@ -61,6 +64,7 @@ type HistorySnapshotView struct { | |||
| 61 | 64 | tailIDs []RowID | |
| 62 | 65 | rows int | |
| 63 | 66 | cells int | |
| 67 | + | nextRowID RowID | |
| 64 | 68 | } | |
| 65 | 69 | ||
| 66 | 70 | func NewHistory(config HistoryConfig) *History { | |
| @@ -104,7 +108,30 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err | |||
| 104 | 108 | } | |
| 105 | 109 | if id == 0 || h.hasRowID(id) { | |
| 106 | 110 | id = h.allocateRowID() | |
| 107 | - | } else if id >= h.nextRowID { | |
| 111 | + | } | |
| 112 | + | return h.appendRow(row, bound, id) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // appendRestored appends a persisted row without synthesizing an identity. | |
| 116 | + | // The caller validates IDs across all persisted blobs before invoking it. | |
| 117 | + | func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) error { | |
| 118 | + | if h == nil || h.maxRows == 0 { | |
| 119 | + | return nil | |
| 120 | + | } | |
| 121 | + | if id == 0 || id == ^RowID(0) || h.hasRowID(id) { | |
| 122 | + | return errInvalidHistoryRowID | |
| 123 | + | } | |
| 124 | + | return h.appendRow(row, bound, id) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error { | |
| 128 | + | if len(row) > h.maxCells { | |
| 129 | + | return ErrHistoryRowTooWide | |
| 130 | + | } | |
| 131 | + | if id == ^RowID(0) { | |
| 132 | + | return errInvalidHistoryRowID | |
| 133 | + | } | |
| 134 | + | if id >= h.nextRowID { | |
| 108 | 135 | h.nextRowID = id + 1 | |
| 109 | 136 | } | |
| 110 | 137 | // Make room before adding so Cells cannot overflow even when the default | |
| @@ -139,20 +166,6 @@ func (h *History) allocateRowID() RowID { | |||
| 139 | 166 | return id | |
| 140 | 167 | } | |
| 141 | 168 | ||
| 142 | - | func (h *History) assignChunkIDs(chunk *HistoryChunk) { | |
| 143 | - | if chunk == nil { | |
| 144 | - | return | |
| 145 | - | } | |
| 146 | - | chunk.rowIDs = growRowIDs(chunk.rowIDs, len(chunk.rows)) | |
| 147 | - | for i := range chunk.rows { | |
| 148 | - | if chunk.rowIDs[i] == 0 || h.hasRowID(chunk.rowIDs[i]) { | |
| 149 | - | chunk.rowIDs[i] = h.allocateRowID() | |
| 150 | - | } else if chunk.rowIDs[i] >= h.nextRowID { | |
| 151 | - | h.nextRowID = chunk.rowIDs[i] + 1 | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | ||
| 156 | 169 | func (h *History) hasRowID(id RowID) bool { | |
| 157 | 170 | for _, chunk := range h.chunks { | |
| 158 | 171 | for _, candidate := range chunk.rowIDs { | |
| @@ -288,9 +301,12 @@ func (h *History) SealAndView() HistoryView { | |||
| 288 | 301 | // View captures the current history. Sealed chunks are shared by identity; a | |
| 289 | 302 | // partially-filled tail is copied into a new immutable chunk for this view. | |
| 290 | 303 | func (h *History) View() HistoryView { | |
| 291 | - | if h == nil || h.rows == 0 { | |
| 304 | + | if h == nil { | |
| 292 | 305 | return HistoryView{} | |
| 293 | 306 | } | |
| 307 | + | if h.rows == 0 { | |
| 308 | + | return HistoryView{nextRowID: h.nextRowID} | |
| 309 | + | } | |
| 294 | 310 | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 295 | 311 | if len(h.tail) > 0 { | |
| 296 | 312 | chunks = append(chunks, &HistoryChunk{ | |
| @@ -299,15 +315,18 @@ func (h *History) View() HistoryView { | |||
| 299 | 315 | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | |
| 300 | 316 | }) | |
| 301 | 317 | } | |
| 302 | - | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | |
| 318 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} | |
| 303 | 319 | } | |
| 304 | 320 | ||
| 305 | 321 | // SnapshotView captures history for persistence without sealing the mutable | |
| 306 | 322 | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | |
| 307 | 323 | func (h *History) SnapshotView() HistorySnapshotView { | |
| 308 | - | if h == nil || h.rows == 0 { | |
| 324 | + | if h == nil { | |
| 309 | 325 | return HistorySnapshotView{} | |
| 310 | 326 | } | |
| 327 | + | if h.rows == 0 { | |
| 328 | + | return HistorySnapshotView{nextRowID: h.nextRowID} | |
| 329 | + | } | |
| 311 | 330 | return HistorySnapshotView{ | |
| 312 | 331 | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 313 | 332 | tail: cloneHistoryRows(h.tail), | |
| @@ -315,6 +334,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 315 | 334 | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | |
| 316 | 335 | rows: h.rows, | |
| 317 | 336 | cells: h.cells, | |
| 337 | + | nextRowID: h.nextRowID, | |
| 318 | 338 | } | |
| 319 | 339 | } | |
| 320 | 340 | ||
| @@ -345,6 +365,14 @@ func (h *History) Cells() int { | |||
| 345 | 365 | return h.cells | |
| 346 | 366 | } | |
| 347 | 367 | ||
| 368 | + | // NextRowID returns the next identity allocated by this history. | |
| 369 | + | func (h *History) NextRowID() RowID { | |
| 370 | + | if h == nil || h.nextRowID == 0 { | |
| 371 | + | return 1 | |
| 372 | + | } | |
| 373 | + | return h.nextRowID | |
| 374 | + | } | |
| 375 | + | ||
| 348 | 376 | // Cap returns the configured bounded row capacity. | |
| 349 | 377 | func (h *History) Cap() int { | |
| 350 | 378 | if h == nil { | |
| @@ -365,6 +393,18 @@ func (v HistoryView) Len() int { return v.rows } | |||
| 365 | 393 | func (v HistoryView) Cells() int { return v.cells } | |
| 366 | 394 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |
| 367 | 395 | ||
| 396 | + | // NextRowID returns the next identity recorded by this view. | |
| 397 | + | func (v HistoryView) NextRowID() RowID { | |
| 398 | + | if v.nextRowID == 0 { | |
| 399 | + | next, ok := historyViewNextRowID(v) | |
| 400 | + | if !ok { | |
| 401 | + | return 1 | |
| 402 | + | } | |
| 403 | + | return next | |
| 404 | + | } | |
| 405 | + | return v.nextRowID | |
| 406 | + | } | |
| 407 | + | ||
| 368 | 408 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |
| 369 | 409 | func (v HistoryView) Chunk(i int) *HistoryChunk { | |
| 370 | 410 | if i < 0 || i >= len(v.chunks) { | |
| @@ -470,6 +510,14 @@ func (v HistorySnapshotView) Len() int { return v.rows } | |||
| 470 | 510 | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 471 | 511 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |
| 472 | 512 | ||
| 513 | + | // NextRowID returns the next identity recorded by this snapshot. | |
| 514 | + | func (v HistorySnapshotView) NextRowID() RowID { | |
| 515 | + | if v.nextRowID == 0 { | |
| 516 | + | return v.Tail().NextRowID() | |
| 517 | + | } | |
| 518 | + | return v.nextRowID | |
| 519 | + | } | |
| 520 | + | ||
| 473 | 521 | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | |
| 474 | 522 | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |
| 475 | 523 | if i < 0 || i >= len(v.chunks) { | |
| @@ -481,12 +529,13 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||
| 481 | 529 | // Tail returns an immutable view of the copied mutable tail. | |
| 482 | 530 | func (v HistorySnapshotView) Tail() HistoryView { | |
| 483 | 531 | if len(v.tail) == 0 { | |
| 484 | - | return HistoryView{} | |
| 532 | + | return HistoryView{nextRowID: v.nextRowID} | |
| 485 | 533 | } | |
| 486 | 534 | return HistoryView{ | |
| 487 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | |
| 488 | - | rows: len(v.tail), | |
| 489 | - | cells: historyRowsCells(v.tail), | |
| 535 | + | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | |
| 536 | + | rows: len(v.tail), | |
| 537 | + | cells: historyRowsCells(v.tail), | |
| 538 | + | nextRowID: v.nextRowID, | |
| 490 | 539 | } | |
| 491 | 540 | } | |
| 492 | 541 | ||
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | // cell budget. The history is not modified when this error is returned. | 11 | // cell budget. The history is not modified when this error is returned. | ||
| 12 | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | 12 | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | ||
| 13 | 13 | ||||
| 14 | + | var errInvalidHistoryRowID = errors.New("invalid history row ID") | |||
| 15 | + | ||||
| 14 | // HistoryConfig controls the bounded terminal history retained by a Screen. | 16 | // HistoryConfig controls the bounded terminal history retained by a Screen. | ||
| 15 | type HistoryConfig struct { | 17 | type HistoryConfig struct { | ||
| 16 | MaxRows int | 18 | MaxRows int | ||
| @@ -46,9 +48,10 @@ type HistoryChunk struct { | |||||
| 46 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | 48 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | ||
| 47 | // storage behind a sealed chunk remains owned by VT. | 49 | // storage behind a sealed chunk remains owned by VT. | ||
| 48 | type HistoryView struct { | 50 | type HistoryView struct { | ||
| 49 | - | chunks []*HistoryChunk | 51 | + | chunks []*HistoryChunk |
| 50 | - | rows int | 52 | + | rows int |
| 51 | - | cells int | 53 | + | cells int |
| 54 | + | nextRowID RowID | |||
| 52 | } | 55 | } | ||
| 53 | 56 | ||||
| 54 | // HistorySnapshotView captures sealed history chunks and the mutable tail | 57 | // HistorySnapshotView captures sealed history chunks and the mutable tail | ||
| @@ -61,6 +64,7 @@ type HistorySnapshotView struct { | |||||
| 61 | tailIDs []RowID | 64 | tailIDs []RowID | ||
| 62 | rows int | 65 | rows int | ||
| 63 | cells int | 66 | cells int | ||
| 67 | + | nextRowID RowID | |||
| 64 | } | 68 | } | ||
| 65 | 69 | ||||
| 66 | func NewHistory(config HistoryConfig) *History { | 70 | func NewHistory(config HistoryConfig) *History { | ||
| @@ -104,7 +108,30 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err | |||||
| 104 | } | 108 | } | ||
| 105 | if id == 0 || h.hasRowID(id) { | 109 | if id == 0 || h.hasRowID(id) { | ||
| 106 | id = h.allocateRowID() | 110 | id = h.allocateRowID() | ||
| 107 | - | } else if id >= h.nextRowID { | 111 | + | } |
| 112 | + | return h.appendRow(row, bound, id) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // appendRestored appends a persisted row without synthesizing an identity. | |||
| 116 | + | // The caller validates IDs across all persisted blobs before invoking it. | |||
| 117 | + | func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) error { | |||
| 118 | + | if h == nil || h.maxRows == 0 { | |||
| 119 | + | return nil | |||
| 120 | + | } | |||
| 121 | + | if id == 0 || id == ^RowID(0) || h.hasRowID(id) { | |||
| 122 | + | return errInvalidHistoryRowID | |||
| 123 | + | } | |||
| 124 | + | return h.appendRow(row, bound, id) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error { | |||
| 128 | + | if len(row) > h.maxCells { | |||
| 129 | + | return ErrHistoryRowTooWide | |||
| 130 | + | } | |||
| 131 | + | if id == ^RowID(0) { | |||
| 132 | + | return errInvalidHistoryRowID | |||
| 133 | + | } | |||
| 134 | + | if id >= h.nextRowID { | |||
| 108 | h.nextRowID = id + 1 | 135 | h.nextRowID = id + 1 | ||
| 109 | } | 136 | } | ||
| 110 | // Make room before adding so Cells cannot overflow even when the default | 137 | // Make room before adding so Cells cannot overflow even when the default | ||
| @@ -139,20 +166,6 @@ func (h *History) allocateRowID() RowID { | |||||
| 139 | return id | 166 | return id | ||
| 140 | } | 167 | } | ||
| 141 | 168 | ||||
| 142 | - | func (h *History) assignChunkIDs(chunk *HistoryChunk) { | |||
| 143 | - | if chunk == nil { | |||
| 144 | - | return | |||
| 145 | - | } | |||
| 146 | - | chunk.rowIDs = growRowIDs(chunk.rowIDs, len(chunk.rows)) | |||
| 147 | - | for i := range chunk.rows { | |||
| 148 | - | if chunk.rowIDs[i] == 0 || h.hasRowID(chunk.rowIDs[i]) { | |||
| 149 | - | chunk.rowIDs[i] = h.allocateRowID() | |||
| 150 | - | } else if chunk.rowIDs[i] >= h.nextRowID { | |||
| 151 | - | h.nextRowID = chunk.rowIDs[i] + 1 | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | func (h *History) hasRowID(id RowID) bool { | 169 | func (h *History) hasRowID(id RowID) bool { | ||
| 157 | for _, chunk := range h.chunks { | 170 | for _, chunk := range h.chunks { | ||
| 158 | for _, candidate := range chunk.rowIDs { | 171 | for _, candidate := range chunk.rowIDs { | ||
| @@ -288,9 +301,12 @@ func (h *History) SealAndView() HistoryView { | |||||
| 288 | // View captures the current history. Sealed chunks are shared by identity; a | 301 | // View captures the current history. Sealed chunks are shared by identity; a | ||
| 289 | // partially-filled tail is copied into a new immutable chunk for this view. | 302 | // partially-filled tail is copied into a new immutable chunk for this view. | ||
| 290 | func (h *History) View() HistoryView { | 303 | func (h *History) View() HistoryView { | ||
| 291 | - | if h == nil || h.rows == 0 { | 304 | + | if h == nil { |
| 292 | return HistoryView{} | 305 | return HistoryView{} | ||
| 293 | } | 306 | } | ||
| 307 | + | if h.rows == 0 { | |||
| 308 | + | return HistoryView{nextRowID: h.nextRowID} | |||
| 309 | + | } | |||
| 294 | chunks := append([]*HistoryChunk(nil), h.chunks...) | 310 | chunks := append([]*HistoryChunk(nil), h.chunks...) | ||
| 295 | if len(h.tail) > 0 { | 311 | if len(h.tail) > 0 { | ||
| 296 | chunks = append(chunks, &HistoryChunk{ | 312 | chunks = append(chunks, &HistoryChunk{ | ||
| @@ -299,15 +315,18 @@ func (h *History) View() HistoryView { | |||||
| 299 | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | 315 | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | ||
| 300 | }) | 316 | }) | ||
| 301 | } | 317 | } | ||
| 302 | - | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | 318 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} |
| 303 | } | 319 | } | ||
| 304 | 320 | ||||
| 305 | // SnapshotView captures history for persistence without sealing the mutable | 321 | // SnapshotView captures history for persistence without sealing the mutable | ||
| 306 | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | 322 | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | ||
| 307 | func (h *History) SnapshotView() HistorySnapshotView { | 323 | func (h *History) SnapshotView() HistorySnapshotView { | ||
| 308 | - | if h == nil || h.rows == 0 { | 324 | + | if h == nil { |
| 309 | return HistorySnapshotView{} | 325 | return HistorySnapshotView{} | ||
| 310 | } | 326 | } | ||
| 327 | + | if h.rows == 0 { | |||
| 328 | + | return HistorySnapshotView{nextRowID: h.nextRowID} | |||
| 329 | + | } | |||
| 311 | return HistorySnapshotView{ | 330 | return HistorySnapshotView{ | ||
| 312 | chunks: append([]*HistoryChunk(nil), h.chunks...), | 331 | chunks: append([]*HistoryChunk(nil), h.chunks...), | ||
| 313 | tail: cloneHistoryRows(h.tail), | 332 | tail: cloneHistoryRows(h.tail), | ||
| @@ -315,6 +334,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||||
| 315 | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | 334 | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | ||
| 316 | rows: h.rows, | 335 | rows: h.rows, | ||
| 317 | cells: h.cells, | 336 | cells: h.cells, | ||
| 337 | + | nextRowID: h.nextRowID, | |||
| 318 | } | 338 | } | ||
| 319 | } | 339 | } | ||
| 320 | 340 | ||||
| @@ -345,6 +365,14 @@ func (h *History) Cells() int { | |||||
| 345 | return h.cells | 365 | return h.cells | ||
| 346 | } | 366 | } | ||
| 347 | 367 | ||||
| 368 | + | // NextRowID returns the next identity allocated by this history. | |||
| 369 | + | func (h *History) NextRowID() RowID { | |||
| 370 | + | if h == nil || h.nextRowID == 0 { | |||
| 371 | + | return 1 | |||
| 372 | + | } | |||
| 373 | + | return h.nextRowID | |||
| 374 | + | } | |||
| 375 | + | ||||
| 348 | // Cap returns the configured bounded row capacity. | 376 | // Cap returns the configured bounded row capacity. | ||
| 349 | func (h *History) Cap() int { | 377 | func (h *History) Cap() int { | ||
| 350 | if h == nil { | 378 | if h == nil { | ||
| @@ -365,6 +393,18 @@ func (v HistoryView) Len() int { return v.rows } | |||||
| 365 | func (v HistoryView) Cells() int { return v.cells } | 393 | func (v HistoryView) Cells() int { return v.cells } | ||
| 366 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | 394 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | ||
| 367 | 395 | ||||
| 396 | + | // NextRowID returns the next identity recorded by this view. | |||
| 397 | + | func (v HistoryView) NextRowID() RowID { | |||
| 398 | + | if v.nextRowID == 0 { | |||
| 399 | + | next, ok := historyViewNextRowID(v) | |||
| 400 | + | if !ok { | |||
| 401 | + | return 1 | |||
| 402 | + | } | |||
| 403 | + | return next | |||
| 404 | + | } | |||
| 405 | + | return v.nextRowID | |||
| 406 | + | } | |||
| 407 | + | ||||
| 368 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | 408 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | ||
| 369 | func (v HistoryView) Chunk(i int) *HistoryChunk { | 409 | func (v HistoryView) Chunk(i int) *HistoryChunk { | ||
| 370 | if i < 0 || i >= len(v.chunks) { | 410 | if i < 0 || i >= len(v.chunks) { | ||
| @@ -470,6 +510,14 @@ func (v HistorySnapshotView) Len() int { return v.rows } | |||||
| 470 | func (v HistorySnapshotView) Cells() int { return v.cells } | 510 | func (v HistorySnapshotView) Cells() int { return v.cells } | ||
| 471 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | 511 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | ||
| 472 | 512 | ||||
| 513 | + | // NextRowID returns the next identity recorded by this snapshot. | |||
| 514 | + | func (v HistorySnapshotView) NextRowID() RowID { | |||
| 515 | + | if v.nextRowID == 0 { | |||
| 516 | + | return v.Tail().NextRowID() | |||
| 517 | + | } | |||
| 518 | + | return v.nextRowID | |||
| 519 | + | } | |||
| 520 | + | ||||
| 473 | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | 521 | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | ||
| 474 | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | 522 | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | ||
| 475 | if i < 0 || i >= len(v.chunks) { | 523 | if i < 0 || i >= len(v.chunks) { | ||
| @@ -481,12 +529,13 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||||
| 481 | // Tail returns an immutable view of the copied mutable tail. | 529 | // Tail returns an immutable view of the copied mutable tail. | ||
| 482 | func (v HistorySnapshotView) Tail() HistoryView { | 530 | func (v HistorySnapshotView) Tail() HistoryView { | ||
| 483 | if len(v.tail) == 0 { | 531 | if len(v.tail) == 0 { | ||
| 484 | - | return HistoryView{} | 532 | + | return HistoryView{nextRowID: v.nextRowID} |
| 485 | } | 533 | } | ||
| 486 | return HistoryView{ | 534 | return HistoryView{ | ||
| 487 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | 535 | + | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, |
| 488 | - | rows: len(v.tail), | 536 | + | rows: len(v.tail), |
| 489 | - | cells: historyRowsCells(v.tail), | 537 | + | cells: historyRowsCells(v.tail), |
| 538 | + | nextRowID: v.nextRowID, | |||
| 490 | } | 539 | } | ||
| 491 | } | 540 | } | ||
| 492 | 541 | ||||
M pkg/vt/history_codec.go +111 -25
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | const ( | |
| 14 | 14 | historyMagic = "VTH1" | |
| 15 | - | historyVersion = 2 | |
| 15 | + | historyVersion = 3 | |
| 16 | 16 | historyCellBytes = 41 | |
| 17 | 17 | historyBoundBytes = 5 | |
| 18 | 18 | maxHistoryChunkRows = 256 | |
| @@ -37,13 +37,29 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 37 | 37 | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | |
| 38 | 38 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 39 | 39 | } | |
| 40 | + | nextRowID, ok := historyViewNextRowID(view) | |
| 41 | + | if !ok { | |
| 42 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 43 | + | } | |
| 40 | 44 | stats := historyDecodeStats{} | |
| 45 | + | seen := make(map[RowID]struct{}, view.rows) | |
| 41 | 46 | for _, chunk := range view.chunks { | |
| 42 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows || | |
| 47 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows || | |
| 43 | 48 | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |
| 44 | 49 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 45 | 50 | } | |
| 46 | - | for _, row := range chunk.rows { | |
| 51 | + | for i, row := range chunk.rows { | |
| 52 | + | if uint64(len(row)) > math.MaxUint32 { | |
| 53 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 54 | + | } | |
| 55 | + | id := chunk.rowIDs[i] | |
| 56 | + | if id == 0 || id == ^RowID(0) { | |
| 57 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 58 | + | } | |
| 59 | + | if _, duplicate := seen[id]; duplicate { | |
| 60 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 61 | + | } | |
| 62 | + | seen[id] = struct{}{} | |
| 47 | 63 | cellCount := uint64(len(row)) | |
| 48 | 64 | cellBytes, ok := historyCellByteCount(cellCount) | |
| 49 | 65 | if !ok || | |
| @@ -52,39 +68,80 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 52 | 68 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 53 | 69 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 54 | 70 | } | |
| 71 | + | bound := chunkBound(chunk, i) | |
| 72 | + | if !validHistoryBound(bound, len(row)) { | |
| 73 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 74 | + | } | |
| 75 | + | for _, cell := range row { | |
| 76 | + | if !validHistoryCell(cell) { | |
| 77 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 78 | + | } | |
| 79 | + | } | |
| 55 | 80 | } | |
| 56 | 81 | } | |
| 82 | + | if nextRowID <= maxHistoryRowID(seen) { | |
| 83 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 84 | + | } | |
| 57 | 85 | ||
| 58 | - | out := make([]byte, 0, 9) | |
| 86 | + | out := make([]byte, 0, 17) | |
| 59 | 87 | out = append(out, historyMagic...) | |
| 60 | 88 | out = append(out, historyVersion) | |
| 61 | 89 | out = appendUint32(out, uint32(len(view.chunks))) | |
| 90 | + | out = binary.BigEndian.AppendUint64(out, uint64(nextRowID)) | |
| 62 | 91 | for _, chunk := range view.chunks { | |
| 63 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows { | |
| 64 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 65 | - | } | |
| 66 | 92 | out = appendUint32(out, uint32(len(chunk.rows))) | |
| 67 | 93 | for i, row := range chunk.rows { | |
| 68 | - | if uint64(len(row)) > math.MaxUint32 { | |
| 69 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 70 | - | } | |
| 71 | - | bound := chunkBound(chunk, i) | |
| 72 | - | if !validHistoryBound(bound, len(row)) { | |
| 73 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 74 | - | } | |
| 94 | + | out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i])) | |
| 75 | 95 | out = appendUint32(out, uint32(len(row))) | |
| 76 | 96 | for _, cell := range row { | |
| 77 | - | if !validHistoryCell(cell) { | |
| 78 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 79 | - | } | |
| 80 | 97 | out = appendHistoryCell(out, cell) | |
| 81 | 98 | } | |
| 82 | - | out = appendHistoryBound(out, bound) | |
| 99 | + | out = appendHistoryBound(out, chunkBound(chunk, i)) | |
| 83 | 100 | } | |
| 84 | 101 | } | |
| 85 | 102 | return out, nil | |
| 86 | 103 | } | |
| 87 | 104 | ||
| 105 | + | func historyViewNextRowID(view HistoryView) (RowID, bool) { | |
| 106 | + | seen := make(map[RowID]struct{}, view.rows) | |
| 107 | + | for _, chunk := range view.chunks { | |
| 108 | + | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) { | |
| 109 | + | return 0, false | |
| 110 | + | } | |
| 111 | + | for _, id := range chunk.rowIDs { | |
| 112 | + | if id == 0 || id == ^RowID(0) { | |
| 113 | + | return 0, false | |
| 114 | + | } | |
| 115 | + | if _, duplicate := seen[id]; duplicate { | |
| 116 | + | return 0, false | |
| 117 | + | } | |
| 118 | + | seen[id] = struct{}{} | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | next := view.nextRowID | |
| 122 | + | if next == 0 { | |
| 123 | + | maxID := maxHistoryRowID(seen) | |
| 124 | + | if maxID == ^RowID(0) { | |
| 125 | + | return 0, false | |
| 126 | + | } | |
| 127 | + | next = maxID + 1 | |
| 128 | + | } | |
| 129 | + | if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) { | |
| 130 | + | return 0, false | |
| 131 | + | } | |
| 132 | + | return next, true | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | |
| 136 | + | var max RowID | |
| 137 | + | for id := range ids { | |
| 138 | + | if id > max { | |
| 139 | + | max = id | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | return max | |
| 143 | + | } | |
| 144 | + | ||
| 88 | 145 | func historyViewRowCount(view HistoryView) int { | |
| 89 | 146 | n := 0 | |
| 90 | 147 | for _, chunk := range view.chunks { | |
| @@ -189,7 +246,7 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||
| 189 | 246 | // parseHistory is the authoritative history payload validation path. populate | |
| 190 | 247 | // controls whether validated cells are retained in a HistoryView. | |
| 191 | 248 | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | |
| 192 | - | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 249 | + | if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 193 | 250 | return HistoryView{}, historyDecodeStats{}, false | |
| 194 | 251 | } | |
| 195 | 252 | ||
| @@ -198,16 +255,22 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 198 | 255 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 199 | 256 | return HistoryView{}, historyDecodeStats{}, false | |
| 200 | 257 | } | |
| 258 | + | nextRowID, ok := p.uint64() | |
| 259 | + | if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) { | |
| 260 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 261 | + | } | |
| 201 | 262 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |
| 263 | + | var seenIDs [maxHistoryRows]RowID | |
| 264 | + | seenCount := 0 | |
| 265 | + | var maxID RowID | |
| 202 | 266 | var chunks []*HistoryChunk | |
| 203 | - | var nextRowID RowID = 1 | |
| 204 | 267 | if populate { | |
| 205 | 268 | chunks = make([]*HistoryChunk, 0, chunkCount) | |
| 206 | 269 | } | |
| 207 | 270 | for range chunkCount { | |
| 208 | 271 | rowCount, ok := p.uint32() | |
| 209 | 272 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |
| 210 | - | uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) || | |
| 273 | + | uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) || | |
| 211 | 274 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 212 | 275 | return HistoryView{}, historyDecodeStats{}, false | |
| 213 | 276 | } | |
| @@ -221,6 +284,21 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 221 | 284 | rowIDs = make([]RowID, 0, rowCount) | |
| 222 | 285 | } | |
| 223 | 286 | for range rowCount { | |
| 287 | + | id, ok := p.uint64() | |
| 288 | + | if !ok || id == 0 || id == ^uint64(0) { | |
| 289 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 290 | + | } | |
| 291 | + | rowID := RowID(id) | |
| 292 | + | for i := 0; i < seenCount; i++ { | |
| 293 | + | if seenIDs[i] == rowID { | |
| 294 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | seenIDs[seenCount] = rowID | |
| 298 | + | seenCount++ | |
| 299 | + | if rowID > maxID { | |
| 300 | + | maxID = rowID | |
| 301 | + | } | |
| 224 | 302 | cellCount, ok := p.uint32() | |
| 225 | 303 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |
| 226 | 304 | if !ok || !validCellBytes || | |
| @@ -251,18 +329,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 251 | 329 | if populate { | |
| 252 | 330 | rows = append(rows, row) | |
| 253 | 331 | bounds = append(bounds, bound) | |
| 254 | - | rowIDs = append(rowIDs, nextRowID) | |
| 255 | - | nextRowID++ | |
| 332 | + | rowIDs = append(rowIDs, rowID) | |
| 256 | 333 | } | |
| 257 | 334 | } | |
| 258 | 335 | if populate { | |
| 259 | 336 | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | |
| 260 | 337 | } | |
| 261 | 338 | } | |
| 262 | - | if len(p.data) != 0 { | |
| 339 | + | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | |
| 263 | 340 | return HistoryView{}, historyDecodeStats{}, false | |
| 264 | 341 | } | |
| 265 | - | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true | |
| 342 | + | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true | |
| 266 | 343 | } | |
| 267 | 344 | ||
| 268 | 345 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |
| @@ -291,6 +368,15 @@ func (p *historyParser) uint32() (uint32, bool) { | |||
| 291 | 368 | return v, true | |
| 292 | 369 | } | |
| 293 | 370 | ||
| 371 | + | func (p *historyParser) uint64() (uint64, bool) { | |
| 372 | + | if len(p.data) < 8 { | |
| 373 | + | return 0, false | |
| 374 | + | } | |
| 375 | + | v := binary.BigEndian.Uint64(p.data) | |
| 376 | + | p.data = p.data[8:] | |
| 377 | + | return v, true | |
| 378 | + | } | |
| 379 | + | ||
| 294 | 380 | func (p *historyParser) cell() (renderer.Cell, bool) { | |
| 295 | 381 | if len(p.data) < historyCellBytes { | |
| 296 | 382 | return renderer.Cell{}, false | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | const ( | 13 | const ( | ||
| 14 | historyMagic = "VTH1" | 14 | historyMagic = "VTH1" | ||
| 15 | - | historyVersion = 2 | 15 | + | historyVersion = 3 |
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | historyBoundBytes = 5 | 17 | historyBoundBytes = 5 | ||
| 18 | maxHistoryChunkRows = 256 | 18 | maxHistoryChunkRows = 256 | ||
| @@ -37,13 +37,29 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||||
| 37 | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | 37 | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | ||
| 38 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 38 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 39 | } | 39 | } | ||
| 40 | + | nextRowID, ok := historyViewNextRowID(view) | |||
| 41 | + | if !ok { | |||
| 42 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 43 | + | } | |||
| 40 | stats := historyDecodeStats{} | 44 | stats := historyDecodeStats{} | ||
| 45 | + | seen := make(map[RowID]struct{}, view.rows) | |||
| 41 | for _, chunk := range view.chunks { | 46 | for _, chunk := range view.chunks { | ||
| 42 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows || | 47 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows || |
| 43 | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | 48 | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | ||
| 44 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 49 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 45 | } | 50 | } | ||
| 46 | - | for _, row := range chunk.rows { | 51 | + | for i, row := range chunk.rows { |
| 52 | + | if uint64(len(row)) > math.MaxUint32 { | |||
| 53 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 54 | + | } | |||
| 55 | + | id := chunk.rowIDs[i] | |||
| 56 | + | if id == 0 || id == ^RowID(0) { | |||
| 57 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 58 | + | } | |||
| 59 | + | if _, duplicate := seen[id]; duplicate { | |||
| 60 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 61 | + | } | |||
| 62 | + | seen[id] = struct{}{} | |||
| 47 | cellCount := uint64(len(row)) | 63 | cellCount := uint64(len(row)) | ||
| 48 | cellBytes, ok := historyCellByteCount(cellCount) | 64 | cellBytes, ok := historyCellByteCount(cellCount) | ||
| 49 | if !ok || | 65 | if !ok || | ||
| @@ -52,39 +68,80 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||||
| 52 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | 68 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | ||
| 53 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 69 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 54 | } | 70 | } | ||
| 71 | + | bound := chunkBound(chunk, i) | |||
| 72 | + | if !validHistoryBound(bound, len(row)) { | |||
| 73 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 74 | + | } | |||
| 75 | + | for _, cell := range row { | |||
| 76 | + | if !validHistoryCell(cell) { | |||
| 77 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 55 | } | 80 | } | ||
| 56 | } | 81 | } | ||
| 82 | + | if nextRowID <= maxHistoryRowID(seen) { | |||
| 83 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 84 | + | } | |||
| 57 | 85 | ||||
| 58 | - | out := make([]byte, 0, 9) | 86 | + | out := make([]byte, 0, 17) |
| 59 | out = append(out, historyMagic...) | 87 | out = append(out, historyMagic...) | ||
| 60 | out = append(out, historyVersion) | 88 | out = append(out, historyVersion) | ||
| 61 | out = appendUint32(out, uint32(len(view.chunks))) | 89 | out = appendUint32(out, uint32(len(view.chunks))) | ||
| 90 | + | out = binary.BigEndian.AppendUint64(out, uint64(nextRowID)) | |||
| 62 | for _, chunk := range view.chunks { | 91 | for _, chunk := range view.chunks { | ||
| 63 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows { | |||
| 64 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 65 | - | } | |||
| 66 | out = appendUint32(out, uint32(len(chunk.rows))) | 92 | out = appendUint32(out, uint32(len(chunk.rows))) | ||
| 67 | for i, row := range chunk.rows { | 93 | for i, row := range chunk.rows { | ||
| 68 | - | if uint64(len(row)) > math.MaxUint32 { | 94 | + | out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i])) |
| 69 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 70 | - | } | |||
| 71 | - | bound := chunkBound(chunk, i) | |||
| 72 | - | if !validHistoryBound(bound, len(row)) { | |||
| 73 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 74 | - | } | |||
| 75 | out = appendUint32(out, uint32(len(row))) | 95 | out = appendUint32(out, uint32(len(row))) | ||
| 76 | for _, cell := range row { | 96 | for _, cell := range row { | ||
| 77 | - | if !validHistoryCell(cell) { | |||
| 78 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 79 | - | } | |||
| 80 | out = appendHistoryCell(out, cell) | 97 | out = appendHistoryCell(out, cell) | ||
| 81 | } | 98 | } | ||
| 82 | - | out = appendHistoryBound(out, bound) | 99 | + | out = appendHistoryBound(out, chunkBound(chunk, i)) |
| 83 | } | 100 | } | ||
| 84 | } | 101 | } | ||
| 85 | return out, nil | 102 | return out, nil | ||
| 86 | } | 103 | } | ||
| 87 | 104 | ||||
| 105 | + | func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||
| 106 | + | seen := make(map[RowID]struct{}, view.rows) | |||
| 107 | + | for _, chunk := range view.chunks { | |||
| 108 | + | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) { | |||
| 109 | + | return 0, false | |||
| 110 | + | } | |||
| 111 | + | for _, id := range chunk.rowIDs { | |||
| 112 | + | if id == 0 || id == ^RowID(0) { | |||
| 113 | + | return 0, false | |||
| 114 | + | } | |||
| 115 | + | if _, duplicate := seen[id]; duplicate { | |||
| 116 | + | return 0, false | |||
| 117 | + | } | |||
| 118 | + | seen[id] = struct{}{} | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | next := view.nextRowID | |||
| 122 | + | if next == 0 { | |||
| 123 | + | maxID := maxHistoryRowID(seen) | |||
| 124 | + | if maxID == ^RowID(0) { | |||
| 125 | + | return 0, false | |||
| 126 | + | } | |||
| 127 | + | next = maxID + 1 | |||
| 128 | + | } | |||
| 129 | + | if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) { | |||
| 130 | + | return 0, false | |||
| 131 | + | } | |||
| 132 | + | return next, true | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | |||
| 136 | + | var max RowID | |||
| 137 | + | for id := range ids { | |||
| 138 | + | if id > max { | |||
| 139 | + | max = id | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | return max | |||
| 143 | + | } | |||
| 144 | + | ||||
| 88 | func historyViewRowCount(view HistoryView) int { | 145 | func historyViewRowCount(view HistoryView) int { | ||
| 89 | n := 0 | 146 | n := 0 | ||
| 90 | for _, chunk := range view.chunks { | 147 | for _, chunk := range view.chunks { | ||
| @@ -189,7 +246,7 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||||
| 189 | // parseHistory is the authoritative history payload validation path. populate | 246 | // parseHistory is the authoritative history payload validation path. populate | ||
| 190 | // controls whether validated cells are retained in a HistoryView. | 247 | // controls whether validated cells are retained in a HistoryView. | ||
| 191 | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | 248 | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | ||
| 192 | - | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | 249 | + | if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion { |
| 193 | return HistoryView{}, historyDecodeStats{}, false | 250 | return HistoryView{}, historyDecodeStats{}, false | ||
| 194 | } | 251 | } | ||
| 195 | 252 | ||||
| @@ -198,16 +255,22 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 198 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | 255 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | ||
| 199 | return HistoryView{}, historyDecodeStats{}, false | 256 | return HistoryView{}, historyDecodeStats{}, false | ||
| 200 | } | 257 | } | ||
| 258 | + | nextRowID, ok := p.uint64() | |||
| 259 | + | if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) { | |||
| 260 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 261 | + | } | |||
| 201 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | 262 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | ||
| 263 | + | var seenIDs [maxHistoryRows]RowID | |||
| 264 | + | seenCount := 0 | |||
| 265 | + | var maxID RowID | |||
| 202 | var chunks []*HistoryChunk | 266 | var chunks []*HistoryChunk | ||
| 203 | - | var nextRowID RowID = 1 | |||
| 204 | if populate { | 267 | if populate { | ||
| 205 | chunks = make([]*HistoryChunk, 0, chunkCount) | 268 | chunks = make([]*HistoryChunk, 0, chunkCount) | ||
| 206 | } | 269 | } | ||
| 207 | for range chunkCount { | 270 | for range chunkCount { | ||
| 208 | rowCount, ok := p.uint32() | 271 | rowCount, ok := p.uint32() | ||
| 209 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | 272 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | ||
| 210 | - | uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) || | 273 | + | uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) || |
| 211 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | 274 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | ||
| 212 | return HistoryView{}, historyDecodeStats{}, false | 275 | return HistoryView{}, historyDecodeStats{}, false | ||
| 213 | } | 276 | } | ||
| @@ -221,6 +284,21 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 221 | rowIDs = make([]RowID, 0, rowCount) | 284 | rowIDs = make([]RowID, 0, rowCount) | ||
| 222 | } | 285 | } | ||
| 223 | for range rowCount { | 286 | for range rowCount { | ||
| 287 | + | id, ok := p.uint64() | |||
| 288 | + | if !ok || id == 0 || id == ^uint64(0) { | |||
| 289 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 290 | + | } | |||
| 291 | + | rowID := RowID(id) | |||
| 292 | + | for i := 0; i < seenCount; i++ { | |||
| 293 | + | if seenIDs[i] == rowID { | |||
| 294 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | seenIDs[seenCount] = rowID | |||
| 298 | + | seenCount++ | |||
| 299 | + | if rowID > maxID { | |||
| 300 | + | maxID = rowID | |||
| 301 | + | } | |||
| 224 | cellCount, ok := p.uint32() | 302 | cellCount, ok := p.uint32() | ||
| 225 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | 303 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | ||
| 226 | if !ok || !validCellBytes || | 304 | if !ok || !validCellBytes || | ||
| @@ -251,18 +329,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 251 | if populate { | 329 | if populate { | ||
| 252 | rows = append(rows, row) | 330 | rows = append(rows, row) | ||
| 253 | bounds = append(bounds, bound) | 331 | bounds = append(bounds, bound) | ||
| 254 | - | rowIDs = append(rowIDs, nextRowID) | 332 | + | rowIDs = append(rowIDs, rowID) |
| 255 | - | nextRowID++ | |||
| 256 | } | 333 | } | ||
| 257 | } | 334 | } | ||
| 258 | if populate { | 335 | if populate { | ||
| 259 | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | 336 | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | ||
| 260 | } | 337 | } | ||
| 261 | } | 338 | } | ||
| 262 | - | if len(p.data) != 0 { | 339 | + | if len(p.data) != 0 || RowID(nextRowID) <= maxID { |
| 263 | return HistoryView{}, historyDecodeStats{}, false | 340 | return HistoryView{}, historyDecodeStats{}, false | ||
| 264 | } | 341 | } | ||
| 265 | - | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true | 342 | + | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true |
| 266 | } | 343 | } | ||
| 267 | 344 | ||||
| 268 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | 345 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | ||
| @@ -291,6 +368,15 @@ func (p *historyParser) uint32() (uint32, bool) { | |||||
| 291 | return v, true | 368 | return v, true | ||
| 292 | } | 369 | } | ||
| 293 | 370 | ||||
| 371 | + | func (p *historyParser) uint64() (uint64, bool) { | |||
| 372 | + | if len(p.data) < 8 { | |||
| 373 | + | return 0, false | |||
| 374 | + | } | |||
| 375 | + | v := binary.BigEndian.Uint64(p.data) | |||
| 376 | + | p.data = p.data[8:] | |||
| 377 | + | return v, true | |||
| 378 | + | } | |||
| 379 | + | ||||
| 294 | func (p *historyParser) cell() (renderer.Cell, bool) { | 380 | func (p *historyParser) cell() (renderer.Cell, bool) { | ||
| 295 | if len(p.data) < historyCellBytes { | 381 | if len(p.data) < historyCellBytes { | ||
| 296 | return renderer.Cell{}, false | 382 | return renderer.Cell{}, false | ||
M pkg/vt/history_codec_bound_test.go +4 -3
| @@ -10,9 +10,10 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |
| 12 | 12 | blob, err := MarshalHistory(HistoryView{ | |
| 13 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}}, | |
| 14 | - | rows: 1, | |
| 15 | - | cells: 4, | |
| 13 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}}, | |
| 14 | + | rows: 1, | |
| 15 | + | cells: 4, | |
| 16 | + | nextRowID: 2, | |
| 16 | 17 | }) | |
| 17 | 18 | require.NoError(t, err) | |
| 18 | 19 | boundOffset := len(blob) - historyBoundBytes | |
| @@ -10,9 +10,10 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | ||
| 12 | blob, err := MarshalHistory(HistoryView{ | 12 | blob, err := MarshalHistory(HistoryView{ | ||
| 13 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}}, | 13 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}}, |
| 14 | - | rows: 1, | 14 | + | rows: 1, |
| 15 | - | cells: 4, | 15 | + | cells: 4, |
| 16 | + | nextRowID: 2, | |||
| 16 | }) | 17 | }) | ||
| 17 | require.NoError(t, err) | 18 | require.NoError(t, err) | ||
| 18 | boundOffset := len(blob) - historyBoundBytes | 19 | boundOffset := len(blob) - historyBoundBytes | ||
M pkg/vt/history_codec_test.go +43 -26
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func TestHistoryCodecV2Bytes(t *testing.T) { | |
| 13 | + | func TestHistoryCodecV3Bytes(t *testing.T) { | |
| 14 | 14 | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |
| 15 | 15 | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | |
| 16 | 16 | t.Fatalf("append: %v", err) | |
| @@ -21,9 +21,11 @@ func TestHistoryCodecV2Bytes(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | want := []byte{ | |
| 24 | - | 'V', 'T', 'H', '1', 2, // magic, version | |
| 24 | + | 'V', 'T', 'H', '1', 3, // magic, version | |
| 25 | 25 | 0, 0, 0, 1, // chunk count | |
| 26 | + | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | |
| 26 | 27 | 0, 0, 0, 1, // row count | |
| 28 | + | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | |
| 27 | 29 | 0, 0, 0, 1, // cell count | |
| 28 | 30 | 0, 0, 0, 'a', // Rune | |
| 29 | 31 | 0, // flags | |
| @@ -65,12 +67,12 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||
| 65 | 67 | } | |
| 66 | 68 | } | |
| 67 | 69 | ||
| 68 | - | func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |
| 69 | - | // Canonical v2 fixture from TestHistoryCodecV2Bytes. | |
| 70 | - | v2 := []byte{ | |
| 70 | + | func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) { | |
| 71 | + | // The previous layouts are deliberately incompatible with stable IDs. | |
| 72 | + | legacy := []byte{ | |
| 71 | 73 | 'V', 'T', 'H', '1', 2, | |
| 72 | - | 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, | |
| 73 | - | 0, 0, 0, 'a', | |
| 74 | + | 0, 0, 0, 1, 0, 0, 0, 1, | |
| 75 | + | 0, 0, 0, 1, 0, 0, 0, 'a', | |
| 74 | 76 | 0, 0, 0, | |
| 75 | 77 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | |
| 76 | 78 | 0, 0, 0, 0, 0, 0, | |
| @@ -79,21 +81,20 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||
| 79 | 81 | 0, 0, 0, | |
| 80 | 82 | 0, 0, 0, 1, 1, | |
| 81 | 83 | } | |
| 82 | - | ||
| 83 | - | // This is a genuine v1-layout payload: version 1 and no trailing bound | |
| 84 | - | // bytes. It is not a v2 payload with only its version byte changed. | |
| 85 | - | v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...) | |
| 86 | - | v1[4] = 1 | |
| 87 | - | if _, err := UnmarshalHistory(v1); err == nil { | |
| 88 | - | t.Fatal("accepted a genuine v1 history payload") | |
| 84 | + | for _, version := range []byte{1, 2} { | |
| 85 | + | data := append([]byte(nil), legacy...) | |
| 86 | + | data[4] = version | |
| 87 | + | if _, err := UnmarshalHistory(data); err == nil { | |
| 88 | + | t.Fatalf("accepted legacy history version %d", version) | |
| 89 | + | } | |
| 89 | 90 | } | |
| 90 | 91 | ||
| 91 | - | for i := 0; i < len(v2); i++ { | |
| 92 | - | if _, err := UnmarshalHistory(v2[:i]); err == nil { | |
| 92 | + | for i := 0; i < len(legacy); i++ { | |
| 93 | + | if _, err := UnmarshalHistory(legacy[:i]); err == nil { | |
| 93 | 94 | t.Fatalf("accepted truncated prefix of length %d", i) | |
| 94 | 95 | } | |
| 95 | 96 | } | |
| 96 | - | if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil { | |
| 97 | + | if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil { | |
| 97 | 98 | t.Fatal("accepted trailing garbage") | |
| 98 | 99 | } | |
| 99 | 100 | } | |
| @@ -300,31 +301,41 @@ func historyViewWithDimensions(rowCount, width int) HistoryView { | |||
| 300 | 301 | row[i].Rune = 'x' | |
| 301 | 302 | } | |
| 302 | 303 | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | |
| 304 | + | rowID := RowID(1) | |
| 303 | 305 | for rowCount > 0 { | |
| 304 | 306 | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 305 | 307 | rows := make([][]renderer.Cell, chunkRows) | |
| 308 | + | rowIDs := make([]RowID, chunkRows) | |
| 306 | 309 | for i := range rows { | |
| 307 | 310 | rows[i] = row | |
| 311 | + | rowIDs[i] = rowID | |
| 312 | + | rowID++ | |
| 308 | 313 | } | |
| 309 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 314 | + | chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs}) | |
| 310 | 315 | rowCount -= chunkRows | |
| 311 | 316 | } | |
| 312 | - | return HistoryView{chunks: chunks, rows: totalRows} | |
| 317 | + | return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID} | |
| 313 | 318 | } | |
| 314 | 319 | ||
| 315 | 320 | func historyPayloadWithDimensions(rowCount, width int) []byte { | |
| 316 | - | data := make([]byte, 9) | |
| 321 | + | data := make([]byte, 17) | |
| 317 | 322 | copy(data, historyMagic) | |
| 318 | 323 | data[4] = historyVersion | |
| 319 | 324 | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |
| 320 | 325 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 326 | + | binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1)) | |
| 327 | + | rowID := uint64(1) | |
| 321 | 328 | for rowCount > 0 { | |
| 322 | 329 | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 323 | 330 | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |
| 324 | 331 | for range chunkRows { | |
| 332 | + | data = binary.BigEndian.AppendUint64(data, rowID) | |
| 333 | + | rowID++ | |
| 325 | 334 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 326 | 335 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | |
| 327 | 336 | data = append(data, make([]byte, width*historyCellBytes)...) | |
| 337 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 338 | + | data = append(data, 0) | |
| 328 | 339 | } | |
| 329 | 340 | rowCount -= chunkRows | |
| 330 | 341 | } | |
| @@ -335,22 +346,28 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 335 | 346 | // budget, but its cells exceed the aggregate budget. It deliberately omits | |
| 336 | 347 | // cell payload bytes: the aggregate limit must reject it before allocation. | |
| 337 | 348 | func aggregateCellLimitDeclaration() []byte { | |
| 338 | - | data := make([]byte, 9, 21) | |
| 349 | + | data := make([]byte, 17, 29) | |
| 339 | 350 | copy(data, historyMagic) | |
| 340 | 351 | data[4] = historyVersion | |
| 341 | 352 | binary.BigEndian.PutUint32(data[5:], 1) | |
| 353 | + | binary.BigEndian.PutUint64(data[9:], 2) | |
| 342 | 354 | data = binary.BigEndian.AppendUint32(data, 1) | |
| 355 | + | data = binary.BigEndian.AppendUint64(data, 1) | |
| 343 | 356 | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | |
| 344 | 357 | } | |
| 345 | 358 | ||
| 346 | 359 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |
| 347 | - | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | |
| 360 | + | data := make([]byte, 17, 17+chunkCount*(4+rowCount*12)) | |
| 348 | 361 | copy(data, historyMagic) | |
| 349 | 362 | data[4] = historyVersion | |
| 350 | 363 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 364 | + | binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1)) | |
| 365 | + | id := uint64(1) | |
| 351 | 366 | for range chunkCount { | |
| 352 | 367 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | |
| 353 | 368 | for range rowCount { | |
| 369 | + | data = binary.BigEndian.AppendUint64(data, id) | |
| 370 | + | id++ | |
| 354 | 371 | data = binary.BigEndian.AppendUint32(data, 0) | |
| 355 | 372 | } | |
| 356 | 373 | } | |
| @@ -363,9 +380,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 363 | 380 | t.Fatalf("marshal valid history: %v", err) | |
| 364 | 381 | } | |
| 365 | 382 | invalidRune := append([]byte(nil), valid...) | |
| 366 | - | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | |
| 383 | + | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) | |
| 367 | 384 | invalidUnderlineStyle := append([]byte(nil), valid...) | |
| 368 | - | invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1) | |
| 385 | + | invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1) | |
| 369 | 386 | ||
| 370 | 387 | tests := []struct { | |
| 371 | 388 | name string | |
| @@ -379,7 +396,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 379 | 396 | {name: "invalid underline style", data: invalidUnderlineStyle}, | |
| 380 | 397 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |
| 381 | 398 | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | |
| 382 | - | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | |
| 399 | + | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}}, | |
| 383 | 400 | } | |
| 384 | 401 | for _, tt := range tests { | |
| 385 | 402 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -398,7 +415,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||
| 398 | 415 | f.Fatalf("marshal valid history: %v", err) | |
| 399 | 416 | } | |
| 400 | 417 | invalidRune := append([]byte(nil), valid...) | |
| 401 | - | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | |
| 418 | + | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) | |
| 402 | 419 | f.Add(valid) | |
| 403 | 420 | f.Add(invalidRune) | |
| 404 | 421 | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | func TestHistoryCodecV2Bytes(t *testing.T) { | 13 | + | func TestHistoryCodecV3Bytes(t *testing.T) { |
| 14 | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | 14 | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | ||
| 15 | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | 15 | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | ||
| 16 | t.Fatalf("append: %v", err) | 16 | t.Fatalf("append: %v", err) | ||
| @@ -21,9 +21,11 @@ func TestHistoryCodecV2Bytes(t *testing.T) { | |||||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | want := []byte{ | 23 | want := []byte{ | ||
| 24 | - | 'V', 'T', 'H', '1', 2, // magic, version | 24 | + | 'V', 'T', 'H', '1', 3, // magic, version |
| 25 | 0, 0, 0, 1, // chunk count | 25 | 0, 0, 0, 1, // chunk count | ||
| 26 | + | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | |||
| 26 | 0, 0, 0, 1, // row count | 27 | 0, 0, 0, 1, // row count | ||
| 28 | + | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | |||
| 27 | 0, 0, 0, 1, // cell count | 29 | 0, 0, 0, 1, // cell count | ||
| 28 | 0, 0, 0, 'a', // Rune | 30 | 0, 0, 0, 'a', // Rune | ||
| 29 | 0, // flags | 31 | 0, // flags | ||
| @@ -65,12 +67,12 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||||
| 65 | } | 67 | } | ||
| 66 | } | 68 | } | ||
| 67 | 69 | ||||
| 68 | - | func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | 70 | + | func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) { |
| 69 | - | // Canonical v2 fixture from TestHistoryCodecV2Bytes. | 71 | + | // The previous layouts are deliberately incompatible with stable IDs. |
| 70 | - | v2 := []byte{ | 72 | + | legacy := []byte{ |
| 71 | 'V', 'T', 'H', '1', 2, | 73 | 'V', 'T', 'H', '1', 2, | ||
| 72 | - | 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, | 74 | + | 0, 0, 0, 1, 0, 0, 0, 1, |
| 73 | - | 0, 0, 0, 'a', | 75 | + | 0, 0, 0, 1, 0, 0, 0, 'a', |
| 74 | 0, 0, 0, | 76 | 0, 0, 0, | ||
| 75 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | 77 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | ||
| 76 | 0, 0, 0, 0, 0, 0, | 78 | 0, 0, 0, 0, 0, 0, | ||
| @@ -79,21 +81,20 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||||
| 79 | 0, 0, 0, | 81 | 0, 0, 0, | ||
| 80 | 0, 0, 0, 1, 1, | 82 | 0, 0, 0, 1, 1, | ||
| 81 | } | 83 | } | ||
| 82 | - | 84 | + | for _, version := range []byte{1, 2} { | |
| 83 | - | // This is a genuine v1-layout payload: version 1 and no trailing bound | 85 | + | data := append([]byte(nil), legacy...) |
| 84 | - | // bytes. It is not a v2 payload with only its version byte changed. | 86 | + | data[4] = version |
| 85 | - | v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...) | 87 | + | if _, err := UnmarshalHistory(data); err == nil { |
| 86 | - | v1[4] = 1 | 88 | + | t.Fatalf("accepted legacy history version %d", version) |
| 87 | - | if _, err := UnmarshalHistory(v1); err == nil { | 89 | + | } |
| 88 | - | t.Fatal("accepted a genuine v1 history payload") | |||
| 89 | } | 90 | } | ||
| 90 | 91 | ||||
| 91 | - | for i := 0; i < len(v2); i++ { | 92 | + | for i := 0; i < len(legacy); i++ { |
| 92 | - | if _, err := UnmarshalHistory(v2[:i]); err == nil { | 93 | + | if _, err := UnmarshalHistory(legacy[:i]); err == nil { |
| 93 | t.Fatalf("accepted truncated prefix of length %d", i) | 94 | t.Fatalf("accepted truncated prefix of length %d", i) | ||
| 94 | } | 95 | } | ||
| 95 | } | 96 | } | ||
| 96 | - | if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil { | 97 | + | if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil { |
| 97 | t.Fatal("accepted trailing garbage") | 98 | t.Fatal("accepted trailing garbage") | ||
| 98 | } | 99 | } | ||
| 99 | } | 100 | } | ||
| @@ -300,31 +301,41 @@ func historyViewWithDimensions(rowCount, width int) HistoryView { | |||||
| 300 | row[i].Rune = 'x' | 301 | row[i].Rune = 'x' | ||
| 301 | } | 302 | } | ||
| 302 | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | 303 | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | ||
| 304 | + | rowID := RowID(1) | |||
| 303 | for rowCount > 0 { | 305 | for rowCount > 0 { | ||
| 304 | chunkRows := min(rowCount, maxHistoryChunkRows) | 306 | chunkRows := min(rowCount, maxHistoryChunkRows) | ||
| 305 | rows := make([][]renderer.Cell, chunkRows) | 307 | rows := make([][]renderer.Cell, chunkRows) | ||
| 308 | + | rowIDs := make([]RowID, chunkRows) | |||
| 306 | for i := range rows { | 309 | for i := range rows { | ||
| 307 | rows[i] = row | 310 | rows[i] = row | ||
| 311 | + | rowIDs[i] = rowID | |||
| 312 | + | rowID++ | |||
| 308 | } | 313 | } | ||
| 309 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | 314 | + | chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs}) |
| 310 | rowCount -= chunkRows | 315 | rowCount -= chunkRows | ||
| 311 | } | 316 | } | ||
| 312 | - | return HistoryView{chunks: chunks, rows: totalRows} | 317 | + | return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID} |
| 313 | } | 318 | } | ||
| 314 | 319 | ||||
| 315 | func historyPayloadWithDimensions(rowCount, width int) []byte { | 320 | func historyPayloadWithDimensions(rowCount, width int) []byte { | ||
| 316 | - | data := make([]byte, 9) | 321 | + | data := make([]byte, 17) |
| 317 | copy(data, historyMagic) | 322 | copy(data, historyMagic) | ||
| 318 | data[4] = historyVersion | 323 | data[4] = historyVersion | ||
| 319 | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | 324 | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | ||
| 320 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | 325 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | ||
| 326 | + | binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1)) | |||
| 327 | + | rowID := uint64(1) | |||
| 321 | for rowCount > 0 { | 328 | for rowCount > 0 { | ||
| 322 | chunkRows := min(rowCount, maxHistoryChunkRows) | 329 | chunkRows := min(rowCount, maxHistoryChunkRows) | ||
| 323 | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | 330 | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | ||
| 324 | for range chunkRows { | 331 | for range chunkRows { | ||
| 332 | + | data = binary.BigEndian.AppendUint64(data, rowID) | |||
| 333 | + | rowID++ | |||
| 325 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | 334 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | ||
| 326 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | 335 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | ||
| 327 | data = append(data, make([]byte, width*historyCellBytes)...) | 336 | data = append(data, make([]byte, width*historyCellBytes)...) | ||
| 337 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |||
| 338 | + | data = append(data, 0) | |||
| 328 | } | 339 | } | ||
| 329 | rowCount -= chunkRows | 340 | rowCount -= chunkRows | ||
| 330 | } | 341 | } | ||
| @@ -335,22 +346,28 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||||
| 335 | // budget, but its cells exceed the aggregate budget. It deliberately omits | 346 | // budget, but its cells exceed the aggregate budget. It deliberately omits | ||
| 336 | // cell payload bytes: the aggregate limit must reject it before allocation. | 347 | // cell payload bytes: the aggregate limit must reject it before allocation. | ||
| 337 | func aggregateCellLimitDeclaration() []byte { | 348 | func aggregateCellLimitDeclaration() []byte { | ||
| 338 | - | data := make([]byte, 9, 21) | 349 | + | data := make([]byte, 17, 29) |
| 339 | copy(data, historyMagic) | 350 | copy(data, historyMagic) | ||
| 340 | data[4] = historyVersion | 351 | data[4] = historyVersion | ||
| 341 | binary.BigEndian.PutUint32(data[5:], 1) | 352 | binary.BigEndian.PutUint32(data[5:], 1) | ||
| 353 | + | binary.BigEndian.PutUint64(data[9:], 2) | |||
| 342 | data = binary.BigEndian.AppendUint32(data, 1) | 354 | data = binary.BigEndian.AppendUint32(data, 1) | ||
| 355 | + | data = binary.BigEndian.AppendUint64(data, 1) | |||
| 343 | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | 356 | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | ||
| 344 | } | 357 | } | ||
| 345 | 358 | ||||
| 346 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | 359 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | ||
| 347 | - | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | 360 | + | data := make([]byte, 17, 17+chunkCount*(4+rowCount*12)) |
| 348 | copy(data, historyMagic) | 361 | copy(data, historyMagic) | ||
| 349 | data[4] = historyVersion | 362 | data[4] = historyVersion | ||
| 350 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | 363 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | ||
| 364 | + | binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1)) | |||
| 365 | + | id := uint64(1) | |||
| 351 | for range chunkCount { | 366 | for range chunkCount { | ||
| 352 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | 367 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | ||
| 353 | for range rowCount { | 368 | for range rowCount { | ||
| 369 | + | data = binary.BigEndian.AppendUint64(data, id) | |||
| 370 | + | id++ | |||
| 354 | data = binary.BigEndian.AppendUint32(data, 0) | 371 | data = binary.BigEndian.AppendUint32(data, 0) | ||
| 355 | } | 372 | } | ||
| 356 | } | 373 | } | ||
| @@ -363,9 +380,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||||
| 363 | t.Fatalf("marshal valid history: %v", err) | 380 | t.Fatalf("marshal valid history: %v", err) | ||
| 364 | } | 381 | } | ||
| 365 | invalidRune := append([]byte(nil), valid...) | 382 | invalidRune := append([]byte(nil), valid...) | ||
| 366 | - | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | 383 | + | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) |
| 367 | invalidUnderlineStyle := append([]byte(nil), valid...) | 384 | invalidUnderlineStyle := append([]byte(nil), valid...) | ||
| 368 | - | invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1) | 385 | + | invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1) |
| 369 | 386 | ||||
| 370 | tests := []struct { | 387 | tests := []struct { | ||
| 371 | name string | 388 | name string | ||
| @@ -379,7 +396,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||||
| 379 | {name: "invalid underline style", data: invalidUnderlineStyle}, | 396 | {name: "invalid underline style", data: invalidUnderlineStyle}, | ||
| 380 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | 397 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | ||
| 381 | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | 398 | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | ||
| 382 | - | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | 399 | + | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}}, |
| 383 | } | 400 | } | ||
| 384 | for _, tt := range tests { | 401 | for _, tt := range tests { | ||
| 385 | t.Run(tt.name, func(t *testing.T) { | 402 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -398,7 +415,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||||
| 398 | f.Fatalf("marshal valid history: %v", err) | 415 | f.Fatalf("marshal valid history: %v", err) | ||
| 399 | } | 416 | } | ||
| 400 | invalidRune := append([]byte(nil), valid...) | 417 | invalidRune := append([]byte(nil), valid...) | ||
| 401 | - | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | 418 | + | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) |
| 402 | f.Add(valid) | 419 | f.Add(valid) | ||
| 403 | f.Add(invalidRune) | 420 | f.Add(invalidRune) | ||
| 404 | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | 421 | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | ||
M pkg/vt/recovery_transcript.go +12 -3
| @@ -5,12 +5,14 @@ import "github.com/bnema/vev/pkg/renderer" | |||
| 5 | 5 | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |
| 6 | 6 | // rows that should be replayed after retained terminal history during recovery. | |
| 7 | 7 | type RecoveryTranscriptSnapshot struct { | |
| 8 | - | segments []recoveryTranscriptSegment | |
| 8 | + | segments []recoveryTranscriptSegment | |
| 9 | + | nextRowID RowID | |
| 9 | 10 | } | |
| 10 | 11 | ||
| 11 | 12 | type recoveryTranscriptSegment struct { | |
| 12 | 13 | rows [][]renderer.Cell | |
| 13 | 14 | bounds []LineBound | |
| 15 | + | rowIDs []RowID | |
| 14 | 16 | } | |
| 15 | 17 | ||
| 16 | 18 | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | |
| @@ -25,7 +27,11 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||
| 25 | 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | - | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))} | |
| 30 | + | nextRowID := s.nextRowID + 1 | |
| 31 | + | if nextRowID == 0 { | |
| 32 | + | nextRowID = ^RowID(0) | |
| 33 | + | } | |
| 34 | + | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | |
| 29 | 35 | for _, b := range buffers { | |
| 30 | 36 | segment := captureRecoveryTranscriptSegment(b) | |
| 31 | 37 | if len(segment.rows) > 0 { | |
| @@ -51,6 +57,7 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||
| 51 | 57 | segment := recoveryTranscriptSegment{ | |
| 52 | 58 | rows: make([][]renderer.Cell, rowCount), | |
| 53 | 59 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |
| 60 | + | rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...), | |
| 54 | 61 | } | |
| 55 | 62 | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |
| 56 | 63 | for y := range rowCount { | |
| @@ -88,7 +95,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 88 | 95 | } | |
| 89 | 96 | } | |
| 90 | 97 | ||
| 91 | - | view := HistoryView{rows: rows, cells: cells} | |
| 98 | + | view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID} | |
| 92 | 99 | for _, segment := range snapshot.segments { | |
| 93 | 100 | for start := 0; start < len(segment.rows); { | |
| 94 | 101 | space := maxHistoryChunkRows | |
| @@ -99,6 +106,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 99 | 106 | end := min(start+space, len(segment.rows)) | |
| 100 | 107 | last.rows = append(last.rows, segment.rows[start:end]...) | |
| 101 | 108 | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |
| 109 | + | last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...) | |
| 102 | 110 | start = end | |
| 103 | 111 | continue | |
| 104 | 112 | } | |
| @@ -108,6 +116,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 108 | 116 | view.chunks = append(view.chunks, &HistoryChunk{ | |
| 109 | 117 | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | |
| 110 | 118 | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | |
| 119 | + | rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...), | |
| 111 | 120 | }) | |
| 112 | 121 | start = end | |
| 113 | 122 | } | |
| @@ -5,12 +5,14 @@ import "github.com/bnema/vev/pkg/renderer" | |||||
| 5 | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | 5 | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | ||
| 6 | // rows that should be replayed after retained terminal history during recovery. | 6 | // rows that should be replayed after retained terminal history during recovery. | ||
| 7 | type RecoveryTranscriptSnapshot struct { | 7 | type RecoveryTranscriptSnapshot struct { | ||
| 8 | - | segments []recoveryTranscriptSegment | 8 | + | segments []recoveryTranscriptSegment |
| 9 | + | nextRowID RowID | |||
| 9 | } | 10 | } | ||
| 10 | 11 | ||||
| 11 | type recoveryTranscriptSegment struct { | 12 | type recoveryTranscriptSegment struct { | ||
| 12 | rows [][]renderer.Cell | 13 | rows [][]renderer.Cell | ||
| 13 | bounds []LineBound | 14 | bounds []LineBound | ||
| 15 | + | rowIDs []RowID | |||
| 14 | } | 16 | } | ||
| 15 | 17 | ||||
| 16 | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | 18 | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | ||
| @@ -25,7 +27,11 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||||
| 25 | buffers = []*buffer{s.alternate.buffer, s.buffer} | 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | ||
| 26 | } | 28 | } | ||
| 27 | 29 | ||||
| 28 | - | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))} | 30 | + | nextRowID := s.nextRowID + 1 |
| 31 | + | if nextRowID == 0 { | |||
| 32 | + | nextRowID = ^RowID(0) | |||
| 33 | + | } | |||
| 34 | + | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | |||
| 29 | for _, b := range buffers { | 35 | for _, b := range buffers { | ||
| 30 | segment := captureRecoveryTranscriptSegment(b) | 36 | segment := captureRecoveryTranscriptSegment(b) | ||
| 31 | if len(segment.rows) > 0 { | 37 | if len(segment.rows) > 0 { | ||
| @@ -51,6 +57,7 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||||
| 51 | segment := recoveryTranscriptSegment{ | 57 | segment := recoveryTranscriptSegment{ | ||
| 52 | rows: make([][]renderer.Cell, rowCount), | 58 | rows: make([][]renderer.Cell, rowCount), | ||
| 53 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | 59 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | ||
| 60 | + | rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...), | |||
| 54 | } | 61 | } | ||
| 55 | cells := make([]renderer.Cell, rowCount*b.frame.Width) | 62 | cells := make([]renderer.Cell, rowCount*b.frame.Width) | ||
| 56 | for y := range rowCount { | 63 | for y := range rowCount { | ||
| @@ -88,7 +95,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||||
| 88 | } | 95 | } | ||
| 89 | } | 96 | } | ||
| 90 | 97 | ||||
| 91 | - | view := HistoryView{rows: rows, cells: cells} | 98 | + | view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID} |
| 92 | for _, segment := range snapshot.segments { | 99 | for _, segment := range snapshot.segments { | ||
| 93 | for start := 0; start < len(segment.rows); { | 100 | for start := 0; start < len(segment.rows); { | ||
| 94 | space := maxHistoryChunkRows | 101 | space := maxHistoryChunkRows | ||
| @@ -99,6 +106,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||||
| 99 | end := min(start+space, len(segment.rows)) | 106 | end := min(start+space, len(segment.rows)) | ||
| 100 | last.rows = append(last.rows, segment.rows[start:end]...) | 107 | last.rows = append(last.rows, segment.rows[start:end]...) | ||
| 101 | last.bounds = append(last.bounds, segment.bounds[start:end]...) | 108 | last.bounds = append(last.bounds, segment.bounds[start:end]...) | ||
| 109 | + | last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...) | |||
| 102 | start = end | 110 | start = end | ||
| 103 | continue | 111 | continue | ||
| 104 | } | 112 | } | ||
| @@ -108,6 +116,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||||
| 108 | view.chunks = append(view.chunks, &HistoryChunk{ | 116 | view.chunks = append(view.chunks, &HistoryChunk{ | ||
| 109 | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | 117 | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | ||
| 110 | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | 118 | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | ||
| 119 | + | rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...), | |||
| 111 | }) | 120 | }) | ||
| 112 | start = end | 121 | start = end | ||
| 113 | } | 122 | } | ||
M pkg/vt/recovery_transcript_test.go +7 -3
| @@ -188,7 +188,7 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||
| 188 | 188 | ||
| 189 | 189 | got, err := s.RecoveryTranscriptSnapshot().Marshal() | |
| 190 | 190 | require.NoError(t, err) | |
| 191 | - | want, err := MarshalHistory(HistoryView{}) | |
| 191 | + | want, err := MarshalHistory(HistoryView{nextRowID: 3}) | |
| 192 | 192 | require.NoError(t, err) | |
| 193 | 193 | ||
| 194 | 194 | require.Equal(t, want, got) | |
| @@ -396,9 +396,13 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) [] | |||
| 396 | 396 | bounds[i].End = len(row) | |
| 397 | 397 | } | |
| 398 | 398 | } | |
| 399 | - | view := HistoryView{rows: len(rows), cells: cells} | |
| 399 | + | rowIDs := make([]RowID, len(rows)) | |
| 400 | + | for i := range rowIDs { | |
| 401 | + | rowIDs[i] = RowID(100 + i) | |
| 402 | + | } | |
| 403 | + | view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))} | |
| 400 | 404 | if len(rows) > 0 { | |
| 401 | - | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}} | |
| 405 | + | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}} | |
| 402 | 406 | } | |
| 403 | 407 | blob, err := MarshalHistory(view) | |
| 404 | 408 | require.NoError(t, err) | |
| @@ -188,7 +188,7 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||||
| 188 | 188 | ||||
| 189 | got, err := s.RecoveryTranscriptSnapshot().Marshal() | 189 | got, err := s.RecoveryTranscriptSnapshot().Marshal() | ||
| 190 | require.NoError(t, err) | 190 | require.NoError(t, err) | ||
| 191 | - | want, err := MarshalHistory(HistoryView{}) | 191 | + | want, err := MarshalHistory(HistoryView{nextRowID: 3}) |
| 192 | require.NoError(t, err) | 192 | require.NoError(t, err) | ||
| 193 | 193 | ||||
| 194 | require.Equal(t, want, got) | 194 | require.Equal(t, want, got) | ||
| @@ -396,9 +396,13 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) [] | |||||
| 396 | bounds[i].End = len(row) | 396 | bounds[i].End = len(row) | ||
| 397 | } | 397 | } | ||
| 398 | } | 398 | } | ||
| 399 | - | view := HistoryView{rows: len(rows), cells: cells} | 399 | + | rowIDs := make([]RowID, len(rows)) |
| 400 | + | for i := range rowIDs { | |||
| 401 | + | rowIDs[i] = RowID(100 + i) | |||
| 402 | + | } | |||
| 403 | + | view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))} | |||
| 400 | if len(rows) > 0 { | 404 | if len(rows) > 0 { | ||
| 401 | - | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}} | 405 | + | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}} |
| 402 | } | 406 | } | ||
| 403 | blob, err := MarshalHistory(view) | 407 | blob, err := MarshalHistory(view) | ||
| 404 | require.NoError(t, err) | 408 | require.NoError(t, err) | ||
A pkg/vt/row_id_persistence_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |
| 12 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4}) | |
| 13 | + | require.NoError(t, history.Append(historyRow("a"), LineBound{End: 1})) | |
| 14 | + | require.NoError(t, history.Append(historyRow("b"), LineBound{End: 1})) | |
| 15 | + | encoded, err := MarshalHistory(history.SealAndView()) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | ||
| 18 | + | firstIDOffset := 17 + 4 | |
| 19 | + | secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes | |
| 20 | + | for _, test := range []struct { | |
| 21 | + | name string | |
| 22 | + | mutate func([]byte) | |
| 23 | + | }{ | |
| 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]) }}, | |
| 26 | + | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | |
| 27 | + | } { | |
| 28 | + | t.Run(test.name, func(t *testing.T) { | |
| 29 | + | data := append([]byte(nil), encoded...) | |
| 30 | + | test.mutate(data) | |
| 31 | + | _, unmarshalErr := UnmarshalHistory(data) | |
| 32 | + | require.Error(t, unmarshalErr) | |
| 33 | + | _, preflightErr := PreflightHistoryBlob(data) | |
| 34 | + | require.Error(t, preflightErr) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |
| 40 | + | _, err := MarshalHistory(HistoryView{ | |
| 41 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | |
| 42 | + | rows: 1, | |
| 43 | + | }) | |
| 44 | + | require.Error(t, err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |
| 48 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | |
| 49 | + | require.NoError(t, history.Append(historyRow("old"), LineBound{End: 3}, RowID(40))) | |
| 50 | + | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | transcript, err := MarshalHistory(HistoryView{ | |
| 53 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}}, | |
| 54 | + | rows: 1, | |
| 55 | + | nextRowID: 51, | |
| 56 | + | }) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | ||
| 59 | + | screen, err := NewScreenWithRecoveryTranscript(3, 1, HistoryConfig{MaxRows: 8, ChunkRows: 2}, sealed, tail, transcript) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, RowID(40), screen.History().View().RowID(0)) | |
| 62 | + | require.Equal(t, RowID(50), screen.History().View().RowID(1)) | |
| 63 | + | require.Equal(t, RowID(51), screen.History().NextRowID()) | |
| 64 | + | require.Greater(t, screen.RowID(0), RowID(50)) | |
| 65 | + | ||
| 66 | + | before := screen.RowID(0) | |
| 67 | + | screen.Write([]byte("\x1b[2J")) | |
| 68 | + | require.Greater(t, screen.RowID(0), before) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | |
| 72 | + | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 73 | + | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 74 | + | require.NoError(t, first.Append(historyRow("one"), LineBound{End: 3}, RowID(7))) | |
| 75 | + | require.NoError(t, second.Append(historyRow("two"), LineBound{End: 3}, RowID(7))) | |
| 76 | + | firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0)) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0)) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | tail, err := MarshalEmptyHistoryTail() | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 1}, [][]byte{firstBlob, secondBlob}, tail) | |
| 84 | + | require.Error(t, err) | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||
| 12 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4}) | |||
| 13 | + | require.NoError(t, history.Append(historyRow("a"), LineBound{End: 1})) | |||
| 14 | + | require.NoError(t, history.Append(historyRow("b"), LineBound{End: 1})) | |||
| 15 | + | encoded, err := MarshalHistory(history.SealAndView()) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | ||||
| 18 | + | firstIDOffset := 17 + 4 | |||
| 19 | + | secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes | |||
| 20 | + | for _, test := range []struct { | |||
| 21 | + | name string | |||
| 22 | + | mutate func([]byte) | |||
| 23 | + | }{ | |||
| 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]) }}, | |||
| 26 | + | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | |||
| 27 | + | } { | |||
| 28 | + | t.Run(test.name, func(t *testing.T) { | |||
| 29 | + | data := append([]byte(nil), encoded...) | |||
| 30 | + | test.mutate(data) | |||
| 31 | + | _, unmarshalErr := UnmarshalHistory(data) | |||
| 32 | + | require.Error(t, unmarshalErr) | |||
| 33 | + | _, preflightErr := PreflightHistoryBlob(data) | |||
| 34 | + | require.Error(t, preflightErr) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |||
| 40 | + | _, err := MarshalHistory(HistoryView{ | |||
| 41 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | |||
| 42 | + | rows: 1, | |||
| 43 | + | }) | |||
| 44 | + | require.Error(t, err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||
| 48 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | |||
| 49 | + | require.NoError(t, history.Append(historyRow("old"), LineBound{End: 3}, RowID(40))) | |||
| 50 | + | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | transcript, err := MarshalHistory(HistoryView{ | |||
| 53 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}}, | |||
| 54 | + | rows: 1, | |||
| 55 | + | nextRowID: 51, | |||
| 56 | + | }) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | ||||
| 59 | + | screen, err := NewScreenWithRecoveryTranscript(3, 1, HistoryConfig{MaxRows: 8, ChunkRows: 2}, sealed, tail, transcript) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Equal(t, RowID(40), screen.History().View().RowID(0)) | |||
| 62 | + | require.Equal(t, RowID(50), screen.History().View().RowID(1)) | |||
| 63 | + | require.Equal(t, RowID(51), screen.History().NextRowID()) | |||
| 64 | + | require.Greater(t, screen.RowID(0), RowID(50)) | |||
| 65 | + | ||||
| 66 | + | before := screen.RowID(0) | |||
| 67 | + | screen.Write([]byte("\x1b[2J")) | |||
| 68 | + | require.Greater(t, screen.RowID(0), before) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | |||
| 72 | + | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 73 | + | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 74 | + | require.NoError(t, first.Append(historyRow("one"), LineBound{End: 3}, RowID(7))) | |||
| 75 | + | require.NoError(t, second.Append(historyRow("two"), LineBound{End: 3}, RowID(7))) | |||
| 76 | + | firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0)) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0)) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | tail, err := MarshalEmptyHistoryTail() | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | ||||
| 83 | + | _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 1}, [][]byte{firstBlob, secondBlob}, tail) | |||
| 84 | + | require.Error(t, err) | |||
| 85 | + | } | |||
M pkg/vt/screen_snapshot.go +30 -7
| @@ -25,11 +25,13 @@ type ModeSnapshot struct { | |||
| 25 | 25 | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | |
| 26 | 26 | // mutated and remains valid while the snapshot is retained. | |
| 27 | 27 | type ScreenSnapshot struct { | |
| 28 | - | frame renderer.Frame | |
| 29 | - | bounds []LineBound | |
| 30 | - | cursor CursorSnapshot | |
| 31 | - | modes ModeSnapshot | |
| 32 | - | title string | |
| 28 | + | frame renderer.Frame | |
| 29 | + | bounds []LineBound | |
| 30 | + | rowIDs []RowID | |
| 31 | + | cursor CursorSnapshot | |
| 32 | + | modes ModeSnapshot | |
| 33 | + | title string | |
| 34 | + | nextRowID RowID | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | // Snapshot captures the active visible viewport without mutating Screen, | |
| @@ -42,8 +44,10 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 42 | 44 | cursorStyle, cursorStyleSet := s.CursorStyle() | |
| 43 | 45 | mouseTracking, mouseSGR := s.MouseMode() | |
| 44 | 46 | return ScreenSnapshot{ | |
| 45 | - | frame: s.Frame.Clone(), | |
| 46 | - | bounds: s.LineBounds(), | |
| 47 | + | frame: s.Frame.Clone(), | |
| 48 | + | bounds: s.LineBounds(), | |
| 49 | + | rowIDs: s.RowIDs(), | |
| 50 | + | nextRowID: s.nextRowID + 1, | |
| 47 | 51 | cursor: CursorSnapshot{ | |
| 48 | 52 | Row: s.CursorRow(), | |
| 49 | 53 | Col: s.CursorCol(), | |
| @@ -84,6 +88,25 @@ func (s ScreenSnapshot) Bound(y int) LineBound { | |||
| 84 | 88 | return s.bounds[y] | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 91 | + | // RowIDs returns an owned copy of the visible physical-row identities. | |
| 92 | + | func (s ScreenSnapshot) RowIDs() []RowID { return append([]RowID(nil), s.rowIDs...) } | |
| 93 | + | ||
| 94 | + | // RowID returns the identity of visible row y, or zero when out of range. | |
| 95 | + | func (s ScreenSnapshot) RowID(y int) RowID { | |
| 96 | + | if y < 0 || y >= len(s.rowIDs) { | |
| 97 | + | return 0 | |
| 98 | + | } | |
| 99 | + | return s.rowIDs[y] | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // NextRowID returns the next identity allocated by the captured screen. | |
| 103 | + | func (s ScreenSnapshot) NextRowID() RowID { | |
| 104 | + | if s.nextRowID == 0 { | |
| 105 | + | return 1 | |
| 106 | + | } | |
| 107 | + | return s.nextRowID | |
| 108 | + | } | |
| 109 | + | ||
| 87 | 110 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | |
| 88 | 111 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | |
| 89 | 112 | func (s ScreenSnapshot) Title() string { return s.title } | |
| @@ -25,11 +25,13 @@ type ModeSnapshot struct { | |||||
| 25 | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | 25 | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | ||
| 26 | // mutated and remains valid while the snapshot is retained. | 26 | // mutated and remains valid while the snapshot is retained. | ||
| 27 | type ScreenSnapshot struct { | 27 | type ScreenSnapshot struct { | ||
| 28 | - | frame renderer.Frame | 28 | + | frame renderer.Frame |
| 29 | - | bounds []LineBound | 29 | + | bounds []LineBound |
| 30 | - | cursor CursorSnapshot | 30 | + | rowIDs []RowID |
| 31 | - | modes ModeSnapshot | 31 | + | cursor CursorSnapshot |
| 32 | - | title string | 32 | + | modes ModeSnapshot |
| 33 | + | title string | |||
| 34 | + | nextRowID RowID | |||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 35 | // Snapshot captures the active visible viewport without mutating Screen, | 37 | // Snapshot captures the active visible viewport without mutating Screen, | ||
| @@ -42,8 +44,10 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||||
| 42 | cursorStyle, cursorStyleSet := s.CursorStyle() | 44 | cursorStyle, cursorStyleSet := s.CursorStyle() | ||
| 43 | mouseTracking, mouseSGR := s.MouseMode() | 45 | mouseTracking, mouseSGR := s.MouseMode() | ||
| 44 | return ScreenSnapshot{ | 46 | return ScreenSnapshot{ | ||
| 45 | - | frame: s.Frame.Clone(), | 47 | + | frame: s.Frame.Clone(), |
| 46 | - | bounds: s.LineBounds(), | 48 | + | bounds: s.LineBounds(), |
| 49 | + | rowIDs: s.RowIDs(), | |||
| 50 | + | nextRowID: s.nextRowID + 1, | |||
| 47 | cursor: CursorSnapshot{ | 51 | cursor: CursorSnapshot{ | ||
| 48 | Row: s.CursorRow(), | 52 | Row: s.CursorRow(), | ||
| 49 | Col: s.CursorCol(), | 53 | Col: s.CursorCol(), | ||
| @@ -84,6 +88,25 @@ func (s ScreenSnapshot) Bound(y int) LineBound { | |||||
| 84 | return s.bounds[y] | 88 | return s.bounds[y] | ||
| 85 | } | 89 | } | ||
| 86 | 90 | ||||
| 91 | + | // RowIDs returns an owned copy of the visible physical-row identities. | |||
| 92 | + | func (s ScreenSnapshot) RowIDs() []RowID { return append([]RowID(nil), s.rowIDs...) } | |||
| 93 | + | ||||
| 94 | + | // RowID returns the identity of visible row y, or zero when out of range. | |||
| 95 | + | func (s ScreenSnapshot) RowID(y int) RowID { | |||
| 96 | + | if y < 0 || y >= len(s.rowIDs) { | |||
| 97 | + | return 0 | |||
| 98 | + | } | |||
| 99 | + | return s.rowIDs[y] | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // NextRowID returns the next identity allocated by the captured screen. | |||
| 103 | + | func (s ScreenSnapshot) NextRowID() RowID { | |||
| 104 | + | if s.nextRowID == 0 { | |||
| 105 | + | return 1 | |||
| 106 | + | } | |||
| 107 | + | return s.nextRowID | |||
| 108 | + | } | |||
| 109 | + | ||||
| 87 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | 110 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | ||
| 88 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | 111 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | ||
| 89 | func (s ScreenSnapshot) Title() string { return s.title } | 112 | func (s ScreenSnapshot) Title() string { return s.title } | ||
M pkg/vt/snapshot.go +43 -19
| @@ -66,7 +66,7 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||
| 66 | 66 | } | |
| 67 | 67 | sealed[i] = blob | |
| 68 | 68 | } | |
| 69 | - | tail, err := MarshalEmptyHistoryTail() | |
| 69 | + | tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()}) | |
| 70 | 70 | if err != nil { | |
| 71 | 71 | return nil, nil, err | |
| 72 | 72 | } | |
| @@ -77,33 +77,30 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||
| 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 | 79 | h := NewHistory(config) | |
| 80 | + | seen := make(map[RowID]struct{}) | |
| 80 | 81 | for _, blob := range sealed { | |
| 81 | 82 | view, err := UnmarshalHistory(blob) | |
| 82 | - | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 { | |
| 83 | + | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { | |
| 83 | 84 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |
| 84 | 85 | } | |
| 85 | - | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 86 | - | h.assignChunkIDs(view.chunks[0]) | |
| 87 | - | h.chunks = append(h.chunks, view.chunks[0]) | |
| 88 | - | h.rows += len(view.chunks[0].rows) | |
| 86 | + | chunk := view.chunks[0] | |
| 87 | + | h.nextRowID = max(h.nextRowID, view.nextRowID) | |
| 88 | + | h.evictUntil(len(chunk.rows), view.Cells()) | |
| 89 | + | h.chunks = append(h.chunks, chunk) | |
| 90 | + | h.rows += len(chunk.rows) | |
| 89 | 91 | h.cells += view.Cells() | |
| 90 | 92 | } | |
| 91 | 93 | view, err := UnmarshalHistory(tail) | |
| 92 | - | if err != nil || len(view.chunks) > 1 { | |
| 94 | + | if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) { | |
| 93 | 95 | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |
| 94 | 96 | } | |
| 97 | + | h.nextRowID = max(h.nextRowID, view.nextRowID) | |
| 95 | 98 | if len(view.chunks) == 1 { | |
| 96 | - | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 97 | - | h.tail = view.chunks[0].rows | |
| 98 | - | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | |
| 99 | - | h.tailIDs = growRowIDs(view.chunks[0].rowIDs, len(h.tail)) | |
| 100 | - | for i := range h.tailIDs { | |
| 101 | - | if h.tailIDs[i] == 0 || h.hasRowID(h.tailIDs[i]) { | |
| 102 | - | h.tailIDs[i] = h.allocateRowID() | |
| 103 | - | } else if h.tailIDs[i] >= h.nextRowID { | |
| 104 | - | h.nextRowID = h.tailIDs[i] + 1 | |
| 105 | - | } | |
| 106 | - | } | |
| 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...) | |
| 107 | 104 | h.rows += len(h.tail) | |
| 108 | 105 | h.cells += view.Cells() | |
| 109 | 106 | } | |
| @@ -112,6 +109,24 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 112 | 109 | return h, nil | |
| 113 | 110 | } | |
| 114 | 111 | ||
| 112 | + | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | |
| 113 | + | for _, chunk := range view.chunks { | |
| 114 | + | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | for _, id := range chunk.rowIDs { | |
| 118 | + | if id == 0 || id == ^RowID(0) { | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | if _, duplicate := seen[id]; duplicate { | |
| 122 | + | return false | |
| 123 | + | } | |
| 124 | + | seen[id] = struct{}{} | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return true | |
| 128 | + | } | |
| 129 | + | ||
| 115 | 130 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |
| 116 | 131 | // history contains the restored bounded history followed by the recovery | |
| 117 | 132 | // transcript. The transcript is decoded in full before history is restored. | |
| @@ -133,7 +148,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 133 | 148 | if remainingRows == 0 { | |
| 134 | 149 | bound.Soft = false | |
| 135 | 150 | } | |
| 136 | - | if err := history.Append(row, bound); err != nil { | |
| 151 | + | if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil { | |
| 137 | 152 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 138 | 153 | } | |
| 139 | 154 | } | |
| @@ -141,5 +156,14 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 141 | 156 | ||
| 142 | 157 | screen := NewScreenWithHistory(width, height, config) | |
| 143 | 158 | screen.history = history | |
| 159 | + | counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1)) | |
| 160 | + | if counter == ^RowID(0) { | |
| 161 | + | return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |
| 162 | + | } | |
| 163 | + | screen.nextRowID = counter - 1 | |
| 164 | + | for i := range screen.buffer.rowIDs { | |
| 165 | + | screen.buffer.rowIDs[i] = screen.nextRowIDValue() | |
| 166 | + | } | |
| 167 | + | screen.Frame = screen.buffer.frame | |
| 144 | 168 | return screen, nil | |
| 145 | 169 | } | |
| @@ -66,7 +66,7 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||||
| 66 | } | 66 | } | ||
| 67 | sealed[i] = blob | 67 | sealed[i] = blob | ||
| 68 | } | 68 | } | ||
| 69 | - | tail, err := MarshalEmptyHistoryTail() | 69 | + | tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()}) |
| 70 | if err != nil { | 70 | if err != nil { | ||
| 71 | return nil, nil, err | 71 | return nil, nil, err | ||
| 72 | } | 72 | } | ||
| @@ -77,33 +77,30 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||||
| 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 | h := NewHistory(config) | ||
| 80 | + | seen := make(map[RowID]struct{}) | |||
| 80 | for _, blob := range sealed { | 81 | for _, blob := range sealed { | ||
| 81 | view, err := UnmarshalHistory(blob) | 82 | view, err := UnmarshalHistory(blob) | ||
| 82 | - | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 { | 83 | + | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { |
| 83 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | 84 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | ||
| 84 | } | 85 | } | ||
| 85 | - | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | 86 | + | chunk := view.chunks[0] |
| 86 | - | h.assignChunkIDs(view.chunks[0]) | 87 | + | h.nextRowID = max(h.nextRowID, view.nextRowID) |
| 87 | - | h.chunks = append(h.chunks, view.chunks[0]) | 88 | + | h.evictUntil(len(chunk.rows), view.Cells()) |
| 88 | - | h.rows += len(view.chunks[0].rows) | 89 | + | h.chunks = append(h.chunks, chunk) |
| 90 | + | h.rows += len(chunk.rows) | |||
| 89 | h.cells += view.Cells() | 91 | h.cells += view.Cells() | ||
| 90 | } | 92 | } | ||
| 91 | view, err := UnmarshalHistory(tail) | 93 | view, err := UnmarshalHistory(tail) | ||
| 92 | - | if err != nil || len(view.chunks) > 1 { | 94 | + | if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) { |
| 93 | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | 95 | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | ||
| 94 | } | 96 | } | ||
| 97 | + | h.nextRowID = max(h.nextRowID, view.nextRowID) | |||
| 95 | if len(view.chunks) == 1 { | 98 | if len(view.chunks) == 1 { | ||
| 96 | - | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | 99 | + | chunk := view.chunks[0] |
| 97 | - | h.tail = view.chunks[0].rows | 100 | + | h.evictUntil(len(chunk.rows), view.Cells()) |
| 98 | - | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | 101 | + | h.tail = chunk.rows |
| 99 | - | h.tailIDs = growRowIDs(view.chunks[0].rowIDs, len(h.tail)) | 102 | + | h.tailBounds = append([]LineBound(nil), chunk.bounds...) |
| 100 | - | for i := range h.tailIDs { | 103 | + | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) |
| 101 | - | if h.tailIDs[i] == 0 || h.hasRowID(h.tailIDs[i]) { | |||
| 102 | - | h.tailIDs[i] = h.allocateRowID() | |||
| 103 | - | } else if h.tailIDs[i] >= h.nextRowID { | |||
| 104 | - | h.nextRowID = h.tailIDs[i] + 1 | |||
| 105 | - | } | |||
| 106 | - | } | |||
| 107 | h.rows += len(h.tail) | 104 | h.rows += len(h.tail) | ||
| 108 | h.cells += view.Cells() | 105 | h.cells += view.Cells() | ||
| 109 | } | 106 | } | ||
| @@ -112,6 +109,24 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 112 | return h, nil | 109 | return h, nil | ||
| 113 | } | 110 | } | ||
| 114 | 111 | ||||
| 112 | + | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | |||
| 113 | + | for _, chunk := range view.chunks { | |||
| 114 | + | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | for _, id := range chunk.rowIDs { | |||
| 118 | + | if id == 0 || id == ^RowID(0) { | |||
| 119 | + | return false | |||
| 120 | + | } | |||
| 121 | + | if _, duplicate := seen[id]; duplicate { | |||
| 122 | + | return false | |||
| 123 | + | } | |||
| 124 | + | seen[id] = struct{}{} | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | return true | |||
| 128 | + | } | |||
| 129 | + | ||||
| 115 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | 130 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | ||
| 116 | // history contains the restored bounded history followed by the recovery | 131 | // history contains the restored bounded history followed by the recovery | ||
| 117 | // transcript. The transcript is decoded in full before history is restored. | 132 | // transcript. The transcript is decoded in full before history is restored. | ||
| @@ -133,7 +148,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 133 | if remainingRows == 0 { | 148 | if remainingRows == 0 { | ||
| 134 | bound.Soft = false | 149 | bound.Soft = false | ||
| 135 | } | 150 | } | ||
| 136 | - | if err := history.Append(row, bound); err != nil { | 151 | + | if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil { |
| 137 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | 152 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | ||
| 138 | } | 153 | } | ||
| 139 | } | 154 | } | ||
| @@ -141,5 +156,14 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 141 | 156 | ||||
| 142 | screen := NewScreenWithHistory(width, height, config) | 157 | screen := NewScreenWithHistory(width, height, config) | ||
| 143 | screen.history = history | 158 | screen.history = history | ||
| 159 | + | counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1)) | |||
| 160 | + | if counter == ^RowID(0) { | |||
| 161 | + | return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |||
| 162 | + | } | |||
| 163 | + | screen.nextRowID = counter - 1 | |||
| 164 | + | for i := range screen.buffer.rowIDs { | |||
| 165 | + | screen.buffer.rowIDs[i] = screen.nextRowIDValue() | |||
| 166 | + | } | |||
| 167 | + | screen.Frame = screen.buffer.frame | |||
| 144 | return screen, nil | 168 | return screen, nil | ||
| 145 | } | 169 | } | ||
M pkg/vt/snapshot_test.go +3 -2
| @@ -44,8 +44,9 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||
| 44 | 44 | ||
| 45 | 45 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |
| 46 | 46 | fullTail, err := MarshalHistory(HistoryView{ | |
| 47 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}}}, | |
| 48 | - | rows: 2, | |
| 47 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}}, | |
| 48 | + | rows: 2, | |
| 49 | + | nextRowID: 3, | |
| 49 | 50 | }) | |
| 50 | 51 | require.NoError(t, err) | |
| 51 | 52 | ||
| @@ -44,8 +44,9 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||||
| 44 | 44 | ||||
| 45 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | 45 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | ||
| 46 | fullTail, err := MarshalHistory(HistoryView{ | 46 | fullTail, err := MarshalHistory(HistoryView{ | ||
| 47 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}}}, | 47 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}}, |
| 48 | - | rows: 2, | 48 | + | rows: 2, |
| 49 | + | nextRowID: 3, | |||
| 49 | }) | 50 | }) | ||
| 50 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| 51 | 52 | ||||