Skip to content

editor: skip code-block re-color rebuild when colors are unchanged (APP-5458) - #15212

Open
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/app-5458-code-block-color-no-op
Open

editor: skip code-block re-color rebuild when colors are unchanged (APP-5458)#15212
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/app-5458-code-block-color-no-op

Conversation

@warp-agent-staging

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

Copy link
Copy Markdown
Contributor

Description

Fixes the memory issue in APP-5458: notebook code-block syntax highlighting rebuilt the block's entire content into a brand-new SumTree on every re-style pass, even when the requested colors exactly matched what was already applied.

NotebookCommand::try_apply_cached_highlighting reapplies the same cached colors every time the debounced highlight pass fires while a block's text is unchanged — this is a common, hot path. Each such call went through Buffer::color_code_block_ranges_internal, which walked every character in the code block and rebuilt it via append_str/push calls into a new SumTree, allocating Arc<Node> tree nodes proportional to the block's size. For large pasted code or command output, this produced multi-GB memory spikes (9.75 GB in the reported Sentry event, 73% of it in SumTree::push).

Scope of this fix: this addresses the identical-recolor no-op case, which is the dominant real-world trigger for repeated rebuilds (every debounced highlight tick once a block's highlighting has stabilized). A genuine re-color with new colors (e.g. the very first highlight pass on a large pasted block) still rebuilds the whole styled block — that path is optimized for allocation count, not eliminated, as described below. This is a mitigation of the profiled scenario, not a proven elimination of the exact 9.75 GB case; I did not have a way to reproduce or re-profile that specific event to confirm the spike is gone.

Before / after

  • Before: every call to color_code_block_ranges_internal — including ones where the requested colors were byte-for-byte identical to what was already applied — walked the whole block character by character, calling append_str(&c.to_string()) once per character (a heap allocation per char) and push() once per color marker transition, producing a brand-new SumTree subtree sized proportional to the block's character count.
  • After:
    • A new read-only check, Buffer::code_block_colors_match, compares the block's existing color markers against the requested colors before doing anything else. If they match exactly (including the synthetic marker that closes a color inherited from before the block), the function returns immediately without touching self.content — no allocation at all. This is the fast path try_apply_cached_highlighting hits on every debounce tick once a block's highlighting has stabilized.
    • When colors genuinely differ and a rebuild is required, runs of unchanged text between color-marker transitions are batched into a String buffer and flushed with one append_str call, instead of one append_str(&c.to_string()) call (and heap allocation) per character. That buffer is bounded: it's also flushed once it reaches TEXT_FRAGMENT_SIZE (the same chunk size append_str itself splits text into internally), so a long run of unstyled text between transitions can't stage a String proportional to the whole block before flushing it — otherwise a block with few or no color transitions would still allocate one large contiguous staging copy, reintroducing an O(block-size) allocation on exactly the large-input case this ticket is about. This reduces the number of SumTree writes from O(chars) to O(color transitions + block-size / TEXT_FRAGMENT_SIZE).

Both changes preserve the original loop's exact marker semantics (the started_colored/is_first_item handling for an inherited color, the style_end - 1 break condition, and the trailing unclosed-range guard).

Safety of the no-op path

The no-op path only returns a None delta — the same convention already used elsewhere in this file (e.g. the ToggleTaskListAtOffset fallback and apply_core_edit_actions's empty-action case) for edits that are true no-ops. I verified update_content_with_autoscroll's handling of a None delta: it skips the undo push, the content-version bump, and all BufferEvent emissions — appropriate here since nothing in the buffer actually changes. NotebookCommand::on_buffer_content_updated additionally filters ContentChanged events to origin != EditOrigin::SystemEdit, and color_code_block_ranges_internal is always called with EditOrigin::SystemEdit, so that specific listener already ignores these events regardless of whether a delta is emitted.

The comparison is conservative: any item other than Text, Newline, or Color markers within the range (e.g. a link or inline-style marker) makes it bail out and do a real rebuild, since a real rebuild would silently drop such items. It's likewise conservative for malformed/unusual colors inputs — zero-width ranges, overlapping ranges, out-of-order ranges, ranges outside the block, etc. all fail to match the expected marker sequence and fall through to the existing rebuild path rather than risking a false match.

Linked Issue

Linear: APP-5458

  • Filed by the Sentry memory triage bot; addresses only the color_code_block_ranges_internal root cause (not the other facets of Sentry issue 7259255054 tracked separately in APP-5353/APP-5356/APP-4844).

Testing

This is a headless, non-visual change to crates/editor's buffer content model, verified with unit tests (no GUI verification possible or expected here).

  • cargo nextest run -p warp_editor — all 489 tests pass, including 5 new tests added for this change:
    • test_color_code_block_no_op_on_identical_colors — re-applies identical colors and asserts the returned delta is None (i.e. the no-op fast path is actually taken), then verifies clearing colors is a real, once-applied-then-idempotent change.
    • test_color_code_block_multibyte_utf8 — colors a block containing a 2-byte UTF-8 character, verifying byte/char offset handling is unaffected by the batched-text-write change, and that re-applying is a no-op.
    • test_color_code_block_adjacent_to_block_item — colors a code block immediately preceded by a <hr> block-item marker, verifying the no-op path doesn't disturb it.
    • test_color_code_block_started_colored_no_op — a code block that inherits an open color from before its own start (hand-constructed content), verifying the no-op detection correctly reasons about the synthetic closing marker.
    • test_color_code_block_large_run_without_color_transitions — colors a ~500-character block with a single color range spanning the whole block (no internal marker transitions), exercising several TEXT_FRAGMENT_SIZE-bounded flushes of the staging buffer and confirming correctness for both the initial color and a no-op re-application.
  • Existing tests, including test_color_code_block, test_remove_coloring_in_middle_of_block (which exercises re-styling from a non-block-aligned offset with colors=[] — the edge case that initially broke my first draft of the no-op check), test_edit_colored_code_block, and test_unstyling_code_block_do_not_leak_syntax_color, all still pass unchanged.
  • Ran the broader notebooks::editor test suite in the app crate (cargo test -p warp --lib notebooks::editor::, 84 tests) to cover NotebookCommand's syntax-highlighting/undo/redo/cache paths that call into this code — all pass locally, including test_syntax_highlighting_in_command and test_delete_block_after_highlighted_block (which specifically exercises the cached-highlighting reapplication path). Note: this run has not been independently reproduced in every environment — the app crate is large and a full-features compile can be killed by a sandbox's memory limit rather than failing on its own merits, so treat this as a strong signal from one full local run rather than a guaranteed-reproducible CI result.
  • ./script/format --check and cargo clippy -p warp_editor --all-targets --tests -- -D warnings both pass clean.

I did not measure an actual memory reduction, and I did not reproduce or re-profile the specific 9.75 GB Sentry event (no profiling tooling or the original repro available in this sandbox). The improvement is argued from complexity — no-op case: O(n) reads with zero allocation, replacing O(n) allocations; genuine-rebuild case: O(color transitions + block-size / TEXT_FRAGMENT_SIZE) bounded writes instead of O(chars) unbounded ones — and confirmed correct via the tests above. This is a mitigation of the profiled repeated-rebuild scenario, not a proven elimination of the exact reported spike.

  • I have manually tested my changes locally with ./script/run

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-BUG-FIX: Fixed a memory issue where re-highlighting a notebook code block could repeatedly rebuild its entire contents even when nothing changed, causing large memory spikes for big pasted code or command output.

Buffer::color_code_block_ranges_internal previously rebuilt a code
block's entire content into a brand-new SumTree on every re-style
pass, even when the requested colors exactly matched what was
already applied. This is the common case for
NotebookCommand::try_apply_cached_highlighting, which reapplies the
same cached colors every time the debounced highlight pass fires
while a block's text is unchanged. Each rebuild allocates Arc<Node>
SumTree nodes proportional to the block's size, which shows up as a
multi-GB memory spike for large pasted code or command output.

- Add code_block_colors_match, a read-only check that compares the
  block's existing color markers against the requested colors and
  returns true only when they match exactly (including the
  synthetic marker that closes a color inherited from before the
  block, and the special case of colors=[] on non-block-aligned
  offsets). When it matches, color_code_block_ranges_internal
  returns immediately without touching self.content, matching the
  existing no-op-delta convention used elsewhere in this file.
- When colors do differ and a rebuild is required, batch runs of
  unchanged text into a single append_str call (via a small String
  buffer) instead of one append_str(&c.to_string()) call per
  character, eliminating a per-char heap allocation and reducing the
  number of SumTree writes from O(chars) to O(color transitions).

Both changes preserve the existing loop's marker semantics exactly
(the started_colored/is_first_item handling, the style_end - 1 break
condition, and the trailing unclosed-range guard are all unchanged).

Extended the existing buffer coloring tests with cases for
re-styling with identical colors (asserting the no-op path is used),
multi-byte UTF-8 content, a code block adjacent to a block-item
marker, and a block that inherits an open color from before its own
start.

Co-Authored-By: Warp <agent@warp.dev>
@cla-bot cla-bot Bot added the cla-signed label Aug 16, 2026
@warp-agent-staging warp-agent-staging Bot added area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. factory:wilson labels Aug 16, 2026
pending_text in color_code_block_ranges_internal was unbounded: on a
genuine re-color with few or no color transitions, it accumulated a
whole run of unchanged text into one contiguous String before
flushing, staging an allocation proportional to the entire block --
reintroducing an O(block-size) allocation on exactly the large-input
case this fix targets.

Flush pending_text once it reaches TEXT_FRAGMENT_SIZE bytes (the same
chunk size append_str itself splits text into), in addition to the
existing flushes at color-marker and non-text-item boundaries. This
keeps the batched SumTree writes without ever staging more than a
small, fixed amount of text at once.

Added test_color_code_block_large_run_without_color_transitions,
which colors a ~500-character block with a single color range (no
internal marker transitions), exercising several buffer flushes and
confirming the resulting content is still correct, both for the
initial color and for a no-op re-application.

Co-Authored-By: Warp <agent@warp.dev>
@warp-agent-staging
warp-agent-staging Bot marked this pull request as ready for review August 16, 2026 22:16
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