From 0ceb077f26b3bdcc17427fcfdc010eaa7b3d3283 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 23:03:41 +0800 Subject: [PATCH] fix(terminal): bound scrollback allocation and reclaim oversized rows --- nebula_terminal/src/grid/row.rs | 106 +++++++++++++++++++++++++++- nebula_terminal/src/grid/storage.rs | 100 ++++++++++++++++++++++---- 2 files changed, 193 insertions(+), 13 deletions(-) diff --git a/nebula_terminal/src/grid/row.rs b/nebula_terminal/src/grid/row.rs index c39150b..e43a4a2 100644 --- a/nebula_terminal/src/grid/row.rs +++ b/nebula_terminal/src/grid/row.rs @@ -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 Row { T: GridCell, { if self.inner.len() <= columns { + Self::reclaim_excess_capacity(&mut self.inner, columns); return None; } @@ -97,9 +101,29 @@ impl Row { 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, 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 IndexMut> for Row { &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::::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::::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::::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::::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); + } +} diff --git a/nebula_terminal/src/grid/storage.rs b/nebula_terminal/src/grid/storage.rs index 6d7984d..bafe81e 100644 --- a/nebula_terminal/src/grid/storage.rs +++ b/nebula_terminal/src/grid/storage.rs @@ -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 Storage { 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 Storage { 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 Storage { 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 Storage { 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. /// /// Exploits the known size of Row to produce a slightly more efficient @@ -267,7 +275,7 @@ impl IndexMut for Storage { 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::::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::::with_capacity(80, 1); + medium.initialize(1, 1); + assert_eq!(medium.inner.len(), 160); + + let mut tall = Storage::::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::::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::::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::::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 = Storage {