detached 8a3d00a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
458458 // wrapped out of. Tests that care about wrapped extents build the bound instead.
459459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
460460 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)))
462462 }
463463
464464 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
169169 // general history encoding. An empty tail is represented by its dedicated
170170 // canonical blob; a non-empty copied tail retains its rows.
171171 func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) {
172- if tail.Len() == 0 {
173- return vt.MarshalEmptyHistoryTail()
174- }
175172 return vt.MarshalHistory(tail)
176173 }
177174
@@ -169,9 +169,6 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
169 // general history encoding. An empty tail is represented by its dedicated169 // 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) {
227227 if len(sealed) == 0 {
228228 t.Fatal("expected a sealed chunk")
229229 }
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:]...)
234235 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
235236 emptyTranscript := transcriptBlob(t, nil)
236237
@@ -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 (
1111 // cell budget. The history is not modified when this error is returned.
1212 var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
1313
14+var errInvalidHistoryRowID = errors.New("invalid history row ID")
15+
1416 // HistoryConfig controls the bounded terminal history retained by a Screen.
1517 type HistoryConfig struct {
1618 MaxRows int
@@ -46,9 +48,10 @@ type HistoryChunk struct {
4648 // HistoryView is an immutable snapshot of history. Row returns a copy so the
4749 // storage behind a sealed chunk remains owned by VT.
4850 type HistoryView struct {
49- chunks []*HistoryChunk
50- rows int
51- cells int
51+ chunks []*HistoryChunk
52+ rows int
53+ cells int
54+ nextRowID RowID
5255 }
5356
5457 // HistorySnapshotView captures sealed history chunks and the mutable tail
@@ -61,6 +64,7 @@ type HistorySnapshotView struct {
6164 tailIDs []RowID
6265 rows int
6366 cells int
67+ nextRowID RowID
6468 }
6569
6670 func NewHistory(config HistoryConfig) *History {
@@ -104,7 +108,30 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err
104108 }
105109 if id == 0 || h.hasRowID(id) {
106110 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 {
108135 h.nextRowID = id + 1
109136 }
110137 // Make room before adding so Cells cannot overflow even when the default
@@ -139,20 +166,6 @@ func (h *History) allocateRowID() RowID {
139166 return id
140167 }
141168
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-
156169 func (h *History) hasRowID(id RowID) bool {
157170 for _, chunk := range h.chunks {
158171 for _, candidate := range chunk.rowIDs {
@@ -288,9 +301,12 @@ func (h *History) SealAndView() HistoryView {
288301 // View captures the current history. Sealed chunks are shared by identity; a
289302 // partially-filled tail is copied into a new immutable chunk for this view.
290303 func (h *History) View() HistoryView {
291- if h == nil || h.rows == 0 {
304+ if h == nil {
292305 return HistoryView{}
293306 }
307+ if h.rows == 0 {
308+ return HistoryView{nextRowID: h.nextRowID}
309+ }
294310 chunks := append([]*HistoryChunk(nil), h.chunks...)
295311 if len(h.tail) > 0 {
296312 chunks = append(chunks, &HistoryChunk{
@@ -299,15 +315,18 @@ func (h *History) View() HistoryView {
299315 rowIDs: growRowIDs(h.tailIDs, len(h.tail)),
300316 })
301317 }
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}
303319 }
304320
305321 // SnapshotView captures history for persistence without sealing the mutable
306322 // tail. Sealed chunks are shared by identity and the tail is deeply copied.
307323 func (h *History) SnapshotView() HistorySnapshotView {
308- if h == nil || h.rows == 0 {
324+ if h == nil {
309325 return HistorySnapshotView{}
310326 }
327+ if h.rows == 0 {
328+ return HistorySnapshotView{nextRowID: h.nextRowID}
329+ }
311330 return HistorySnapshotView{
312331 chunks: append([]*HistoryChunk(nil), h.chunks...),
313332 tail: cloneHistoryRows(h.tail),
@@ -315,6 +334,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
315334 tailIDs: growRowIDs(h.tailIDs, len(h.tail)),
316335 rows: h.rows,
317336 cells: h.cells,
337+ nextRowID: h.nextRowID,
318338 }
319339 }
320340
@@ -345,6 +365,14 @@ func (h *History) Cells() int {
345365 return h.cells
346366 }
347367
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+
348376 // Cap returns the configured bounded row capacity.
349377 func (h *History) Cap() int {
350378 if h == nil {
@@ -365,6 +393,18 @@ func (v HistoryView) Len() int { return v.rows }
365393 func (v HistoryView) Cells() int { return v.cells }
366394 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
367395
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+
368408 // Chunk returns the immutable chunk at i, or nil when i is out of range.
369409 func (v HistoryView) Chunk(i int) *HistoryChunk {
370410 if i < 0 || i >= len(v.chunks) {
@@ -470,6 +510,14 @@ func (v HistorySnapshotView) Len() int { return v.rows }
470510 func (v HistorySnapshotView) Cells() int { return v.cells }
471511 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
472512
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+
473521 // Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
474522 func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
475523 if i < 0 || i >= len(v.chunks) {
@@ -481,12 +529,13 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
481529 // Tail returns an immutable view of the copied mutable tail.
482530 func (v HistorySnapshotView) Tail() HistoryView {
483531 if len(v.tail) == 0 {
484- return HistoryView{}
532+ return HistoryView{nextRowID: v.nextRowID}
485533 }
486534 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,
490539 }
491540 }
492541
@@ -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 int18 MaxRows int
@@ -46,9 +48,10 @@ type HistoryChunk struct {
46 // HistoryView is an immutable snapshot of history. Row returns a copy so the48 // 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 []*HistoryChunk51+ chunks []*HistoryChunk
50- rows int52+ rows int
51- cells int53+ cells int
54+ nextRowID RowID
52 }55 }
53 56
54 // HistorySnapshotView captures sealed history chunks and the mutable tail57 // HistorySnapshotView captures sealed history chunks and the mutable tail
@@ -61,6 +64,7 @@ type HistorySnapshotView struct {
61 tailIDs []RowID64 tailIDs []RowID
62 rows int65 rows int
63 cells int66 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 + 1135 h.nextRowID = id + 1
109 }136 }
110 // Make room before adding so Cells cannot overflow even when the default137 // Make room before adding so Cells cannot overflow even when the default
@@ -139,20 +166,6 @@ func (h *History) allocateRowID() RowID {
139 return id166 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; a301 // 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 mutable321 // 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.cells365 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 (
1212
1313 const (
1414 historyMagic = "VTH1"
15- historyVersion = 2
15+ historyVersion = 3
1616 historyCellBytes = 41
1717 historyBoundBytes = 5
1818 maxHistoryChunkRows = 256
@@ -37,13 +37,29 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
3737 if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
3838 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
3939 }
40+ nextRowID, ok := historyViewNextRowID(view)
41+ if !ok {
42+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
43+ }
4044 stats := historyDecodeStats{}
45+ seen := make(map[RowID]struct{}, view.rows)
4146 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 ||
4348 !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
4449 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
4550 }
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{}{}
4763 cellCount := uint64(len(row))
4864 cellBytes, ok := historyCellByteCount(cellCount)
4965 if !ok ||
@@ -52,39 +68,80 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
5268 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
5369 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
5470 }
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+ }
5580 }
5681 }
82+ if nextRowID <= maxHistoryRowID(seen) {
83+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
84+ }
5785
58- out := make([]byte, 0, 9)
86+ out := make([]byte, 0, 17)
5987 out = append(out, historyMagic...)
6088 out = append(out, historyVersion)
6189 out = appendUint32(out, uint32(len(view.chunks)))
90+ out = binary.BigEndian.AppendUint64(out, uint64(nextRowID))
6291 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- }
6692 out = appendUint32(out, uint32(len(chunk.rows)))
6793 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]))
7595 out = appendUint32(out, uint32(len(row)))
7696 for _, cell := range row {
77- if !validHistoryCell(cell) {
78- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79- }
8097 out = appendHistoryCell(out, cell)
8198 }
82- out = appendHistoryBound(out, bound)
99+ out = appendHistoryBound(out, chunkBound(chunk, i))
83100 }
84101 }
85102 return out, nil
86103 }
87104
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+
88145 func historyViewRowCount(view HistoryView) int {
89146 n := 0
90147 for _, chunk := range view.chunks {
@@ -189,7 +246,7 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) {
189246 // parseHistory is the authoritative history payload validation path. populate
190247 // controls whether validated cells are retained in a HistoryView.
191248 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 {
193250 return HistoryView{}, historyDecodeStats{}, false
194251 }
195252
@@ -198,16 +255,22 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
198255 if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
199256 return HistoryView{}, historyDecodeStats{}, false
200257 }
258+ nextRowID, ok := p.uint64()
259+ if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) {
260+ return HistoryView{}, historyDecodeStats{}, false
261+ }
201262 stats := historyDecodeStats{chunks: uint64(chunkCount)}
263+ var seenIDs [maxHistoryRows]RowID
264+ seenCount := 0
265+ var maxID RowID
202266 var chunks []*HistoryChunk
203- var nextRowID RowID = 1
204267 if populate {
205268 chunks = make([]*HistoryChunk, 0, chunkCount)
206269 }
207270 for range chunkCount {
208271 rowCount, ok := p.uint32()
209272 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) ||
211274 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
212275 return HistoryView{}, historyDecodeStats{}, false
213276 }
@@ -221,6 +284,21 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
221284 rowIDs = make([]RowID, 0, rowCount)
222285 }
223286 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+ }
224302 cellCount, ok := p.uint32()
225303 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
226304 if !ok || !validCellBytes ||
@@ -251,18 +329,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
251329 if populate {
252330 rows = append(rows, row)
253331 bounds = append(bounds, bound)
254- rowIDs = append(rowIDs, nextRowID)
255- nextRowID++
332+ rowIDs = append(rowIDs, rowID)
256333 }
257334 }
258335 if populate {
259336 chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
260337 }
261338 }
262- if len(p.data) != 0 {
339+ if len(p.data) != 0 || RowID(nextRowID) <= maxID {
263340 return HistoryView{}, historyDecodeStats{}, false
264341 }
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
266343 }
267344
268345 func historyCellByteCount(cellCount uint64) (uint64, bool) {
@@ -291,6 +368,15 @@ func (p *historyParser) uint32() (uint32, bool) {
291368 return v, true
292369 }
293370
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+
294380 func (p *historyParser) cell() (renderer.Cell, bool) {
295381 if len(p.data) < historyCellBytes {
296382 return renderer.Cell{}, false
@@ -12,7 +12,7 @@ import (
12 12
13 const (13 const (
14 historyMagic = "VTH1"14 historyMagic = "VTH1"
15- historyVersion = 215+ historyVersion = 3
16 historyCellBytes = 4116 historyCellBytes = 41
17 historyBoundBytes = 517 historyBoundBytes = 5
18 maxHistoryChunkRows = 25618 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, nil102 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 := 0146 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. populate246 // 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{}, false250 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{}, false256 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 []*HistoryChunk266 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{}, false275 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{}, false340 return HistoryView{}, historyDecodeStats{}, false
264 }341 }
265- return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true342+ 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, true368 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{}, false382 return renderer.Cell{}, false
M pkg/vt/history_codec_bound_test.go +4 -3
@@ -10,9 +10,10 @@ import (
1010
1111 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {
1212 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,
1617 })
1718 require.NoError(t, err)
1819 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) - historyBoundBytes19 boundOffset := len(blob) - historyBoundBytes
M pkg/vt/history_codec_test.go +43 -26
@@ -10,7 +10,7 @@ import (
1010 "github.com/bnema/vev/pkg/renderer"
1111 )
1212
13-func TestHistoryCodecV2Bytes(t *testing.T) {
13+func TestHistoryCodecV3Bytes(t *testing.T) {
1414 h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
1515 if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
1616 t.Fatalf("append: %v", err)
@@ -21,9 +21,11 @@ func TestHistoryCodecV2Bytes(t *testing.T) {
2121 }
2222
2323 want := []byte{
24- 'V', 'T', 'H', '1', 2, // magic, version
24+ 'V', 'T', 'H', '1', 3, // magic, version
2525 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
2627 0, 0, 0, 1, // row count
28+ 0, 0, 0, 0, 0, 0, 0, 1, // row ID
2729 0, 0, 0, 1, // cell count
2830 0, 0, 0, 'a', // Rune
2931 0, // flags
@@ -65,12 +67,12 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) {
6567 }
6668 }
6769
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{
7173 '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',
7476 0, 0, 0,
7577 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
7678 0, 0, 0, 0, 0, 0,
@@ -79,21 +81,20 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
7981 0, 0, 0,
8082 0, 0, 0, 1, 1,
8183 }
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+ }
8990 }
9091
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 {
9394 t.Fatalf("accepted truncated prefix of length %d", i)
9495 }
9596 }
96- if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97+ if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil {
9798 t.Fatal("accepted trailing garbage")
9899 }
99100 }
@@ -300,31 +301,41 @@ func historyViewWithDimensions(rowCount, width int) HistoryView {
300301 row[i].Rune = 'x'
301302 }
302303 chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
304+ rowID := RowID(1)
303305 for rowCount > 0 {
304306 chunkRows := min(rowCount, maxHistoryChunkRows)
305307 rows := make([][]renderer.Cell, chunkRows)
308+ rowIDs := make([]RowID, chunkRows)
306309 for i := range rows {
307310 rows[i] = row
311+ rowIDs[i] = rowID
312+ rowID++
308313 }
309- chunks = append(chunks, &HistoryChunk{rows: rows})
314+ chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})
310315 rowCount -= chunkRows
311316 }
312- return HistoryView{chunks: chunks, rows: totalRows}
317+ return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
313318 }
314319
315320 func historyPayloadWithDimensions(rowCount, width int) []byte {
316- data := make([]byte, 9)
321+ data := make([]byte, 17)
317322 copy(data, historyMagic)
318323 data[4] = historyVersion
319324 chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
320325 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
326+ binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
327+ rowID := uint64(1)
321328 for rowCount > 0 {
322329 chunkRows := min(rowCount, maxHistoryChunkRows)
323330 data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
324331 for range chunkRows {
332+ data = binary.BigEndian.AppendUint64(data, rowID)
333+ rowID++
325334 data = binary.BigEndian.AppendUint32(data, uint32(width))
326335 //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
327336 data = append(data, make([]byte, width*historyCellBytes)...)
337+ data = binary.BigEndian.AppendUint32(data, uint32(width))
338+ data = append(data, 0)
328339 }
329340 rowCount -= chunkRows
330341 }
@@ -335,22 +346,28 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
335346 // budget, but its cells exceed the aggregate budget. It deliberately omits
336347 // cell payload bytes: the aggregate limit must reject it before allocation.
337348 func aggregateCellLimitDeclaration() []byte {
338- data := make([]byte, 9, 21)
349+ data := make([]byte, 17, 29)
339350 copy(data, historyMagic)
340351 data[4] = historyVersion
341352 binary.BigEndian.PutUint32(data[5:], 1)
353+ binary.BigEndian.PutUint64(data[9:], 2)
342354 data = binary.BigEndian.AppendUint32(data, 1)
355+ data = binary.BigEndian.AppendUint64(data, 1)
343356 return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
344357 }
345358
346359 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))
348361 copy(data, historyMagic)
349362 data[4] = historyVersion
350363 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
364+ binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
365+ id := uint64(1)
351366 for range chunkCount {
352367 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
353368 for range rowCount {
369+ data = binary.BigEndian.AppendUint64(data, id)
370+ id++
354371 data = binary.BigEndian.AppendUint32(data, 0)
355372 }
356373 }
@@ -363,9 +380,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
363380 t.Fatalf("marshal valid history: %v", err)
364381 }
365382 invalidRune := append([]byte(nil), valid...)
366- binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))
383+ binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
367384 invalidUnderlineStyle := append([]byte(nil), valid...)
368- invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1)
385+ invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1)
369386
370387 tests := []struct {
371388 name string
@@ -379,7 +396,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
379396 {name: "invalid underline style", data: invalidUnderlineStyle},
380397 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381398 {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}},
383400 }
384401 for _, tt := range tests {
385402 t.Run(tt.name, func(t *testing.T) {
@@ -398,7 +415,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
398415 f.Fatalf("marshal valid history: %v", err)
399416 }
400417 invalidRune := append([]byte(nil), valid...)
401- binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))
418+ binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
402419 f.Add(valid)
403420 f.Add(invalidRune)
404421 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, version24+ 'V', 'T', 'H', '1', 3, // magic, version
25 0, 0, 0, 1, // chunk count25 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
26 0, 0, 0, 1, // row count27 0, 0, 0, 1, // row count
28+ 0, 0, 0, 0, 0, 0, 0, 1, // row ID
27 0, 0, 0, 1, // cell count29 0, 0, 0, 1, // cell count
28 0, 0, 0, 'a', // Rune30 0, 0, 0, 'a', // Rune
29 0, // flags31 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 bound85+ 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] = 188+ 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] = row310 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 -= chunkRows315 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] = historyVersion323 data[4] = historyVersion
319 chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows324 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 -= chunkRows340 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 omits346 // 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] = historyVersion351 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] = historyVersion362 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 string388 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"
55 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
66 // rows that should be replayed after retained terminal history during recovery.
77 type RecoveryTranscriptSnapshot struct {
8- segments []recoveryTranscriptSegment
8+ segments []recoveryTranscriptSegment
9+ nextRowID RowID
910 }
1011
1112 type recoveryTranscriptSegment struct {
1213 rows [][]renderer.Cell
1314 bounds []LineBound
15+ rowIDs []RowID
1416 }
1517
1618 // RecoveryTranscriptSnapshot captures the active primary viewport, or the
@@ -25,7 +27,11 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
2527 buffers = []*buffer{s.alternate.buffer, s.buffer}
2628 }
2729
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}
2935 for _, b := range buffers {
3036 segment := captureRecoveryTranscriptSegment(b)
3137 if len(segment.rows) > 0 {
@@ -51,6 +57,7 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
5157 segment := recoveryTranscriptSegment{
5258 rows: make([][]renderer.Cell, rowCount),
5359 bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
60+ rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...),
5461 }
5562 cells := make([]renderer.Cell, rowCount*b.frame.Width)
5663 for y := range rowCount {
@@ -88,7 +95,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
8895 }
8996 }
9097
91- view := HistoryView{rows: rows, cells: cells}
98+ view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID}
9299 for _, segment := range snapshot.segments {
93100 for start := 0; start < len(segment.rows); {
94101 space := maxHistoryChunkRows
@@ -99,6 +106,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
99106 end := min(start+space, len(segment.rows))
100107 last.rows = append(last.rows, segment.rows[start:end]...)
101108 last.bounds = append(last.bounds, segment.bounds[start:end]...)
109+ last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...)
102110 start = end
103111 continue
104112 }
@@ -108,6 +116,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
108116 view.chunks = append(view.chunks, &HistoryChunk{
109117 rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
110118 bounds: append([]LineBound(nil), segment.bounds[start:end]...),
119+ rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...),
111120 })
112121 start = end
113122 }
@@ -5,12 +5,14 @@ import "github.com/bnema/vev/pkg/renderer"
5 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport5 // 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 []recoveryTranscriptSegment8+ segments []recoveryTranscriptSegment
9+ nextRowID RowID
9 }10 }
10 11
11 type recoveryTranscriptSegment struct {12 type recoveryTranscriptSegment struct {
12 rows [][]renderer.Cell13 rows [][]renderer.Cell
13 bounds []LineBound14 bounds []LineBound
15+ rowIDs []RowID
14 }16 }
15 17
16 // RecoveryTranscriptSnapshot captures the active primary viewport, or the18 // 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 := maxHistoryChunkRows101 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 = end110 start = end
103 continue111 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 = end121 start = end
113 }122 }
M pkg/vt/recovery_transcript_test.go +7 -3
@@ -188,7 +188,7 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
188188
189189 got, err := s.RecoveryTranscriptSnapshot().Marshal()
190190 require.NoError(t, err)
191- want, err := MarshalHistory(HistoryView{})
191+ want, err := MarshalHistory(HistoryView{nextRowID: 3})
192192 require.NoError(t, err)
193193
194194 require.Equal(t, want, got)
@@ -396,9 +396,13 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []
396396 bounds[i].End = len(row)
397397 }
398398 }
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))}
400404 if len(rows) > 0 {
401- view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}}
405+ view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}
402406 }
403407 blob, err := MarshalHistory(view)
404408 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 {
2525 // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
2626 // mutated and remains valid while the snapshot is retained.
2727 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
3335 }
3436
3537 // Snapshot captures the active visible viewport without mutating Screen,
@@ -42,8 +44,10 @@ func (s *Screen) Snapshot() ScreenSnapshot {
4244 cursorStyle, cursorStyleSet := s.CursorStyle()
4345 mouseTracking, mouseSGR := s.MouseMode()
4446 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,
4751 cursor: CursorSnapshot{
4852 Row: s.CursorRow(),
4953 Col: s.CursorCol(),
@@ -84,6 +88,25 @@ func (s ScreenSnapshot) Bound(y int) LineBound {
8488 return s.bounds[y]
8589 }
8690
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+
87110 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
88111 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
89112 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 be25 // 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.Frame28+ frame renderer.Frame
29- bounds []LineBound29+ bounds []LineBound
30- cursor CursorSnapshot30+ rowIDs []RowID
31- modes ModeSnapshot31+ cursor CursorSnapshot
32- title string32+ 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) {
6666 }
6767 sealed[i] = blob
6868 }
69- tail, err := MarshalEmptyHistoryTail()
69+ tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()})
7070 if err != nil {
7171 return nil, nil, err
7272 }
@@ -77,33 +77,30 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
7777 // and the mandatory tail blob. It never feeds decoded rows through Append.
7878 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
7979 h := NewHistory(config)
80+ seen := make(map[RowID]struct{})
8081 for _, blob := range sealed {
8182 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) {
8384 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
8485 }
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)
8991 h.cells += view.Cells()
9092 }
9193 view, err := UnmarshalHistory(tail)
92- if err != nil || len(view.chunks) > 1 {
94+ if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) {
9395 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
9496 }
97+ h.nextRowID = max(h.nextRowID, view.nextRowID)
9598 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...)
107104 h.rows += len(h.tail)
108105 h.cells += view.Cells()
109106 }
@@ -112,6 +109,24 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
112109 return h, nil
113110 }
114111
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+
115130 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
116131 // history contains the restored bounded history followed by the recovery
117132 // transcript. The transcript is decoded in full before history is restored.
@@ -133,7 +148,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
133148 if remainingRows == 0 {
134149 bound.Soft = false
135150 }
136- if err := history.Append(row, bound); err != nil {
151+ if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil {
137152 return nil, fmt.Errorf("restore recovery transcript: %w", err)
138153 }
139154 }
@@ -141,5 +156,14 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
141156
142157 screen := NewScreenWithHistory(width, height, config)
143158 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
144168 return screen, nil
145169 }
@@ -66,7 +66,7 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
66 }66 }
67 sealed[i] = blob67 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, err71 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].rows100+ 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, nil109 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 whose130 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
116 // history contains the restored bounded history followed by the recovery131 // 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 = false149 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 = history158 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, nil168 return screen, nil
145 }169 }
M pkg/vt/snapshot_test.go +3 -2
@@ -44,8 +44,9 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
4444
4545 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
4646 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,
4950 })
5051 require.NoError(t, err)
5152
@@ -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

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close