Make opening large files use less peak memory (#62748)

# Objective
Opening a file laods it twice. `decode_file_text` builds the whole file
as a `String`, and that `String` stays alive alongside the finished rope
while `text::Buffer::new` copies it in. The `Vec` behind it grows by
doubling, so it also commits up to nearly the file's size again in
capacity it never uses.

Partially addresses #27283.

## Solution

Add `decode_file_text_to_rope`, which streams the file in 1 MB blocks
straight into a `Rope`, validating UTF-8 and normalizing line endings as
it goes. The file is never fully held as a `String`.

`LoadedFile::text` becomes a `Rope` carrying the `LineEnding` detected
before normalizing, so `buffer_store` calls `Buffer::new_normalized`.

## Testing

On a 729 MB SQL dump, peak memory fell 25% and CPU fell around 28%.
tested on 5950x, win11.

Release Notes:

- Improved memory use when opening large files, reducing peak memory
during load by roughly the size of the file itself.
This commit is contained in:
Ali authored and GitHub committed 2026-08-18 16:59:08 +00:00
1 parent be5535ef7b
commit ac9a03c4b9
6 files changed
+253 -33

No files matched your search

+3 -2
View File
@@ -69,11 +69,12 @@ impl ContextProvider for JsonTaskProvider {
.await
.ok()?;
let path = cx.update(|cx| file.abs_path(cx)).as_path().into();
let contents_text = contents.text.to_string();
let task_templates = if is_package_json {
let package_json = serde_json_lenient::from_str::<
HashMap<String, serde_json_lenient::Value>,
>(&contents.text)
>(&contents_text)
.ok()?;
let package_json = PackageJsonData::new(path, package_json);
let command = package_json.package_manager.unwrap_or("npm").to_owned();
@@ -100,7 +101,7 @@ impl ContextProvider for JsonTaskProvider {
}])
.collect()
} else if is_composer_json {
serde_json_lenient::Value::from_str(&contents.text)
serde_json_lenient::Value::from_str(&contents_text)
.ok()?
.get("scripts")?
.as_object()?
+6 -1
View File
@@ -696,7 +696,12 @@ impl LocalBufferStore {
let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64());
let text_buffer = cx
.background_spawn(async move {
text::Buffer::new(ReplicaId::LOCAL, buffer_id, loaded.text)
text::Buffer::new_normalized(
ReplicaId::LOCAL,
buffer_id,
loaded.line_ending,
loaded.text,
)
})
.await;
cx.insert_entity(reservation, |_| {
@@ -6105,7 +6105,8 @@ async fn test_rename_file_to_new_directory(cx: &mut gpui::TestAppContext) {
})
.await
.unwrap()
.text,
.text
.to_string(),
expected_contents,
"Moved file's contents should be preserved"
);
@@ -6152,7 +6153,8 @@ async fn test_rename_file_to_new_directory(cx: &mut gpui::TestAppContext) {
})
.await
.unwrap()
.text,
.text
.to_string(),
expected_contents,
"Moved file's contents should be preserved"
);
+5 -7
View File
@@ -732,13 +732,11 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
cx.spawn_in(window, async move |editor, cx| {
if let Some(task) = task {
text.push_str(
&task
.await
.log_err()
.map(|loaded_file| loaded_file.text)
.unwrap_or_default(),
);
if let Some(loaded_file) = task.await.log_err() {
for chunk in loaded_file.text.chunks() {
text.push_str(chunk);
}
}
}
if !text.is_empty() && !is_end_of_file {
+233 -20
View File
@@ -59,6 +59,7 @@ use std::{
ffi::OsStr,
fmt,
future::Future,
io::Read,
mem::{self},
ops::{Deref, DerefMut, Range},
path::{Path, PathBuf},
@@ -108,7 +109,8 @@ pub enum CreatedEntry {
#[derive(Debug)]
pub struct LoadedFile {
pub file: Arc<File>,
pub text: String,
pub text: Rope,
pub line_ending: LineEnding,
pub encoding: &'static Encoding,
pub has_bom: bool,
pub is_writable: bool,
@@ -1715,7 +1717,8 @@ impl LocalWorktree {
{
anyhow::bail!("File is too large to load");
}
let (text, encoding, has_bom) = decode_file_text(fs.as_ref(), &abs_path).await?;
let (text, line_ending, encoding, has_bom) =
decode_file_text_to_rope(fs.as_ref(), &abs_path).await?;
let is_writable = metadata.is_some_and(|metadata| metadata.is_writable);
let worktree = this.upgrade().context("worktree was dropped")?;
@@ -1748,6 +1751,7 @@ impl LocalWorktree {
Ok(LoadedFile {
file,
text,
line_ending,
encoding,
has_bom,
is_writable,
@@ -7173,6 +7177,25 @@ impl fs::Watcher for NullWatcher {
}
}
/// Reads the beginning of `file` to determine its kind and encoding, returning
/// the bytes consumed and whether the file ended within them.
fn read_file_header(file: &mut dyn Read, abs_path: &Path) -> Result<(Vec<u8>, bool)> {
let mut header = Vec::with_capacity(FILE_ANALYSIS_BYTES);
let mut buf = [0u8; FILE_ANALYSIS_BYTES];
let mut reached_eof = false;
while header.len() < FILE_ANALYSIS_BYTES {
let n = file
.read(&mut buf)
.with_context(|| format!("reading bytes of the file {abs_path:?}"))?;
if n == 0 {
reached_eof = true;
break;
}
header.extend_from_slice(&buf[..n]);
}
Ok((header, reached_eof))
}
pub async fn decode_file_text(
fs: &dyn Fs,
abs_path: &Path,
@@ -7182,24 +7205,7 @@ pub async fn decode_file_text(
.await
.with_context(|| format!("opening file {abs_path:?}"))?;
// First, read the beginning of the file to determine its kind and encoding.
// We do not want to load an entire large blob into memory only to discard it.
let mut file_first_bytes = Vec::with_capacity(FILE_ANALYSIS_BYTES);
let mut buf = [0u8; FILE_ANALYSIS_BYTES];
let mut reached_eof = false;
loop {
if file_first_bytes.len() >= FILE_ANALYSIS_BYTES {
break;
}
let n = file
.read(&mut buf)
.with_context(|| format!("reading bytes of the file {abs_path:?}"))?;
if n == 0 {
reached_eof = true;
break;
}
file_first_bytes.extend_from_slice(&buf[..n]);
}
let (file_first_bytes, reached_eof) = read_file_header(&mut *file, abs_path)?;
let (_, byte_content) = decode_byte_header(&file_first_bytes);
anyhow::ensure!(
byte_content != ByteContent::Binary,
@@ -7224,6 +7230,156 @@ pub async fn decode_file_text(
Ok((decoded.text, decoded.encoding, decoded.has_bom))
}
const STREAM_BLOCK_BYTES: usize = 1024 * 1024;
/// Reads and decodes a file straight into a [`Rope`].
/// The returned rope has already had its line endings normalized, the
/// [`LineEnding`] detected before normalizing is returned alongside it.
pub async fn decode_file_text_to_rope(
fs: &dyn Fs,
abs_path: &Path,
) -> Result<(Rope, LineEnding, &'static Encoding, bool)> {
let mut file = fs
.open_sync(abs_path)
.await
.with_context(|| format!("opening file {abs_path:?}"))?;
let (prefix, reached_eof) = read_file_header(&mut *file, abs_path)?;
let (bom_encoding, byte_content) = decode_byte_header(&prefix);
anyhow::ensure!(
byte_content != ByteContent::Binary,
"Binary files are not supported"
);
// Only BOM-less, non-UTF-16 files are candidates for streaming: everything
// else needs the whole byte buffer in hand to decode or to detect encoding.
if bom_encoding.is_none()
&& byte_content == ByteContent::Unknown
&& let Some((rope, line_ending)) =
stream_utf8_into_rope(&mut *file, prefix, reached_eof, abs_path)?
{
return Ok((rope, line_ending, encoding_rs::UTF_8, false));
}
// Not plain UTF-8 after all. Re-read the file and decode it all at once.
let (mut text, encoding, has_bom) = decode_file_text(fs, abs_path).await?;
let line_ending = LineEnding::detect(&text);
LineEnding::normalize(&mut text);
Ok((Rope::from(text), line_ending, encoding, has_bom))
}
/// Streams a presumed-UTF-8 file into a [`Rope`], normalizing line endings as it
/// goes.
///
/// Returns `None` if the file turns out not to be plain UTF-8, in which case the
/// caller re-reads it and decodes it the slow way. `prefix` is the portion of
/// the file already consumed from `file` for encoding detection.
fn stream_utf8_into_rope(
file: &mut dyn Read,
prefix: Vec<u8>,
reached_eof: bool,
abs_path: &Path,
) -> Result<Option<(Rope, LineEnding)>> {
let mut rope = Rope::new();
let mut line_ending = None;
let mut scratch = String::new();
let mut pending = prefix;
let mut buf = vec![0u8; STREAM_BLOCK_BYTES];
let mut eof = reached_eof;
loop {
// Fill a whole block before decoding, so that each `Rope::push` gets a
// slice large enough to build its chunks in parallel.
while !eof && pending.len() < STREAM_BLOCK_BYTES {
let n = file
.read(&mut buf)
.with_context(|| format!("reading bytes of the file {abs_path:?}"))?;
if n == 0 {
eof = true;
} else {
pending.extend_from_slice(&buf[..n]);
}
}
// Decode as much of `pending` as forms complete UTF-8.
let valid_len = match std::str::from_utf8(&pending) {
Ok(_) => pending.len(),
// A multi-byte character straddling the block boundary; the rest of
// it arrives with the next block.
Err(e) if e.error_len().is_none() && !eof => e.valid_up_to(),
// Genuinely not UTF-8, or truncated at EOF: fall back.
Err(_) => return Ok(None),
};
// Hold back a trailing carriage return until we know what follows it.
let emit_len = if !eof && valid_len > 0 && pending[valid_len - 1] == b'\r' {
valid_len - 1
} else {
valid_len
};
let text = std::str::from_utf8(&pending[..emit_len])
.expect("a prefix of validated UTF-8 is itself valid UTF-8");
// ISO-2022-JP and friends are valid UTF-8 but carry escape sequences, so
// they need the full-file encoding detector rather than this fast path.
if text.contains('\x1b') {
return Ok(None);
}
// `LineEnding::detect` only inspects the first 1000 bytes, so the first
// block gives the same answer the whole file would.
if line_ending.is_none() && !text.is_empty() {
line_ending = Some(LineEnding::detect(text));
}
push_normalized(&mut rope, text, &mut scratch);
pending.drain(..emit_len);
if eof {
break;
}
}
// At EOF everything should have been consumed. Anything left over is a
// truncated multi-byte sequence, which means this is not valid UTF-8.
if !pending.is_empty() {
return Ok(None);
}
Ok(Some((rope, line_ending.unwrap_or_default())))
}
/// Appends `text` to `rope`, rewriting CRLF and lone CR as LF, matching
/// [`LineEnding::normalize`]. `scratch` is reused across calls.
fn push_normalized(rope: &mut Rope, text: &str, scratch: &mut String) {
if !text.contains('\r') {
rope.push(text);
return;
}
scratch.clear();
scratch.reserve(text.len());
let bytes = text.as_bytes();
let mut start = 0;
let mut ix = 0;
while ix < bytes.len() {
if bytes[ix] == b'\r' {
scratch.push_str(&text[start..ix]);
scratch.push('\n');
ix += if bytes.get(ix + 1) == Some(&b'\n') {
2
} else {
1
};
start = ix;
} else {
ix += 1;
}
}
scratch.push_str(&text[start..]);
rope.push(scratch);
}
pub fn decode_byte_header(prefix: &[u8]) -> (Option<&'static Encoding>, ByteContent) {
if let Some((encoding, _bom_len)) = Encoding::for_bom(prefix) {
return (Some(encoding), ByteContent::Unknown);
@@ -7235,6 +7391,63 @@ pub fn decode_byte_header(prefix: &[u8]) -> (Option<&'static Encoding>, ByteCont
mod tests {
use super::*;
/// Streams `bytes` the way `decode_file_text_to_rope` would, returning the
/// decoded text and detected line ending, or `None` if the fast path bailed.
fn stream(bytes: &[u8]) -> Option<(String, LineEnding)> {
let mut reader = std::io::Cursor::new(bytes.to_vec());
stream_utf8_into_rope(&mut reader, Vec::new(), false, Path::new("test"))
.unwrap()
.map(|(rope, line_ending)| (rope.to_string(), line_ending))
}
#[test]
fn test_stream_utf8_normalizes_line_endings() {
let crlf = "one\r\ntwo\r\nthree\r\n".repeat(40);
let (text, line_ending) = stream(crlf.as_bytes()).unwrap();
assert_eq!(text, crlf.replace("\r\n", "\n"));
assert_eq!(line_ending, LineEnding::Windows);
let cr = "one\rtwo\rthree\r".repeat(40);
assert_eq!(stream(cr.as_bytes()).unwrap().0, cr.replace('\r', "\n"));
}
#[test]
fn test_stream_utf8_block_boundaries() {
// A carriage return landing on the last byte of a block, with and
// without its newline arriving in the next one.
for (suffix, expected) in [("\r\ntail\n", "\ntail\n"), ("\rtail", "\ntail")] {
let filler = "a".repeat(STREAM_BLOCK_BYTES - 1);
let source = format!("{filler}{suffix}");
assert_eq!(
stream(source.as_bytes()).unwrap().0,
format!("{filler}{expected}"),
"suffix = {suffix:?}"
);
}
// Multi-byte characters straddling the boundary at every split point.
for ch in ['\u{20ac}', '\u{1f600}'] {
for split in 1..=ch.len_utf8() {
let filler = "a".repeat(STREAM_BLOCK_BYTES - split);
let source = format!("{filler}{ch}tail");
assert_eq!(
stream(source.as_bytes()).unwrap().0,
source,
"ch = {ch:?}, split = {split}"
);
}
}
}
#[test]
fn test_stream_utf8_falls_back_on_non_utf8() {
// Each of these must bail so the caller re-reads and decodes the slow
// way, rather than silently mangling the file.
assert_eq!(stream(b"hello \xff\xfeA"), None, "invalid utf-8");
assert_eq!(stream(b"hello \xe2\x82"), None, "truncated at eof");
assert_eq!(stream(b"plain \x1b$B text"), None, "iso-2022 escape");
}
/// reproduction of issue #50785
fn build_pcm16_wav_bytes() -> Vec<u8> {
let header: Vec<u8> = vec![
@@ -5593,7 +5593,8 @@ async fn test_load_file_encoding(cx: &mut TestAppContext) {
}
let loaded = loaded.unwrap();
assert_eq!(
loaded.text, case.expected_text,
loaded.text.to_string(),
case.expected_text,
"Encoding mismatch for file: {}",
case.name
);