fix(rowid): reject malformed explicit history IDs
- commit
- 7d1e3b2bf202d53b62eb5cff3b7b6b525e90a31e
- author
- brice · · 2026-08-02 21:49 UTC
- committer
- brice · · 2026-08-03 17:54 UTC
- parent
- 3c2478b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +77 -55
M internal/usecase/copy/mode.go +7 -9
| @@ -24,20 +24,18 @@ type Snapshot struct { | |||
| 24 | 24 | Width, Height int | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot { | |
| 28 | - | var screenRowIDs []vt.RowID | |
| 29 | - | if len(rowIDs) > 0 { | |
| 30 | - | screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...) | |
| 31 | - | } | |
| 32 | - | return newSnapshot(historySource, screen, bounds, screenRowIDs) | |
| 27 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot { | |
| 28 | + | return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...)) | |
| 33 | 29 | } | |
| 34 | 30 | ||
| 35 | 31 | // NewSnapshotFromScreen captures a screen while its owner holds the Screen | |
| 36 | - | // lock. Screen.RowIDs already returns owned storage, so the snapshot can take | |
| 37 | - | // that copy directly without allocating a duplicate metadata slice. | |
| 32 | + | // lock. Screen.RowIDs returns the one owned copy required for immutable | |
| 33 | + | // snapshot state; newSnapshot takes that slice directly without duplicating | |
| 34 | + | // it. The live screen's rowIDs cannot be transferred or shared because later | |
| 35 | + | // scroll, resize, and clear mutations update that storage. | |
| 38 | 36 | func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot { | |
| 39 | 37 | if screen == nil { | |
| 40 | - | return NewSnapshot(historySource, renderer.Frame{}, nil) | |
| 38 | + | return NewSnapshot(historySource, renderer.Frame{}, nil, nil) | |
| 41 | 39 | } | |
| 42 | 40 | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | |
| 43 | 41 | } | |
| @@ -24,20 +24,18 @@ type Snapshot struct { | |||||
| 24 | Width, Height int | 24 | Width, Height int | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot { | 27 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot { |
| 28 | - | var screenRowIDs []vt.RowID | 28 | + | return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...)) |
| 29 | - | if len(rowIDs) > 0 { | |||
| 30 | - | screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...) | |||
| 31 | - | } | |||
| 32 | - | return newSnapshot(historySource, screen, bounds, screenRowIDs) | |||
| 33 | } | 29 | } | ||
| 34 | 30 | ||||
| 35 | // NewSnapshotFromScreen captures a screen while its owner holds the Screen | 31 | // NewSnapshotFromScreen captures a screen while its owner holds the Screen | ||
| 36 | - | // lock. Screen.RowIDs already returns owned storage, so the snapshot can take | 32 | + | // lock. Screen.RowIDs returns the one owned copy required for immutable |
| 37 | - | // that copy directly without allocating a duplicate metadata slice. | 33 | + | // snapshot state; newSnapshot takes that slice directly without duplicating |
| 34 | + | // it. The live screen's rowIDs cannot be transferred or shared because later | |||
| 35 | + | // scroll, resize, and clear mutations update that storage. | |||
| 38 | func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot { | 36 | func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot { | ||
| 39 | if screen == nil { | 37 | if screen == nil { | ||
| 40 | - | return NewSnapshot(historySource, renderer.Frame{}, nil) | 38 | + | return NewSnapshot(historySource, renderer.Frame{}, nil, nil) |
| 41 | } | 39 | } | ||
| 42 | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | 40 | return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs()) | ||
| 43 | } | 41 | } | ||
M internal/usecase/copy/mode_test.go +6 -6
| @@ -44,8 +44,8 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||
| 44 | 44 | ||
| 45 | 45 | func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) { | |
| 46 | 46 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 47 | - | require.NoError(t, history.Append(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) | |
| 48 | - | require.NoError(t, history.Append(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) | |
| 47 | + | require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) | |
| 48 | + | require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) | |
| 49 | 49 | screen := renderer.NewFrame(4, 2) | |
| 50 | 50 | copy(screen.Row(0), row("live")) | |
| 51 | 51 | copy(screen.Row(1), row("more")) | |
| @@ -81,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |||
| 81 | 81 | screen := renderer.NewFrame(4, 2) | |
| 82 | 82 | copy(screen.Row(0), row("live")) | |
| 83 | 83 | ||
| 84 | - | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}) | |
| 84 | + | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil) | |
| 85 | 85 | ||
| 86 | 86 | tests := []struct { | |
| 87 | 87 | name string | |
| @@ -255,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||
| 255 | 255 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | |
| 256 | 256 | } | |
| 257 | 257 | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | |
| 258 | - | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil) | |
| 258 | + | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil) | |
| 259 | 259 | view := history.SealAndView() | |
| 260 | 260 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | |
| 261 | 261 | ||
| @@ -368,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |||
| 368 | 368 | copy(screen.Row(0), row("ef", width)) | |
| 369 | 369 | copy(screen.Row(1), row("gh", width)) | |
| 370 | 370 | ||
| 371 | - | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}) | |
| 371 | + | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil) | |
| 372 | 372 | doc := NewDocument(snapshot, "") | |
| 373 | 373 | ||
| 374 | 374 | ranges := []CellRange{ | |
| @@ -437,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |||
| 437 | 437 | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |
| 438 | 438 | screen.Write([]byte(tc.input)) | |
| 439 | 439 | ||
| 440 | - | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds()) | |
| 440 | + | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil) | |
| 441 | 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | |
| 442 | 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | |
| 443 | 443 | ||
| @@ -44,8 +44,8 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||||
| 44 | 44 | ||||
| 45 | func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) { | 45 | func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) { | ||
| 46 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | 46 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | ||
| 47 | - | require.NoError(t, history.Append(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) | 47 | + | require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11))) |
| 48 | - | require.NoError(t, history.Append(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) | 48 | + | require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12))) |
| 49 | screen := renderer.NewFrame(4, 2) | 49 | screen := renderer.NewFrame(4, 2) | ||
| 50 | copy(screen.Row(0), row("live")) | 50 | copy(screen.Row(0), row("live")) | ||
| 51 | copy(screen.Row(1), row("more")) | 51 | copy(screen.Row(1), row("more")) | ||
| @@ -81,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |||||
| 81 | screen := renderer.NewFrame(4, 2) | 81 | screen := renderer.NewFrame(4, 2) | ||
| 82 | copy(screen.Row(0), row("live")) | 82 | copy(screen.Row(0), row("live")) | ||
| 83 | 83 | ||||
| 84 | - | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}) | 84 | + | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil) |
| 85 | 85 | ||||
| 86 | tests := []struct { | 86 | tests := []struct { | ||
| 87 | name string | 87 | name string | ||
| @@ -255,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||||
| 255 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | 255 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | ||
| 256 | } | 256 | } | ||
| 257 | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | 257 | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | ||
| 258 | - | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil) | 258 | + | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil) |
| 259 | view := history.SealAndView() | 259 | view := history.SealAndView() | ||
| 260 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | 260 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | ||
| 261 | 261 | ||||
| @@ -368,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |||||
| 368 | copy(screen.Row(0), row("ef", width)) | 368 | copy(screen.Row(0), row("ef", width)) | ||
| 369 | copy(screen.Row(1), row("gh", width)) | 369 | copy(screen.Row(1), row("gh", width)) | ||
| 370 | 370 | ||||
| 371 | - | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}) | 371 | + | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil) |
| 372 | doc := NewDocument(snapshot, "") | 372 | doc := NewDocument(snapshot, "") | ||
| 373 | 373 | ||||
| 374 | ranges := []CellRange{ | 374 | ranges := []CellRange{ | ||
| @@ -437,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |||||
| 437 | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | 437 | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | ||
| 438 | screen.Write([]byte(tc.input)) | 438 | screen.Write([]byte(tc.input)) | ||
| 439 | 439 | ||||
| 440 | - | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds()) | 440 | + | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil) |
| 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | ||
| 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | ||
| 443 | 443 | ||||
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 101 | 101 | sess.client = ac | |
| 102 | 102 | ac.setSession(sess) | |
| 103 | 103 | closing.mu.Lock() | |
| 104 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 104 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 105 | 105 | closing.mu.Unlock() | |
| 106 | 106 | ac.overlays.copyMu.Lock() | |
| 107 | 107 | ac.overlays.copyPane = closing | |
| @@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 146 | 146 | tb := sess.activeTab() | |
| 147 | 147 | p := tb.focusedPane() | |
| 148 | 148 | p.mu.Lock() | |
| 149 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 149 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 150 | 150 | p.mu.Unlock() | |
| 151 | 151 | ||
| 152 | 152 | // Pause after candidate publication, then make the captured target stale. | |
| @@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 370 | 370 | tb := sess.activeTab() | |
| 371 | 371 | p := tb.focusedPane() | |
| 372 | 372 | p.mu.Lock() | |
| 373 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 373 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 374 | 374 | p.mu.Unlock() | |
| 375 | 375 | tc.invalidate(sess, tb, p) | |
| 376 | 376 | ||
| @@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 101 | sess.client = ac | 101 | sess.client = ac | ||
| 102 | ac.setSession(sess) | 102 | ac.setSession(sess) | ||
| 103 | closing.mu.Lock() | 103 | closing.mu.Lock() | ||
| 104 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators) | 104 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 105 | closing.mu.Unlock() | 105 | closing.mu.Unlock() | ||
| 106 | ac.overlays.copyMu.Lock() | 106 | ac.overlays.copyMu.Lock() | ||
| 107 | ac.overlays.copyPane = closing | 107 | ac.overlays.copyPane = closing | ||
| @@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 146 | tb := sess.activeTab() | 146 | tb := sess.activeTab() | ||
| 147 | p := tb.focusedPane() | 147 | p := tb.focusedPane() | ||
| 148 | p.mu.Lock() | 148 | p.mu.Lock() | ||
| 149 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | 149 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 150 | p.mu.Unlock() | 150 | p.mu.Unlock() | ||
| 151 | 151 | ||||
| 152 | // Pause after candidate publication, then make the captured target stale. | 152 | // Pause after candidate publication, then make the captured target stale. | ||
| @@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||||
| 370 | tb := sess.activeTab() | 370 | tb := sess.activeTab() | ||
| 371 | p := tb.focusedPane() | 371 | p := tb.focusedPane() | ||
| 372 | p.mu.Lock() | 372 | p.mu.Lock() | ||
| 373 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | 373 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 374 | p.mu.Unlock() | 374 | p.mu.Unlock() | ||
| 375 | tc.invalidate(sess, tb, p) | 375 | tc.invalidate(sess, tb, p) | ||
| 376 | 376 | ||||
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
| @@ -350,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||
| 350 | 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 351 | 351 | p := sess.activeTab().focusedPane() | |
| 352 | 352 | p.mu.Lock() | |
| 353 | - | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 353 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 354 | 354 | p.mu.Unlock() | |
| 355 | 355 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 356 | 356 | }}, | |
| @@ -350,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||||
| 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | ||
| 351 | p := sess.activeTab().focusedPane() | 351 | p := sess.activeTab().focusedPane() | ||
| 352 | p.mu.Lock() | 352 | p.mu.Lock() | ||
| 353 | - | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | 353 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) |
| 354 | p.mu.Unlock() | 354 | p.mu.Unlock() | ||
| 355 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | 355 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | ||
| 356 | }}, | 356 | }}, | ||
M internal/usecase/daemon/copymode_test.go +5 -5
| @@ -130,7 +130,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 130 | 130 | tb := sess.activeTab() | |
| 131 | 131 | pane := tb.focusedPane() | |
| 132 | 132 | pane.mu.Lock() | |
| 133 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()) | |
| 133 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil) | |
| 134 | 134 | pane.mu.Unlock() | |
| 135 | 135 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 136 | 136 | ||
| @@ -164,7 +164,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 164 | 164 | tb := sess.activeTab() | |
| 165 | 165 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 166 | 166 | tb.focusedPane().screen.Write([]byte("live")) | |
| 167 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds()) | |
| 167 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil) | |
| 168 | 168 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 169 | 169 | ||
| 170 | 170 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -452,7 +452,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 452 | 452 | } | |
| 453 | 453 | p.mu.Lock() | |
| 454 | 454 | defer p.mu.Unlock() | |
| 455 | - | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12 | |
| 455 | + | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12 | |
| 456 | 456 | }, 2*time.Second, 5*time.Millisecond) | |
| 457 | 457 | ||
| 458 | 458 | sess := firstSession(d) | |
| @@ -802,7 +802,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 802 | 802 | } | |
| 803 | 803 | fp.screen.Write([]byte("live")) | |
| 804 | 804 | fp.mu.Lock() | |
| 805 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len() | |
| 805 | + | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len() | |
| 806 | 806 | fp.mu.Unlock() | |
| 807 | 807 | ||
| 808 | 808 | d.enterCopyMode(sess, ac) | |
| @@ -913,7 +913,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||
| 913 | 913 | base := renderer.NewFrame(20, 8) | |
| 914 | 914 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 915 | 915 | pane.screen.Write([]byte("copy")) | |
| 916 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators)) | |
| 916 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators)) | |
| 917 | 917 | if tt.selectMode { | |
| 918 | 918 | mode.ToggleLineSelection() | |
| 919 | 919 | } | |
| @@ -130,7 +130,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 130 | tb := sess.activeTab() | 130 | tb := sess.activeTab() | ||
| 131 | pane := tb.focusedPane() | 131 | pane := tb.focusedPane() | ||
| 132 | pane.mu.Lock() | 132 | pane.mu.Lock() | ||
| 133 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()) | 133 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil) |
| 134 | pane.mu.Unlock() | 134 | pane.mu.Unlock() | ||
| 135 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 135 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 136 | 136 | ||||
| @@ -164,7 +164,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 164 | tb := sess.activeTab() | 164 | tb := sess.activeTab() | ||
| 165 | tb.focusedPane().screen = vt.NewScreen(12, 3) | 165 | tb.focusedPane().screen = vt.NewScreen(12, 3) | ||
| 166 | tb.focusedPane().screen.Write([]byte("live")) | 166 | tb.focusedPane().screen.Write([]byte("live")) | ||
| 167 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds()) | 167 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil) |
| 168 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 168 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 169 | 169 | ||||
| 170 | bars := barState{status: sess.statusSegments(true)} | 170 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -452,7 +452,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 452 | } | 452 | } | ||
| 453 | p.mu.Lock() | 453 | p.mu.Lock() | ||
| 454 | defer p.mu.Unlock() | 454 | defer p.mu.Unlock() | ||
| 455 | - | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12 | 455 | + | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12 |
| 456 | }, 2*time.Second, 5*time.Millisecond) | 456 | }, 2*time.Second, 5*time.Millisecond) | ||
| 457 | 457 | ||||
| 458 | sess := firstSession(d) | 458 | sess := firstSession(d) | ||
| @@ -802,7 +802,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 802 | } | 802 | } | ||
| 803 | fp.screen.Write([]byte("live")) | 803 | fp.screen.Write([]byte("live")) | ||
| 804 | fp.mu.Lock() | 804 | fp.mu.Lock() | ||
| 805 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len() | 805 | + | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len() |
| 806 | fp.mu.Unlock() | 806 | fp.mu.Unlock() | ||
| 807 | 807 | ||||
| 808 | d.enterCopyMode(sess, ac) | 808 | d.enterCopyMode(sess, ac) | ||
| @@ -913,7 +913,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||||
| 913 | base := renderer.NewFrame(20, 8) | 913 | base := renderer.NewFrame(20, 8) | ||
| 914 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | 914 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | ||
| 915 | pane.screen.Write([]byte("copy")) | 915 | pane.screen.Write([]byte("copy")) | ||
| 916 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators)) | 916 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators)) |
| 917 | if tt.selectMode { | 917 | if tt.selectMode { | ||
| 918 | mode.ToggleLineSelection() | 918 | mode.ToggleLineSelection() | ||
| 919 | } | 919 | } | ||
M internal/usecase/daemon/daemon_test.go +1 -1
| @@ -458,7 +458,7 @@ func testRow(text string) []renderer.Cell { | |||
| 458 | 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | |
| 459 | 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | |
| 460 | 460 | tb.Helper() | |
| 461 | - | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1))) | |
| 461 | + | require.NoError(tb, history.AppendWithID(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1))) | |
| 462 | 462 | } | |
| 463 | 463 | ||
| 464 | 464 | func tabCount(sess *session) int { | |
| @@ -458,7 +458,7 @@ func testRow(text string) []renderer.Cell { | |||||
| 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | ||
| 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | ||
| 460 | tb.Helper() | 460 | tb.Helper() | ||
| 461 | - | require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}, vt.RowID(1_000_000+history.Len()+1))) | 461 | + | require.NoError(tb, history.AppendWithID(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/overlay_runtime_test.go +1 -1
| @@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 200 | 200 | ac.initOverlays() | |
| 201 | 201 | history := newTestHistory(1) | |
| 202 | 202 | appendHistoryRow(t, history, testRow("before")) | |
| 203 | - | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators) | |
| 203 | + | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil, nil), domain.DefaultWordSeparators) | |
| 204 | 204 | ||
| 205 | 205 | ac.overlays.copyMu.Lock() | |
| 206 | 206 | ac.overlays.copyMode = scopy.NewMode(document) | |
| @@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||||
| 200 | ac.initOverlays() | 200 | ac.initOverlays() | ||
| 201 | history := newTestHistory(1) | 201 | history := newTestHistory(1) | ||
| 202 | appendHistoryRow(t, history, testRow("before")) | 202 | appendHistoryRow(t, history, testRow("before")) | ||
| 203 | - | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators) | 203 | + | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil, nil), domain.DefaultWordSeparators) |
| 204 | 204 | ||||
| 205 | ac.overlays.copyMu.Lock() | 205 | ac.overlays.copyMu.Lock() | ||
| 206 | ac.overlays.copyMode = scopy.NewMode(document) | 206 | ac.overlays.copyMode = scopy.NewMode(document) | ||
M internal/usecase/daemon/render_test.go +1 -1
| @@ -1273,7 +1273,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1273 | 1273 | } | |
| 1274 | 1274 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1275 | 1275 | p.screen.Write([]byte("ab\r\ncd")) | |
| 1276 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) | |
| 1276 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil) | |
| 1277 | 1277 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1278 | 1278 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1279 | 1279 | ||
| @@ -1273,7 +1273,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||||
| 1273 | } | 1273 | } | ||
| 1274 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | 1274 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | ||
| 1275 | p.screen.Write([]byte("ab\r\ncd")) | 1275 | p.screen.Write([]byte("ab\r\ncd")) | ||
| 1276 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) | 1276 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil) |
| 1277 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | 1277 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | ||
| 1278 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | 1278 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | ||
| 1279 | 1279 | ||||
M pkg/vt/history.go +18 -19
| @@ -92,38 +92,37 @@ func defaultHistoryMaxCells(maxRows int) int { | |||
| 92 | 92 | return maxRows * 160 | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | - | // Append records a copy of row along with its logical extent and optional row | |
| 96 | - | // identity. Calls without an identity receive a history-owned nonzero ID. | |
| 97 | - | // Once a chunk is full it is sealed forever. Rows wider than the total cell | |
| 98 | - | // capacity are rejected without mutation. | |
| 99 | - | func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error { | |
| 95 | + | // Append records a copy of row along with its logical extent and an | |
| 96 | + | // automatically allocated nonzero ID. Once a chunk is full it is sealed | |
| 97 | + | // forever. Rows wider than the total cell capacity are rejected without | |
| 98 | + | // mutation. | |
| 99 | + | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |
| 100 | 100 | if h == nil || h.maxRows == 0 { | |
| 101 | 101 | return nil | |
| 102 | 102 | } | |
| 103 | 103 | if len(row) > h.maxCells { | |
| 104 | 104 | return ErrHistoryRowTooWide | |
| 105 | 105 | } | |
| 106 | - | id := RowID(0) | |
| 107 | - | if len(ids) > 0 { | |
| 108 | - | id = ids[0] | |
| 109 | - | } | |
| 110 | - | if id == 0 || h.hasRowID(id) { | |
| 111 | - | var err error | |
| 112 | - | id, err = h.allocateRowID() | |
| 113 | - | if err != nil { | |
| 114 | - | return err | |
| 115 | - | } | |
| 106 | + | id, err := h.allocateRowID() | |
| 107 | + | if err != nil { | |
| 108 | + | return err | |
| 116 | 109 | } | |
| 117 | 110 | return h.appendRow(row, bound, id) | |
| 118 | 111 | } | |
| 119 | 112 | ||
| 120 | - | // appendRestored appends a persisted row without synthesizing an identity. | |
| 121 | - | // The caller validates IDs across all persisted blobs before invoking it. | |
| 122 | - | func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) error { | |
| 113 | + | // AppendWithID records a copy of row with an explicit persisted identity. | |
| 114 | + | // Unlike Append, malformed identities are rejected rather than synthesized. | |
| 115 | + | func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error { | |
| 116 | + | if id == 0 || id >= ^RowID(0)-1 { | |
| 117 | + | return errInvalidHistoryRowID | |
| 118 | + | } | |
| 123 | 119 | if h == nil || h.maxRows == 0 { | |
| 124 | 120 | return nil | |
| 125 | 121 | } | |
| 126 | - | if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) { | |
| 122 | + | if len(row) > h.maxCells { | |
| 123 | + | return ErrHistoryRowTooWide | |
| 124 | + | } | |
| 125 | + | if h.hasRowID(id) { | |
| 127 | 126 | return errInvalidHistoryRowID | |
| 128 | 127 | } | |
| 129 | 128 | return h.appendRow(row, bound, id) | |
| @@ -92,38 +92,37 @@ func defaultHistoryMaxCells(maxRows int) int { | |||||
| 92 | return maxRows * 160 | 92 | return maxRows * 160 | ||
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | - | // Append records a copy of row along with its logical extent and optional row | 95 | + | // Append records a copy of row along with its logical extent and an |
| 96 | - | // identity. Calls without an identity receive a history-owned nonzero ID. | 96 | + | // automatically allocated nonzero ID. Once a chunk is full it is sealed |
| 97 | - | // Once a chunk is full it is sealed forever. Rows wider than the total cell | 97 | + | // forever. Rows wider than the total cell capacity are rejected without |
| 98 | - | // capacity are rejected without mutation. | 98 | + | // mutation. |
| 99 | - | func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error { | 99 | + | func (h *History) Append(row []renderer.Cell, bound LineBound) error { |
| 100 | if h == nil || h.maxRows == 0 { | 100 | if h == nil || h.maxRows == 0 { | ||
| 101 | return nil | 101 | return nil | ||
| 102 | } | 102 | } | ||
| 103 | if len(row) > h.maxCells { | 103 | if len(row) > h.maxCells { | ||
| 104 | return ErrHistoryRowTooWide | 104 | return ErrHistoryRowTooWide | ||
| 105 | } | 105 | } | ||
| 106 | - | id := RowID(0) | 106 | + | id, err := h.allocateRowID() |
| 107 | - | if len(ids) > 0 { | 107 | + | if err != nil { |
| 108 | - | id = ids[0] | 108 | + | return err |
| 109 | - | } | |||
| 110 | - | if id == 0 || h.hasRowID(id) { | |||
| 111 | - | var err error | |||
| 112 | - | id, err = h.allocateRowID() | |||
| 113 | - | if err != nil { | |||
| 114 | - | return err | |||
| 115 | - | } | |||
| 116 | } | 109 | } | ||
| 117 | return h.appendRow(row, bound, id) | 110 | return h.appendRow(row, bound, id) | ||
| 118 | } | 111 | } | ||
| 119 | 112 | ||||
| 120 | - | // appendRestored appends a persisted row without synthesizing an identity. | 113 | + | // AppendWithID records a copy of row with an explicit persisted identity. |
| 121 | - | // The caller validates IDs across all persisted blobs before invoking it. | 114 | + | // Unlike Append, malformed identities are rejected rather than synthesized. |
| 122 | - | func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID) error { | 115 | + | func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error { |
| 116 | + | if id == 0 || id >= ^RowID(0)-1 { | |||
| 117 | + | return errInvalidHistoryRowID | |||
| 118 | + | } | |||
| 123 | if h == nil || h.maxRows == 0 { | 119 | if h == nil || h.maxRows == 0 { | ||
| 124 | return nil | 120 | return nil | ||
| 125 | } | 121 | } | ||
| 126 | - | if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) { | 122 | + | if len(row) > h.maxCells { |
| 123 | + | return ErrHistoryRowTooWide | |||
| 124 | + | } | |||
| 125 | + | if h.hasRowID(id) { | |||
| 127 | return errInvalidHistoryRowID | 126 | return errInvalidHistoryRowID | ||
| 128 | } | 127 | } | ||
| 129 | return h.appendRow(row, bound, id) | 128 | return h.appendRow(row, bound, id) | ||
M pkg/vt/row_id_persistence_test.go +29 -4
| @@ -43,6 +43,31 @@ func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) { | |||
| 43 | 43 | require.Error(t, history.Append(historyRow("row"), LineBound{End: 3})) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) { | |
| 47 | + | const maxPersistedRowID = ^RowID(0) - 2 | |
| 48 | + | for _, test := range []struct { | |
| 49 | + | name string | |
| 50 | + | id RowID | |
| 51 | + | existing bool | |
| 52 | + | }{ | |
| 53 | + | {name: "zero", id: 0}, | |
| 54 | + | {name: "duplicate", id: 7, existing: true}, | |
| 55 | + | {name: "first unpersistable", id: maxPersistedRowID + 1}, | |
| 56 | + | {name: "maximum", id: ^RowID(0)}, | |
| 57 | + | } { | |
| 58 | + | t.Run(test.name, func(t *testing.T) { | |
| 59 | + | history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2}) | |
| 60 | + | if test.existing { | |
| 61 | + | require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id)) | |
| 62 | + | } | |
| 63 | + | before := history.View() | |
| 64 | + | require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id)) | |
| 65 | + | require.Equal(t, before.Len(), history.Len()) | |
| 66 | + | require.Equal(t, before.NextRowID(), history.NextRowID()) | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 46 | 71 | func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |
| 47 | 72 | const maxPersistedRowID = ^RowID(0) - 2 | |
| 48 | 73 | for _, test := range []struct { | |
| @@ -78,7 +103,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||
| 78 | 103 | require.Equal(t, test.want+1, view.NextRowID()) | |
| 79 | 104 | ||
| 80 | 105 | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |
| 81 | - | require.NoError(t, history.Append(row, LineBound{End: 1}, screen.RowID(0))) | |
| 106 | + | require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0))) | |
| 82 | 107 | _, err = MarshalHistory(history.SealAndView()) | |
| 83 | 108 | require.NoError(t, err) | |
| 84 | 109 | }) | |
| @@ -95,7 +120,7 @@ func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |||
| 95 | 120 | ||
| 96 | 121 | func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |
| 97 | 122 | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | |
| 98 | - | require.NoError(t, history.Append(historyRow("old"), LineBound{End: 3}, RowID(40))) | |
| 123 | + | require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40))) | |
| 99 | 124 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |
| 100 | 125 | require.NoError(t, err) | |
| 101 | 126 | transcript, err := MarshalHistory(HistoryView{ | |
| @@ -141,8 +166,8 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t | |||
| 141 | 166 | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | |
| 142 | 167 | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 143 | 168 | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 144 | - | require.NoError(t, first.Append(historyRow("one"), LineBound{End: 3}, RowID(7))) | |
| 145 | - | require.NoError(t, second.Append(historyRow("two"), LineBound{End: 3}, RowID(7))) | |
| 169 | + | require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7))) | |
| 170 | + | require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7))) | |
| 146 | 171 | firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0)) | |
| 147 | 172 | require.NoError(t, err) | |
| 148 | 173 | secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0)) | |
| @@ -43,6 +43,31 @@ func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) { | |||||
| 43 | require.Error(t, history.Append(historyRow("row"), LineBound{End: 3})) | 43 | require.Error(t, history.Append(historyRow("row"), LineBound{End: 3})) | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | + | func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) { | |||
| 47 | + | const maxPersistedRowID = ^RowID(0) - 2 | |||
| 48 | + | for _, test := range []struct { | |||
| 49 | + | name string | |||
| 50 | + | id RowID | |||
| 51 | + | existing bool | |||
| 52 | + | }{ | |||
| 53 | + | {name: "zero", id: 0}, | |||
| 54 | + | {name: "duplicate", id: 7, existing: true}, | |||
| 55 | + | {name: "first unpersistable", id: maxPersistedRowID + 1}, | |||
| 56 | + | {name: "maximum", id: ^RowID(0)}, | |||
| 57 | + | } { | |||
| 58 | + | t.Run(test.name, func(t *testing.T) { | |||
| 59 | + | history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2}) | |||
| 60 | + | if test.existing { | |||
| 61 | + | require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id)) | |||
| 62 | + | } | |||
| 63 | + | before := history.View() | |||
| 64 | + | require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id)) | |||
| 65 | + | require.Equal(t, before.Len(), history.Len()) | |||
| 66 | + | require.Equal(t, before.NextRowID(), history.NextRowID()) | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 46 | func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | 71 | func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | ||
| 47 | const maxPersistedRowID = ^RowID(0) - 2 | 72 | const maxPersistedRowID = ^RowID(0) - 2 | ||
| 48 | for _, test := range []struct { | 73 | for _, test := range []struct { | ||
| @@ -78,7 +103,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||||
| 78 | require.Equal(t, test.want+1, view.NextRowID()) | 103 | require.Equal(t, test.want+1, view.NextRowID()) | ||
| 79 | 104 | ||||
| 80 | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | 105 | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | ||
| 81 | - | require.NoError(t, history.Append(row, LineBound{End: 1}, screen.RowID(0))) | 106 | + | require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0))) |
| 82 | _, err = MarshalHistory(history.SealAndView()) | 107 | _, err = MarshalHistory(history.SealAndView()) | ||
| 83 | require.NoError(t, err) | 108 | require.NoError(t, err) | ||
| 84 | }) | 109 | }) | ||
| @@ -95,7 +120,7 @@ func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |||||
| 95 | 120 | ||||
| 96 | func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | 121 | func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | ||
| 97 | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | 122 | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | ||
| 98 | - | require.NoError(t, history.Append(historyRow("old"), LineBound{End: 3}, RowID(40))) | 123 | + | require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40))) |
| 99 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | 124 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | ||
| 100 | require.NoError(t, err) | 125 | require.NoError(t, err) | ||
| 101 | transcript, err := MarshalHistory(HistoryView{ | 126 | transcript, err := MarshalHistory(HistoryView{ | ||
| @@ -141,8 +166,8 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t | |||||
| 141 | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | 166 | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | ||
| 142 | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 167 | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
| 143 | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 168 | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
| 144 | - | require.NoError(t, first.Append(historyRow("one"), LineBound{End: 3}, RowID(7))) | 169 | + | require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7))) |
| 145 | - | require.NoError(t, second.Append(historyRow("two"), LineBound{End: 3}, RowID(7))) | 170 | + | require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7))) |
| 146 | firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0)) | 171 | firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0)) | ||
| 147 | require.NoError(t, err) | 172 | require.NoError(t, err) | ||
| 148 | secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0)) | 173 | secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0)) | ||
M pkg/vt/row_id_test.go +3 -3
| @@ -95,7 +95,7 @@ func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | |||
| 95 | 95 | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |
| 96 | 96 | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | |
| 97 | 97 | for i, text := range []string{"one", "two"} { | |
| 98 | - | if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { | |
| 98 | + | if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { | |
| 99 | 99 | t.Fatal(err) | |
| 100 | 100 | } | |
| 101 | 101 | } | |
| @@ -104,7 +104,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |||
| 104 | 104 | t.Fatalf("sealed row ID = %d, want %d", got, want) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | - | if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { | |
| 107 | + | if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { | |
| 108 | 108 | t.Fatal(err) | |
| 109 | 109 | } | |
| 110 | 110 | if got := before.FindRowID(2); got != 1 { | |
| @@ -115,7 +115,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |||
| 115 | 115 | t.Fatalf("new view FindRowID(3) = %d, want 2", got) | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | - | if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { | |
| 118 | + | if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { | |
| 119 | 119 | t.Fatal(err) | |
| 120 | 120 | } | |
| 121 | 121 | if got := before.FindRowID(1); got != 0 { | |
| @@ -95,7 +95,7 @@ func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | |||||
| 95 | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | 95 | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | ||
| 96 | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | 96 | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | ||
| 97 | for i, text := range []string{"one", "two"} { | 97 | for i, text := range []string{"one", "two"} { | ||
| 98 | - | if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { | 98 | + | if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { |
| 99 | t.Fatal(err) | 99 | t.Fatal(err) | ||
| 100 | } | 100 | } | ||
| 101 | } | 101 | } | ||
| @@ -104,7 +104,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |||||
| 104 | t.Fatalf("sealed row ID = %d, want %d", got, want) | 104 | t.Fatalf("sealed row ID = %d, want %d", got, want) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | - | if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { | 107 | + | if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { |
| 108 | t.Fatal(err) | 108 | t.Fatal(err) | ||
| 109 | } | 109 | } | ||
| 110 | if got := before.FindRowID(2); got != 1 { | 110 | if got := before.FindRowID(2); got != 1 { | ||
| @@ -115,7 +115,7 @@ func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |||||
| 115 | t.Fatalf("new view FindRowID(3) = %d, want 2", got) | 115 | t.Fatalf("new view FindRowID(3) = %d, want 2", got) | ||
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| 118 | - | if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { | 118 | + | if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { |
| 119 | t.Fatal(err) | 119 | t.Fatal(err) | ||
| 120 | } | 120 | } | ||
| 121 | if got := before.FindRowID(1); got != 0 { | 121 | if got := before.FindRowID(1); got != 0 { | ||
M pkg/vt/screen_scroll.go +1 -1
| @@ -107,7 +107,7 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 107 | 107 | ||
| 108 | 108 | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | |
| 109 | 109 | if s.history != nil { | |
| 110 | - | err := s.history.Append(row, bound, id) | |
| 110 | + | err := s.history.AppendWithID(row, bound, id) | |
| 111 | 111 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |
| 112 | 112 | panic(err) | |
| 113 | 113 | } | |
| @@ -107,7 +107,7 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 107 | 107 | ||||
| 108 | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | 108 | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | ||
| 109 | if s.history != nil { | 109 | if s.history != nil { | ||
| 110 | - | err := s.history.Append(row, bound, id) | 110 | + | err := s.history.AppendWithID(row, bound, id) |
| 111 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | 111 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | ||
| 112 | panic(err) | 112 | panic(err) | ||
| 113 | } | 113 | } | ||
M pkg/vt/snapshot.go +1 -1
| @@ -179,7 +179,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 179 | 179 | if remainingRows == 0 { | |
| 180 | 180 | bound.Soft = false | |
| 181 | 181 | } | |
| 182 | - | if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil { | |
| 182 | + | if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil { | |
| 183 | 183 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 184 | 184 | } | |
| 185 | 185 | } | |
| @@ -179,7 +179,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 179 | if remainingRows == 0 { | 179 | if remainingRows == 0 { | ||
| 180 | bound.Soft = false | 180 | bound.Soft = false | ||
| 181 | } | 181 | } | ||
| 182 | - | if err := history.appendRestored(row, bound, chunk.rowIDs[i]); err != nil { | 182 | + | if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil { |
| 183 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | 183 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | ||
| 184 | } | 184 | } | ||
| 185 | } | 185 | } | ||