Bound auto-reload's background diff to a max content size - #15312
Bound auto-reload's background diff to a max content size#15312warp-agent-staging[bot] wants to merge 2 commits into
Conversation
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.
|
This PR was generated with Warp. Comment |
…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.
There was a problem hiding this comment.
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_allsynchronously 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 theTEXT_FRAGMENT_SIZEprecedent 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; |
There was a problem hiding this comment.
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.


Description
GlobalBufferModel::start_background_diff_parse(auto-reload path, behindFeatureFlag::IncrementalAutoReload, which is compiled into thedefaultfeature set for theapp/warpcrate — 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 ownedStrings on every auto-reload event, with no size guard, before handing both toimara_diff'stext_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 freshStringfor 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 changingFileModelEventto carry anArc<str>/ownedStringinstead of a borrowed&strthroughpopulate_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_contentskipsstart_background_diff_parseentirely and takes a synchronousreplace_allfallback (apply_synchronous_reload) — the same one already used whenIncrementalAutoReloadis disabled. This also skips the diff's ownimara_diff::Interner/InternedInputallocation (~0.72 GB in the same heap profile), sincetext_diffis 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_reloadnow also:ServerLocalbroadcast semantics.ServerModelonly reacts to a failedBufferUpdatedFromFileEvent(to forward a conflict); a successful one is not handled at all. The generic fallback used to emit only that event, so aServerLocalbuffer's connected clients would never learn oversized content had changed. The fallback now mirrorsapply_diff_result'sServerLocalbranch: it bumps the sync clock'sserver_versionand emits a full-replacementServerLocalBufferUpdated(built the same wayforce_reload_server_localalready builds one), instead ofBufferUpdatedFromFileEvent.pending_diff_parseset; 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 spuriousBufferUpdatedFromFileEvent { success: false }(forwarded toServerLocalclients as a conflict).apply_synchronous_reloadnow takes and abortsstate.pending_diff_parsefirst, the same waystart_background_diff_parsealready does when a newer diff supersedes an older one.>; reworded to "above" to match.The
base_versionstaleness check and theServerLocalbyte→char conversion inapply_diff_resultare 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_allsynchronously 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
Testing
cargo check -p warp --lib --features test-utiland--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: aServerLocalbuffer's sync-clockserver_versionstill 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).#[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 existingTEXT_FRAGMENT_SIZEsplit, though that precedent only justifies the technique, not the 32 MiB production value.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.CHANGELOG-BUG-FIX: Fixed a memory blowup on auto-reload of very large files with unsaved-change preservation enabled.