Skip to content

Bound auto-reload's background diff to a max content size - #15312

Draft
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/app-5529-bound-auto-reload-diff-memory
Draft

Bound auto-reload's background diff to a max content size#15312
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/app-5529-bound-auto-reload-diff-memory

Conversation

@warp-agent-staging

@warp-agent-staging warp-agent-staging Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Description

GlobalBufferModel::start_background_diff_parse (auto-reload path, behind FeatureFlag::IncrementalAutoReload, which is compiled into the default feature set for the app/warp crate — i.e. it is already on for stable users) unconditionally cloned the buffer's full current text (buffer.as_ref(ctx).text().into_string()) and the full new file content (new_content.to_string()) into owned Strings on every auto-reload event, with no size guard, before handing both to imara_diff's text_diff. For a large file this puts several multiples of the file's size live on the heap for a single auto-reload: one such clone alone was 7.52 GB (58%) of a 12.92 GB sampled heap in APP-5529 / Sentry.

Design choice: size bound over handle handoff. Buffer's internal representation is a rope of styled blocks, not a plain string, so .text() always has to materialize a fresh String for the diff — there's no existing cheaply-clonable text to hand off for the old side. The new content clone could in principle be avoided by changing FileModelEvent to carry an Arc<str>/owned String instead of a borrowed &str through populate_buffer_with_read_content, but that touches the ownership model across three call sites (FileLoaded, FileUpdated, discard_unsaved_changes) for a change whose worst case is already eliminated by the size bound below — out of scope for this fix per the linked ticket.

This bounds the diff path by content size: when either the buffer's current content or the new file content is above MAX_INCREMENTAL_DIFF_CONTENT_BYTES (32 MiB), populate_buffer_with_read_content skips start_background_diff_parse entirely and takes a synchronous replace_all fallback (apply_synchronous_reload) — the same one already used when IncrementalAutoReload is disabled. This also skips the diff's own imara_diff::Interner/InternedInput allocation (~0.72 GB in the same heap profile), since text_diff is simply never called for oversized content.

Revision (post-review): an adversarial review of the first version of this fix found the naive fallback silently dropped two behaviors the background-diff path provided, so apply_synchronous_reload now also:

  • Preserves ServerLocal broadcast semantics. ServerModel only reacts to a failed BufferUpdatedFromFileEvent (to forward a conflict); a successful one is not handled at all. The generic fallback used to emit only that event, so a ServerLocal buffer's connected clients would never learn oversized content had changed. The fallback now mirrors apply_diff_result's ServerLocal branch: it bumps the sync clock's server_version and emits a full-replacement ServerLocalBufferUpdated (built the same way force_reload_server_local already builds one), instead of BufferUpdatedFromFileEvent.
  • Aborts a stale pending diff before replacing. An under-limit update can leave pending_diff_parse set; a subsequent over-limit update used to leave it untouched, so the stale background task would keep its owned text/interner alive, then later fail its base-version check against content it no longer matches and emit a spurious BufferUpdatedFromFileEvent { success: false } (forwarded to ServerLocal clients as a conflict). apply_synchronous_reload now takes and aborts state.pending_diff_parse first, the same way start_background_diff_parse already does when a newer diff supersedes an older one.
  • Also fixed a doc/code off-by-one: the threshold's doc comment said "at or above" but the check is strictly >; reworded to "above" to match.

The base_version staleness check and the ServerLocal byte→char conversion in apply_diff_result are otherwise untouched — they only run once a diff is actually produced.

32 MiB is chosen well above the size of source/config files the incremental path exists to serve (preserving undo history and anchors across an on-disk change), so only files far outside that use case fall back to a full replace. (The threshold value itself, as a UI-latency cutoff for running replace_all synchronously on the model/UI context, is a separate judgment call for the requester — not changed here.)

Fixes APP-5529. Distinct facets of the same Sentry issue (Buffer::replace_all, StyledBufferRun/invalidate_layout, etc.) are tracked separately in APP-5357/APP-5353/APP-4844/APP-5388/APP-5429/APP-5462/APP-5458/APP-4840/APP-5524/APP-5464 and are intentionally not touched here.

Linked Issue

  • Filed by the Sentry memory-triage bot as APP-5529; no separate GitHub issue.

Testing

  • cargo check -p warp --lib --features test-util and --features test-util,local_fs: both pass.
  • cargo clippy -p warp --lib --features test-util --all-targets -- -D warnings: passes, no warnings.
  • ./script/format: clean (no diffs after running).
  • cargo test -p warp --lib --features test-util global_buffer_model_tests: all 8 tests pass, including four new regression tests:
    • auto_reload_under_size_limit_uses_background_diff: content under the limit still spawns the background diff (existing behavior preserved).
    • auto_reload_over_size_limit_falls_back_to_synchronous_replace: content over the limit takes the synchronous fallback instead, with no background diff spawned.
    • auto_reload_over_size_limit_broadcasts_for_server_local_buffer: a ServerLocal buffer's sync-clock server_version still bumps for over-limit content (regression test for the broadcast gap above).
    • auto_reload_over_size_limit_aborts_stale_pending_diff: an under-limit update followed by an over-limit one leaves no stale pending diff (regression test for the abort gap above).
    • The size threshold is a #[cfg(test)]-shrunk constant (4096 bytes) so these tests exercise the real boundary logic without materializing a 32 MiB buffer; the test/prod split technique mirrors the existing TEXT_FRAGMENT_SIZE split, though that precedent only justifies the technique, not the 32 MiB production value.
  • Not independently verified: end-to-end behavior of a real multi-GB file under a running Warp client, or a real ServerLocal/remote-server round trip — no toolchain for that scale of manual reproduction in this environment. The size-guard and fallback logic are covered by the tests above.
  • No UI change, so no visual proof is included.

CHANGELOG-BUG-FIX: Fixed a memory blowup on auto-reload of very large files with unsaved-change preservation enabled.

start_background_diff_parse cloned the buffer's full current text and the
full new file content into owned Strings on every auto-reload event, with
no size guard, then handed both to imara-diff's text_diff. For a large
buffer this meant several multiples of the file's size allocated live at
once on a single auto-reload (APP-5529): 7.52 GB for one such clone in a
12.92 GB sampled heap.

Gate the incremental diff path by content size: when either the buffer's
current content or the new file content is at or above
MAX_INCREMENTAL_DIFF_CONTENT_BYTES (32 MiB), skip start_background_diff_parse
and take the same synchronous replace_all fallback already used when
IncrementalAutoReload is disabled. The size check reads the buffer's
precomputed byte-length summary, so it never itself forces a full-text
materialization.
@cla-bot cla-bot Bot added the cla-signed label Aug 19, 2026
@warp-agent-staging warp-agent-staging Bot added factory:wilson area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. labels Aug 19, 2026
@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

Comment @warp-factory on this PR to send it follow-up work.

View run View conversation

…ize-gate fallback

Review found two correctness gaps in the fallback the new size gate routes
oversized auto-reload content to:

- ServerLocal buffers only got the generic BufferUpdatedFromFileEvent, which
  ServerModel ignores on success. Connected clients never learned the content
  changed. The fallback now mirrors apply_diff_result's ServerLocal branch:
  bump the sync clock and broadcast a full-replacement ServerLocalBufferUpdated.
- An under-limit update could leave pending_diff_parse set; a later over-limit
  update took the fallback without touching it, so the stale background diff
  would later fail its base-version check and broadcast a spurious conflict
  (while also holding its owned text/interner alive longer than necessary).
  The fallback now takes and aborts any pending diff first, same as
  start_background_diff_parse already does for a newer diff superseding an
  older one.

Also fixed a doc/code off-by-one: the threshold comment said content "at or
above" the cutoff falls back, but the check is strictly greater-than.

Added a ServerLocal regression test asserting the sync clock still bumps for
over-limit content, and a regression test for an under-limit update followed
by an over-limit one, asserting the stale diff no longer shows as pending.

@warp-agent-staging warp-agent-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Bounds auto-reload's background diff by content size so the unbounded full-content clones can no longer reach multi-GB, falling back to the existing synchronous replace_all above the limit. The memory fix itself is sound; one design question needs an owner's decision before this merges.

Concerns

  • The 32 MiB cutoff is justified as a content-size bound but not as a latency bound: content just over it now runs replace_all synchronously on the model/UI context, trading the memory spike for a possible UI stall on exactly the largest files. That trade is probably right, but nobody has measured what the synchronous path costs at that size, and the TEXT_FRAGMENT_SIZE precedent cited in the doc comment only supports the test/prod split, not the production number. Either take a representative measurement at the boundary, or record an explicit decision that the stall is acceptable for auto-reload.

Verdict

Checks: build pass, tests pass, CI green, visual proof n/a

Found: 0 critical, 0 important, 0 suggestions, 0 nits, 1 question

Responding as wilson: Open session · View factory task

/// is, so tests can exercise the fallback without materializing a
/// multi-megabyte buffer.
#[cfg(not(test))]
const MAX_INCREMENTAL_DIFF_CONTENT_BYTES: usize = 32 * 1024 * 1024;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the synchronous fallback cost at this boundary? The doc above argues 32 MiB sits well above the source/config files the incremental path serves, which justifies it as a memory bound. The open question is the other side: a 32 MiB replace_all on the model/UI context may stall the UI where the old path only spiked memory. A measurement at the boundary, or an explicit "acceptable for auto-reload" call, would settle it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. cla-signed factory:wilson

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants