fix(terminal): bound scrollback allocation and reclaim oversized rows

This commit is contained in:
kuddev committed 2026-09-15 23:03:41 +08:00
1 parent a02cf48fee
commit 0ceb077f26
2 files changed
+193 -13

No files matched your search

+105 -1
View File
@@ -11,6 +11,9 @@ use crate::grid::GridCell;
use crate::index::Column;
use crate::term::cell::ResetDiscriminant;
/// Avoid reallocating on every column while still releasing materially oversized rows.
const MIN_RECLAIMABLE_CELLS: usize = 32;
/// A row in the grid.
#[derive(Default, Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
@@ -87,6 +90,7 @@ impl<T: Default> Row<T> {
T: GridCell,
{
if self.inner.len() <= columns {
Self::reclaim_excess_capacity(&mut self.inner, columns);
return None;
}
@@ -97,9 +101,29 @@ impl<T: Default> Row<T> {
let index = max(content, cursor);
new_row.truncate(index);
Self::reclaim_excess_capacity(&mut self.inner, columns);
self.occ = min(self.occ, columns);
if new_row.is_empty() { None } else { Some(new_row) }
if new_row.is_empty() {
None
} else {
// Reflow may grow a short tail back to the new width. Keep that
// much capacity so reclaiming it does not force an immediate grow.
Self::reclaim_excess_capacity(&mut new_row, columns);
Some(new_row)
}
}
#[inline]
fn reclaim_excess_capacity(cells: &mut Vec<T>, columns: usize) {
let retained = max(cells.len(), columns);
let capacity = cells.capacity();
if capacity.saturating_sub(retained) >= MIN_RECLAIMABLE_CELLS
&& capacity >= retained.saturating_mul(2)
{
cells.shrink_to(retained);
}
}
/// Reset all cells in the row to the `template` cell.
@@ -312,3 +336,83 @@ impl<T> IndexMut<RangeToInclusive<Column>> for Row<T> {
&mut self.inner[..=(index.end.0)]
}
}
#[cfg(test)]
mod tests {
use super::Row;
use crate::index::Column;
use crate::term::cell::Cell;
#[test]
fn small_width_changes_keep_row_capacity() {
let mut row = Row::<Cell>::new(100);
let original_capacity = row.inner.capacity();
let _ = row.shrink(70);
assert_eq!(row.len(), 70);
assert_eq!(row.inner.capacity(), original_capacity);
}
#[test]
fn large_width_changes_reclaim_row_capacity() {
let mut row = Row::<Cell>::new(100);
let original_capacity = row.inner.capacity();
let _ = row.shrink(40);
assert_eq!(row.len(), 40);
assert!(row.inner.capacity() < original_capacity);
}
#[test]
fn capacity_reclamation_requires_both_thresholds() {
for (old, new, reclaim) in [(60, 30, false), (100, 60, false), (64, 32, true)] {
let mut row = Row::<Cell>::new(old);
let original_capacity = row.inner.capacity();
let _ = row.shrink(new);
assert_eq!(row.inner.capacity() < original_capacity, reclaim, "{old} -> {new}");
}
}
#[test]
fn short_rows_reclaim_without_losing_occupied_cells() {
let mut cells = Vec::with_capacity(200);
cells.resize(20, Cell::default());
cells[10].c = 'x';
let mut row = Row::from_vec(cells, 11);
assert!(row.shrink(40).is_none());
assert_eq!(row.len(), 20);
assert_eq!(row.occ, 11);
assert_eq!(row[Column(10)].c, 'x');
assert!(row.inner.capacity() < 200);
let capacity = row.inner.capacity();
row.grow(40);
assert_eq!(row.inner.capacity(), capacity);
}
#[test]
fn reclaimed_tail_preserves_content_and_cursor_space_without_regrowing_capacity() {
let mut row = Row::<Cell>::new(200);
row[Column(39)].c = 'a';
row[Column(45)].c = '界';
let tail = row.shrink_with_minimum(40, 60).unwrap();
assert_eq!(row.len(), 40);
assert_eq!(row.occ, 40);
assert_eq!(row[Column(39)].c, 'a');
assert_eq!(tail.len(), 20);
assert_eq!(tail[5].c, '界');
assert!(tail[6..].iter().all(|cell| *cell == Cell::default()));
assert!(tail.capacity() < 160);
let capacity = tail.capacity();
let mut tail = Row::from_vec(tail, 20);
tail.grow(40);
assert_eq!(tail.inner.capacity(), capacity);
}
}
+88 -12
View File
@@ -9,8 +9,9 @@ use serde::{Deserialize, Serialize};
use super::Row;
use crate::index::Line;
/// Maximum number of buffered lines outside of the grid for performance optimization.
const MAX_CACHE_SIZE: usize = 1_000;
/// Bounds for the number of initialized rows kept ahead of active scrollback.
const MIN_CACHE_SIZE: usize = 32;
const MAX_CACHE_SIZE: usize = 128;
/// A ring buffer for optimizing indexing and rotation.
///
@@ -96,10 +97,10 @@ impl<T> Storage<T> {
pub fn shrink_visible_lines(&mut self, next: usize) {
// Shrink the size without removing any lines.
let shrinkage = self.visible_lines - next;
self.shrink_lines(shrinkage);
// Update visible lines.
// Reclaim against the new viewport's cache allowance.
self.visible_lines = next;
self.shrink_lines(shrinkage);
}
/// Shrink the number of lines in the buffer.
@@ -108,7 +109,7 @@ impl<T> Storage<T> {
self.len -= shrinkage;
// Free memory.
if self.inner.len() > self.len + MAX_CACHE_SIZE {
if self.inner.len() > self.len + self.cache_size() {
self.truncate();
}
}
@@ -130,7 +131,7 @@ impl<T> Storage<T> {
if self.len + additional_rows > self.inner.len() {
self.rezero();
let realloc_size = self.inner.len() + max(additional_rows, MAX_CACHE_SIZE);
let realloc_size = self.inner.len() + max(additional_rows, self.cache_size());
self.inner.resize_with(realloc_size, || Row::new(columns));
}
@@ -142,6 +143,13 @@ impl<T> Storage<T> {
self.len
}
/// Keep roughly one viewport ready so line-at-a-time output does not have to
/// rezero the ring on every scroll, without prebuilding thousands of cells.
#[inline]
fn cache_size(&self) -> usize {
self.visible_lines.clamp(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
}
/// Swap implementation for Row<T>.
///
/// Exploits the known size of Row<T> to produce a slightly more efficient
@@ -267,7 +275,7 @@ impl<T> IndexMut<Line> for Storage<T> {
mod tests {
use crate::grid::GridCell;
use crate::grid::row::Row;
use crate::grid::storage::{MAX_CACHE_SIZE, Storage};
use crate::grid::storage::{MAX_CACHE_SIZE, MIN_CACHE_SIZE, Storage};
use crate::index::{Column, Line};
use crate::term::cell::Flags;
@@ -345,7 +353,7 @@ mod tests {
/// 2: -
/// 3: \0
/// ...
/// MAX_CACHE_SIZE: \0
/// MIN_CACHE_SIZE: \0
#[test]
fn grow_after_zero() {
// Setup storage area.
@@ -366,7 +374,7 @@ mod tests {
visible_lines: 4,
len: 4,
};
expected.inner.append(&mut vec![filled_row('\0'); MAX_CACHE_SIZE]);
expected.inner.append(&mut vec![filled_row('\0'); MIN_CACHE_SIZE]);
assert_eq!(storage.visible_lines, expected.visible_lines);
assert_eq!(storage.inner, expected.inner);
@@ -386,7 +394,7 @@ mod tests {
/// 2: -
/// 3: \0
/// ...
/// MAX_CACHE_SIZE: \0
/// MIN_CACHE_SIZE: \0
#[test]
fn grow_before_zero() {
// Setup storage area.
@@ -407,7 +415,7 @@ mod tests {
visible_lines: 4,
len: 4,
};
expected.inner.append(&mut vec![filled_row('\0'); MAX_CACHE_SIZE]);
expected.inner.append(&mut vec![filled_row('\0'); MIN_CACHE_SIZE]);
assert_eq!(storage.visible_lines, expected.visible_lines);
assert_eq!(storage.inner, expected.inner);
@@ -738,7 +746,7 @@ mod tests {
filled_row('4'),
filled_row('5'),
];
let expected_init_size = std::cmp::max(init_size, MAX_CACHE_SIZE);
let expected_init_size = std::cmp::max(init_size, MIN_CACHE_SIZE);
expected_inner.append(&mut vec![filled_row('\0'); expected_init_size]);
let expected_storage = Storage { inner: expected_inner, zero: 0, visible_lines: 0, len: 9 };
@@ -747,6 +755,74 @@ mod tests {
assert_eq!(storage.inner, expected_storage.inner);
}
#[test]
fn initialize_caches_one_bounded_viewport() {
let mut ordinary = Storage::<char>::with_capacity(30, 1);
ordinary.initialize(1, 1);
assert_eq!(ordinary.len, 31);
assert_eq!(ordinary.inner.len(), 30 + MIN_CACHE_SIZE);
let mut medium = Storage::<char>::with_capacity(80, 1);
medium.initialize(1, 1);
assert_eq!(medium.inner.len(), 160);
let mut tall = Storage::<char>::with_capacity(200, 1);
tall.initialize(1, 1);
assert_eq!(tall.inner.len(), 200 + MAX_CACHE_SIZE);
}
#[test]
fn shrink_lines_releases_cache_beyond_one_viewport() {
let mut storage = Storage::<char>::with_capacity(40, 1);
storage.initialize(80, 1);
assert_eq!(storage.inner.len(), 120);
storage.shrink_lines(80);
assert_eq!(storage.len, 40);
assert_eq!(storage.inner.len(), 40);
}
#[test]
fn shrinking_viewport_uses_the_smaller_cache_allowance() {
let mut storage = Storage::<char>::with_capacity(100, 1);
storage.initialize(1, 1);
storage.shrink_visible_lines(30);
assert_eq!(storage.visible_lines, 30);
assert_eq!(storage.len, 31);
assert_eq!(storage.inner.len(), 31);
}
#[test]
fn repeated_growth_and_rotated_reclamation_preserve_line_order() {
let mut storage = Storage::<char>::with_capacity(40, 1);
for value in 1..=400_u32 {
storage.initialize(1, 1);
storage.rotate(-1);
storage[Line(39)][Column(0)] = char::from_u32(value).unwrap();
assert!(storage.inner.len() - storage.len < 40);
for offset in 0..value {
assert_eq!(
storage[Line(39 - offset as i32)][Column(0)],
char::from_u32(value - offset).unwrap(),
);
}
}
storage.shrink_lines(350);
assert_eq!(storage.len, 90);
assert_eq!(storage.inner.len(), 90);
for offset in 0..90 {
assert_eq!(
storage[Line(39 - offset)][Column(0)],
char::from_u32(400 - offset as u32).unwrap(),
);
}
}
#[test]
fn rotate_wrap_zero() {
let mut storage: Storage<char> = Storage {