Fix text finder crash from unbounded memory use (#60377)

- Fixes #60238.
- The text finder could spike memory into the tens of gigabytes and
crash the app. The finder was copying the full matched line into every
match it kept, so memory grew with the number of matches times the size
of each line. Enough matches carrying enough text and the process runs
out of memory.

## Solution

- Stop storing line text on matches. Each match now keeps only its
position and column, and the text shown in a row is built lazily for the
visible rows, using a bounded slice around the match rather than the
whole line. Line boundaries come from the buffer's line index instead of
re-scanning the content per match.
- Cap the number of matches the finder builds while streaming results,
using the same ceiling project search already applies. This keeps the
finder from ever assembling an unbounded match list on the UI thread.
- Behavior is unchanged: every occurrence is still its own row, jumping
to a match lands on the exact line and column, and the rendered text and
highlighting look the same. The finder just no longer holds text
proportional to the match count.

## Testing
- Added tests covering the new behavior: the match count is capped while
streaming, every occurrence below the ceiling still becomes its own
match at the correct line and column, and the rendered slice around a
match stays bounded on a huge line while still covering a whole short
line.
- Verified by hand with the reproduction from the issue. The screenshot
below shows the memory usage and the matching against the issue data:
<img width="1277" height="1053" alt="Screenshot 2026-07-03 at 17 43 11"
src="https://github.com/user-attachments/assets/71c0aa3b-2423-427d-91ba-792951d9ba31"
/>

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed a crash and high memory usage in the text finder.
This commit is contained in:
David Alecrim 2026-07-06 12:21:32 -03:00 committed by GitHub
parent 2b0a83ea81
commit e58a02d974
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 208 additions and 46 deletions

View file

@ -152,7 +152,7 @@ impl Search {
}
pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000;
pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
pub const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
/// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers
/// or full search results.
pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle {

View file

@ -475,8 +475,7 @@ pub struct SearchMatch {
pub buffer: Entity<Buffer>,
pub anchor_range: Range<Anchor>,
pub range: Range<usize>,
pub relative_range: Range<usize>,
pub line_text: String,
pub match_start_byte_column: u32,
pub line_number: u32,
}

View file

@ -38,7 +38,7 @@ use gpui::{
use gpui::{Entity, FocusHandle};
use language::{Buffer, LanguageAwareStyling};
use picker::{Picker, PickerDelegate};
use project::{Project, ProjectPath};
use project::{Project, ProjectPath, Search};
use project::{SearchResults, search::SearchQuery, search::SearchResult};
use settings::Settings;
use smol::future::yield_now;
@ -130,27 +130,15 @@ fn multibuffer_ranges_to_search_matches<'a>(
let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start);
let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end);
let line_number = buffer_snapshot.offset_to_point(start_offset).row + 1;
let text = buffer_snapshot.text();
let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_end = text[start_offset..]
.find('\n')
.map(|i| start_offset + i)
.unwrap_or(text.len());
let line_text = text[line_start..line_end].to_string();
let relative_start = start_offset - line_start;
let relative_end = end_offset - line_start;
let point = buffer_snapshot.offset_to_point(start_offset);
Some(SearchMatch {
path,
buffer,
anchor_range: text_range,
range: start_offset..end_offset,
relative_range: relative_start..relative_end,
line_text,
line_number,
match_start_byte_column: point.column,
line_number: point.row + 1,
})
})
}
@ -476,7 +464,7 @@ impl Delegate {
};
let path = selected_match.path.clone();
let line_number = selected_match.line_number;
let column = selected_match.relative_range.start as u32;
let column = selected_match.match_start_byte_column;
let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
return;
};
@ -594,6 +582,7 @@ const SEARCH_DEBOUNCE_MS: u64 = 100;
const CLICK_THRESHOLD_MS: u128 = 50;
const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300;
const SEARCH_RESULTS_BATCH_SIZE: usize = 256;
const MAX_MATCH_CONTEXT_BYTES: usize = 512;
impl PickerDelegate for Delegate {
type ListItem = AnyElement;
@ -826,7 +815,7 @@ impl PickerDelegate for Delegate {
let path = selected_match.path.clone();
let line_number = selected_match.line_number;
let column = selected_match.relative_range.start as u32;
let column = selected_match.match_start_byte_column;
let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
return;
@ -1052,6 +1041,11 @@ async fn stream_results_to_picker(
.ready_chunks(SEARCH_RESULTS_BATCH_SIZE)
);
// Project search enforces its ranges cap per file,
// so one minified line slips through uncapped; cap it here.
let cap = Search::MAX_SEARCH_RESULT_RANGES;
let mut total_matches = 0;
let mut clear_existing = matches!(imported_matches, ImportedMatches::No);
while let Some(results) = results_stream.next().await {
if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) {
@ -1064,8 +1058,14 @@ async fn stream_results_to_picker(
for result in results {
match result {
SearchResult::Buffer { buffer, ranges } => {
let matches = Delegate::process_search_result(&buffer, &ranges, cx);
let remaining = cap.saturating_sub(total_matches + batch_matches.len());
let capped = ranges.len().min(remaining);
let matches = Delegate::process_search_result(&buffer, &ranges[..capped], cx);
batch_matches.extend(matches);
if capped < ranges.len() {
limit_reached = true;
break;
}
}
SearchResult::LimitReached => {
limit_reached = true;
@ -1074,6 +1074,8 @@ async fn stream_results_to_picker(
}
}
total_matches += batch_matches.len();
picker
.update(cx, |picker, cx| {
let delegate = &mut picker.delegate;
@ -1115,6 +1117,29 @@ async fn stream_results_to_picker(
None
}
/// Byte range around the match to render: a bounded slice of the matched line so rendering never scales with line length.
fn matched_line_window(
snapshot: &language::BufferSnapshot,
match_range: &Range<usize>,
column: u32,
) -> Range<usize> {
let line_start = match_range.start.saturating_sub(column as usize);
let row = snapshot.offset_to_point(match_range.start).row;
let line_end = snapshot.point_to_offset(text::Point::new(row, snapshot.line_len(row)));
let start = snapshot.clip_offset(
match_range
.start
.saturating_sub(MAX_MATCH_CONTEXT_BYTES)
.max(line_start),
text::Bias::Left,
);
let end = snapshot.clip_offset(
(match_range.end + MAX_MATCH_CONTEXT_BYTES).min(line_end),
text::Bias::Right,
);
start..end
}
/// Renders the matched source line with syntax highlighting, overlaying the
/// search match with a highlighted background and bold weight.
fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText {
@ -1129,23 +1154,40 @@ fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText {
line_height: relative(1.),
..Default::default()
};
let original_line = &search_match.line_text;
let line_text = original_line.trim_start();
let trim_offset = original_line.len() - line_text.len();
let search_match_style = HighlightStyle {
background_color: Some(cx.theme().colors().search_match_background),
font_weight: Some(gpui::FontWeight::BOLD),
..Default::default()
};
let line_start_abs = search_match.range.start - search_match.relative_range.start;
let visible_start_abs = line_start_abs + trim_offset;
let visible_end_abs = line_start_abs + original_line.len();
let snapshot = search_match.buffer.read(cx).snapshot();
// Render a bounded window around the match, not the whole line,
// so a minified single-line file stays cheap.
let line_start_abs = search_match
.range
.start
.saturating_sub(search_match.match_start_byte_column as usize);
let window = matched_line_window(
&snapshot,
&search_match.range,
search_match.match_start_byte_column,
);
let window_text: String = snapshot.text_for_range(window.clone()).collect();
// Trim leading indentation only when the window starts at the line start;
// a mid-line window already begins on content.
let trim_offset = if window.start == line_start_abs {
window_text.len() - window_text.trim_start().len()
} else {
0
};
let visible_start_abs = window.start + trim_offset;
let visible_end_abs = window.end;
let line_text = &window_text[trim_offset..];
// Syntax highlights for the visible (trimmed) portion of the line, with
// ranges relative to the start of the rendered text.
let snapshot = search_match.buffer.read(cx).snapshot();
let syntax_theme = cx.theme().syntax();
let mut syntax_highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
let mut current_offset = 0;
@ -1235,23 +1277,11 @@ impl Delegate {
worktree_id: f.worktree_id(cx),
path: f.path().clone(),
});
let text = buf.text();
let mut matches = Vec::new();
for anchor_range in ranges {
let start_offset: usize = buf.summary_for_anchor(&anchor_range.start);
let end_offset: usize = buf.summary_for_anchor(&anchor_range.end);
let match_row = buf.offset_to_point(start_offset).row;
let line_number = match_row + 1;
let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_end = text[start_offset..]
.find('\n')
.map(|i| start_offset + i)
.unwrap_or(text.len());
let line_text = text[line_start..line_end].to_string();
let relative_start = start_offset - line_start;
let relative_end = end_offset - line_start;
let point = buf.offset_to_point(start_offset);
if let Some(path) = &path {
matches.push(SearchMatch {
@ -1259,9 +1289,8 @@ impl Delegate {
buffer: buffer.clone(),
anchor_range: anchor_range.clone(),
range: start_offset..end_offset,
relative_range: relative_start..relative_end,
line_text,
line_number,
match_start_byte_column: point.column,
line_number: point.row + 1,
});
}
}
@ -1269,3 +1298,137 @@ impl Delegate {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{AppContext, TestAppContext};
use project::search::{SearchQuery, SearchResult};
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use util::path;
use util::paths::PathMatcher;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
crate::init(cx);
});
}
async fn project_with_file(cx: &mut TestAppContext, contents: String) -> Entity<Project> {
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(path!("/dir"), json!({ "sample.js": contents }))
.await;
Project::test(fs, [path!("/dir").as_ref()], cx).await
}
#[gpui::test]
async fn test_finder_caps_matches_on_long_line(cx: &mut TestAppContext) {
use workspace::MultiWorkspace;
init_test(cx);
let line = "return ".repeat(Search::MAX_SEARCH_RESULT_RANGES * 2);
let project = project_with_file(cx, line).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let delegate = window
.update(cx, |_mw, window, cx| {
workspace.update(cx, |workspace, cx| Delegate::new(workspace, window, cx))
})
.unwrap()
.await;
let picker = window
.update(cx, |_mw, window, cx| {
cx.new(|cx| Picker::list(delegate, window, cx))
})
.unwrap();
window
.update(cx, |_mw, window, cx| {
picker.update(cx, |picker, cx| picker.set_query("return", window, cx))
})
.unwrap();
// Search is debounced; advance past the debounce and let results stream in.
cx.executor()
.advance_clock(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS + 50));
cx.run_until_parked();
picker.read_with(cx, |picker, _| {
assert_eq!(
picker.delegate.matches.len(),
Search::MAX_SEARCH_RESULT_RANGES
);
});
}
#[gpui::test]
async fn test_builds_one_match_per_occurrence(cx: &mut TestAppContext) {
init_test(cx);
let line = "return ".repeat(2_000);
let expected_matches = line.matches("return").count();
let project = project_with_file(cx, line).await;
let query = SearchQuery::text(
"return",
false,
false,
false,
PathMatcher::default(),
PathMatcher::default(),
false,
None,
)
.unwrap();
let search = project.update(cx, |project, cx| project.search(query, cx));
let async_cx = cx.to_async();
let mut matches = Vec::new();
while let Ok(SearchResult::Buffer { buffer, ranges }) = search.rx.recv().await {
matches.extend(Delegate::process_search_result(&buffer, &ranges, &async_cx));
}
assert_eq!(matches.len(), expected_matches);
assert!(matches.iter().all(|m| m.line_number == 1));
assert!(
matches
.windows(2)
.all(|pair| pair[0].match_start_byte_column < pair[1].match_start_byte_column)
);
}
#[gpui::test]
fn test_matched_line_window_is_bounded(cx: &mut gpui::TestAppContext) {
let long_line = "abcdefghij".repeat(1_000_000);
let buffer = cx.new(|cx| language::Buffer::local(long_line, cx));
buffer.read_with(cx, |buffer, _| {
let snapshot = buffer.snapshot();
let match_range = 5_000_000..5_000_003;
let column = match_range.start as u32; // single line, so column equals the offset
let window = matched_line_window(&snapshot, &match_range, column);
assert!(window.start <= match_range.start && window.end >= match_range.end);
assert!(window.len() <= 2 * MAX_MATCH_CONTEXT_BYTES + match_range.len());
});
let buffer = cx.new(|cx| language::Buffer::local(" let foo = bar;", cx));
buffer.read_with(cx, |buffer, _| {
let snapshot = buffer.snapshot();
let match_range = 8..11; // "foo"
let window = matched_line_window(&snapshot, &match_range, match_range.start as u32);
assert_eq!(window, 0..snapshot.len());
});
}
}