Skip to content

Bound tree-sitter parse memory with a wall-clock budget (APP-4667) - #15150

Draft
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/bound-tree-sitter-parse-memory
Draft

Bound tree-sitter parse memory with a wall-clock budget (APP-4667)#15150
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/bound-tree-sitter-parse-memory

Conversation

@warp-agent-staging

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

Copy link
Copy Markdown
Contributor

Description

Fixes APP-4667: tree-sitter's error-recovery pass (ts_parser__recover) can allocate memory super-linearly on dense-error inputs regardless of file size. Field reports (Sentry) show a buffer well under the existing MAX_PARSE_BYTES cap (2 MiB) driving ~9.29 GB of tree-sitter parsing/error-recovery memory — the size cap alone is provably insufficient.

What's new: a PARSE_BUDGET (750ms wall-clock) enforced via tree-sitter's parse_with_options progress callback. When a parse exceeds the budget, it's cancelled and the buffer falls back to the existing "no syntax tree" path (same as exceeding MAX_PARSE_BYTES today).

What a user loses when it trips: syntax highlighting for that specific buffer — the editor still renders and functions normally, just without coloring. Once a buffer trips the budget, further tree-sitter parsing for it is skipped entirely (latched) until its language is reset (e.g. the file is reopened), rather than re-attempting a full parse on each edit.

MAX_PARSE_BYTES (2 MiB) is kept as a cheap first line of defense — it's a size comparison, not a parse attempt, so it can never itself run away — but it no longer carries the whole guarantee; PARSE_BUDGET is the actual bound on the parse itself.

Design notes

  • The progress callback's polarity is true = cancel (easy to get backwards; see tree-sitter#4312 — an older draft attempt at this had it inverted).
  • Tree-sitter polls the callback roughly every 100 parse actions (OP_COUNT_PER_PARSER_TIMEOUT_CHECK in its own parser.c), not on a fixed time or byte cadence. That bounds a runaway parse's overshoot past the deadline to "however long one more ~100-action batch takes" rather than something that scales with N. In local benchmarking that overshoot was within roughly 2x the budget even for inputs at the MAX_PARSE_BYTES cap, but because per-action cost itself grows under the pathological workload this targets, it isn't a hard guarantee — it's a large practical improvement over unbounded growth, not a mathematically tight bound. (Flagged as an open question for the requester below.)
  • Real cancellation, not just abort(). ModelContext::spawn's AbortHandle/Abortable only observes cancellation when the wrapped future is polled at an .await point — the blocking tree-sitter call has none until it returns, so calling abort() on a superseded task does not stop the in-flight parse from continuing to allocate. Fixed by threading an Arc<AtomicBool> cancellation flag into the parse's own progress callback (checked alongside the deadline), and serializing: only one parse is ever in flight per buffer — a new edit while a parse is running signals it to cancel and coalesces itself into a pending_edit, dispatched once that parse's completion is observed. This closes what was otherwise a per-parse (not per-buffer) bound: rapid edits on a pathological buffer could previously pile up multiple full-budget parses on the background executor.
  • Outcome classification. parse_with_options can return None for reasons other than our own cancellation (e.g. a scanner error). A CancelReason (set only when our callback decides to cancel, and why) distinguishes a confirmed deadline trip (BudgetExceeded, which latches) from a coalescing cancellation (Superseded, silently discarded — not a pathological buffer, just normal fast typing) from an unrelated parser failure (Failed, falls back the same way but does not latch or log a false timeout). Classification lives in a small pure function (classify_parse_result) for direct unit testing.
  • Stale-completion guard. A monotonic generation counter ensures a completion is only applied if it still matches the state's current generation, so a delayed/stale result can never mislatch or discard a newer buffer version.
  • 750ms was picked empirically: legitimate full parses of files up to the 2 MiB cap should complete well under this in normal use, while worst-case pathological bailout stays in the low seconds instead of unbounded. See Testing for the numbers behind this.
  • Reviewed the 5 stale draft PRs referenced in the issue (fix: skip tree-sitter parsing for files exceeding 1 MiB to prevent OOM (APP-4667) #12245, fix: skip tree-sitter parsing for files larger than 1 MB to prevent memory exhaustion #11641, fix: add timeout to tree-sitter parsing to prevent unbounded memory growth (APP-4323) #11640, fix: skip tree-sitter parsing for buffers over 10 MB to prevent memory spikes #12059, fix: add 10 MB size guard for tree-sitter parsing to prevent memory explosion #11997) for prior art. fix: add timeout to tree-sitter parsing to prevent unbounded memory growth (APP-4323) #11640 attempted a similar progress-callback timeout but had the cancel/continue polarity inverted (would have cancelled on the very first check), which is part of why a fresh implementation was warranted.

Out of scope

  • The secondary GlobalBufferModel::resolve_conflict -> Buffer::replace_all -> populate_buffer_with_read_content memory site (app/src/code/global_buffer_model.rs, ~9.72 GB in the same profile) is a different mechanism — replacing a buffer's content re-materializes it into a new SumTree<BufferText> while the old tree and an intermediate formatted-text representation are still live. This looks like the expected (if non-trivial, ~2-4x) cost of a full-buffer replace rather than an unbounded/pathological blowup, but I did not fully trace apply_core_edit_actions/convert_text_with_style_to_formatted_text's peak-memory behavior or have the actual file size from the Sentry incident, so I'm not fully confident. Flagging as a candidate follow-up rather than folding a speculative fix into this PR.

Open questions for the requester (not resolved in this PR)

  • Whether a 750ms wall-clock deadline is an acceptable bound at all, given it meters latency while the underlying bug is memory, and the progress callback only fires every ~100 parse operations (see inline PR comment).
  • The latch's reset semantics: set_color_map calls set_language, so a theme change silently re-arms a previously-latched pathological buffer, while fixing the buffer's content does not restore highlighting on its own (see inline PR comment).

Linked Issue

  • Tracked via Linear APP-4667 (no corresponding GitHub issue to label).
  • No UI changes to the settings/chrome of the app; this is a fallback behavior change in the code editor's syntax highlighting.

Testing

  • crates/syntax_tree/src/lib_tests.rs (13 tests total, all deterministic, no wall-clock-dependent assertions):

    • test_classify_parse_result_distinguishes_none_reasons: pure-function coverage of the deadline vs. superseded vs. failed classification.
    • test_parse_exceeding_deadline_falls_back_instead_of_completing: a dense-error SQL input under MAX_PARSE_BYTES, with an already-elapsed deadline, returns BudgetExceeded instead of completing.
    • test_parse_returns_superseded_when_cancel_flag_is_set: a pre-armed cancellation flag (deadline far in the future) returns Superseded, not BudgetExceeded.
    • test_parse_skips_oversized_buffer_without_attempting_to_parse: buffers over MAX_PARSE_BYTES still short-circuit on the cheap size check.
    • test_budget_exceeded_completion_latches_discards_tree_and_stops_reparsing: drives a real BudgetExceeded completion through update_internal_state_with_delta (via a test-only overridable parse budget) and asserts on the actual production side effects — latch set, tree removed from the map, no parse left in flight, DecorationUpdated emitted (observed via a real model-event subscription), and a further edit does not re-dispatch a parse.
    • test_rapid_edits_coalesce_instead_of_running_concurrent_parses: two edits arriving before the first parse's completion is observed result in the cancel flag being signaled and the second edit being coalesced into pending_edit, rather than a second concurrent parse starting.
  • cargo test -p syntax_tree — 13/13 pass in 0.42s.

  • cargo clippy -p syntax_tree --all-targets --all-features --tests -- -D warnings — clean.

  • ./script/format — no changes needed.

  • cargo check -p ai and cargo check -p warp --bin warp (downstream consumers of syntax_tree) — both compile cleanly; no public API of SyntaxTreeState changed.

  • Standalone benchmark (pinned to the exact arborium/arborium-tree-sitter 2.13.0 used by this repo's Cargo.lock, not committed) against dense-error SQL, unbounded:

    • ~1.8 MB input (under the 2 MiB cap): ~1.24s, ~371 MB peak RSS
    • ~2.0 MB input (at the cap): ~1.3s, ~402 MB peak RSS
    • ~8.6 MB input (over the cap, for trend only): ~3.05s, ~873 MB peak RSS

    I could not reproduce the full ~9.29 GB from the field report locally (my synthetic pattern tops out in the hundreds of MB before hitting the byte cap), but this does reproduce the same class of bug: a buffer safely under the current size cap driving 100-400x its own size in memory via tree-sitter parsing.

  • Visual proof: captured on macOS (recording and screenshots above). macOS 26.3.1 arm64, 8 cores / 16 GB; debug build of this branch at b9570cd, bundled and launched as a real .app via the repo's documented ./script/run path, logged out.

    • Baseline: an 897-byte valid .sql file renders with full syntax highlighting — keywords, types/constraints, string and numeric literals, and function names each distinctly colored.
    • Fallback: a 2,093,040-byte dense-error .sql file (52,326 lines of unclosed nesting, ~575k unclosed parens by EOF) — 4,112 bytes under the 2 MiB MAX_PARSE_BYTES cap, so it takes the parse path rather than the size-skip path — renders as uniform white with no color differentiation anywhere, checked at the top and around lines 16k, 33k and 44k.
    • The buffer stays fully usable: content appeared within ~1s with no spinner, blank pane or "too large" message; scrollbar-drag navigation to lines 16254 / 31758 / 33003 / 44235 rendered instantly; typing SELECT 1; at line 33020 inserted inline immediately and the unsaved-changes indicator updated. No hang, beachball or crash at any point.
    • The fallback cause was confirmed from ~/Library/Logs/warp_local.log, not inferred from the rendering — exactly one occurrence of:
      [WARN] [syntax_tree] [SyntaxTreeState] tree-sitter parse exceeded 750ms budget; disabling syntax highlighting for this buffer until its language is reset
    • Two caveats from the session, stated as observed: synthetic mouse-wheel scroll events had no effect on the viewport (scrollbar drag was used instead; undetermined whether that is an automation artifact or real editor behavior), and there is no in-UI notice that highlighting was dropped — the only status text present, "Language support is unavailable for this file type", also showed for the highlighted small file, so it is pre-existing and unrelated to this change.
  • Not verified: whether this fix eliminates the exact Sentry alert in production — that needs field observation post-deploy (see the warn! log added for this).

Computer-use video recordings

View video recording - Warp's built-in editor on macOS: a small valid SQL file renders with syntax highlighting, then a 2,093,040-byte dense-error SQL file (just under the 2 MiB cap) renders unhighlighted but stays fully scrollable and editable as the 750 ms parse budget trips.

Computer-use screenshots

Agent Mode

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

CHANGELOG-BUG-FIX: Fixed a memory spike where editing files with dense syntax errors could cause tree-sitter's error recovery to consume multiple gigabytes of memory; such buffers now fall back to no syntax highlighting instead.

Tree-sitter's error-recovery pass (ts_parser__recover) can allocate memory
super-linearly on dense-error inputs regardless of file size. The existing
MAX_PARSE_BYTES cap (2 MiB) does not prevent this: field reports show a
sub-cap buffer driving ~9.29 GB of tree-sitter parsing/error-recovery memory.

Add PARSE_BUDGET, a 750ms wall-clock deadline enforced via tree-sitter's
progress callback (parse_with_options), so a pathological parse bails out
to the existing "no syntax tree" fallback instead of running unbounded.
The callback is polled roughly every 100 parse actions by tree-sitter
itself, bounding a runaway parse's overshoot to a small, input-size-
independent amount of extra work rather than growing with N.

To avoid re-spending the budget on every keystroke once a buffer proves
pathological, latch parsing off for that buffer (SyntaxTreeState) after a
budget trip; the latch clears when the language is reset (e.g. reopening
the file). Log a single warn! breadcrumb per trip for field observability.

Keep MAX_PARSE_BYTES as a cheap, size-based first line of defense (a size
comparison can never itself run away), but it no longer carries the whole
guarantee.

Add regression tests: a dense-error SQL parse under MAX_PARSE_BYTES trips
PARSE_BUDGET instead of completing, an oversized buffer still short-
circuits on size alone, and a latched buffer skips re-parsing entirely on
the next edit.

CHANGELOG-BUG-FIX: Fixed a memory spike where editing files with dense
syntax errors could cause tree-sitter's error recovery to consume
multiple gigabytes of memory; such buffers now fall back to no syntax
highlighting instead.

Co-Authored-By: Warp <agent@warp.dev>
@cla-bot cla-bot Bot added the cla-signed label Aug 14, 2026
@warp-agent-staging warp-agent-staging Bot added area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. factory:wilson labels Aug 14, 2026
@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

View run View conversation

@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 tree-sitter parsing with a 750ms wall-clock deadline enforced through parse_with_options' progress callback, falling back to the existing no-syntax-tree path. Two design questions below need your decision; separately identified correctness defects are being revised and will follow as commits.

Verdict

Checks: build pass, tests pass (10/10 syntax_tree), CI green (5 passed, 15 skipped), visual proof missing

Found: 0 critical, 0 important, 0 suggestions, 0 nits, 2 questions — both awaiting your judgment, not a request for changes.

Responding as wilson: Open session · View factory task

/// input took ~700ms/~220MB unbounded; an ~8.5MB one took ~3s/~870MB). 750ms
/// comfortably covers legitimate full parses of files up to the size cap while
/// keeping the worst-case bailout in the low seconds instead of unbounded.
const PARSE_BUDGET: Duration = Duration::from_millis(750);

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.

question — a wall-clock deadline is not a memory bound, and the bug is a memory blowup.

In the pinned arborium-tree-sitter 2.13.0, the progress callback is only checked once the operation counter reaches 100 (src/parser.c), and a single operation can enter ts_parser__recover and do input-size-dependent stack/tree work before returning to that check. So the measured ~2x overshoot is not guaranteed to hold: a machine faster than the M1 Pro in the report allocates more within the same 750ms, and nothing here imposes an allocation ceiling.

Decision needed: is a latency proxy an acceptable bound for this, or do you want a real per-parse memory ceiling (allocator/resource limit, or a structural content guard) with this deadline kept only as a secondary latency limit?

Responding as wilson: Open session · View factory task

syntax_query: HighlightQuery::new(&language.highlight_query, self.color_map),
language,
});
self.parse_budget_exceeded = false;

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.

question — the latch reset fires on theme changes but not on content fixes.

Because set_color_map calls set_language, an unrelated appearance change silently re-arms tree-sitter on a buffer already known to be pathological, while correcting the offending content does not restore highlighting until the file is reopened. Combined with the deadline being load-sensitive, a transient 750ms miss (loaded machine, resume from sleep, debug build) can permanently degrade a benign buffer.

Decision needed: may a transient miss permanently disable highlighting, and what should reset it? If the intent is a language/new-buffer transition, the latch should survive color-map-only updates.

Responding as wilson: Open session · View factory task

…n the parse budget

Address review findings on the initial PARSE_BUDGET implementation:

1. Aborting the previous ctx.spawn task did not actually interrupt an
   in-flight blocking tree-sitter parse (Abortable only checks between
   .await points, and the blocking parse has none). Rapid edits could
   pile up multiple full-budget parses on the background executor,
   multiplying the exact memory exposure this feature exists to bound.
   Fixed by threading a shared cancellation flag into the parse's own
   progress callback, and serializing/coalescing: only one parse is ever
   in flight per buffer; a newer edit signals the current one to bail
   out and is dispatched once that completion is observed.

2. Every `None` from parse_with_options was treated as BudgetExceeded,
   even though tree-sitter can also return None for unrelated reasons
   (e.g. a scanner error). Fixed by tracking whether *our* callback
   actually triggered the cancellation (and why) via a new CancelReason,
   classified by a pure, unit-testable `classify_parse_result` function.
   Added a distinct `ParseOutcome::Failed` for this case, and a
   `Superseded` outcome for cancellations caused by coalescing (neither
   trips the latch or logs a false timeout).

3. Parse completions were applied with no generation check, so a stale
   result could in principle latch/discard a newer, unrelated buffer
   version. Added a generation counter; a completion is only applied if
   it still matches the state's current generation.

4. Rewrote the test suite to remove all wall-clock-dependent assertions.
   `SyntaxTreeState` now carries a `parse_budget: Duration` field
   (test-only overridable) and `parse_text` takes an explicit `deadline`
   and `cancel` flag, so tests can deterministically drive real
   BudgetExceeded/Superseded completions through the actual production
   code path and assert on the real side effects (latch set, tree
   discarded, DecorationUpdated emitted, no replacement parse scheduled)
   instead of asserting on elapsed time or poking private state.

Co-Authored-By: Warp <agent@warp.dev>

@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

Replaces the insufficient 2 MiB size cap as the sole guard with a 750 ms parse deadline enforced through tree-sitter's progress callback, falling back to the existing no-syntax-tree path. Four correctness defects found in review were fixed in b9570cd; the two design questions below need a human decision and are the reason this is not a straight approval.

Concerns

  • The fallback has no in-UI notice. Visual verification on macOS confirmed the buffer silently renders unhighlighted; the only status text present, "Language support is unavailable for this file type", also shows for files that are highlighted, so it is unrelated. A user whose highlighting vanishes has nothing to tell them why — decide whether that needs a surface before this ships.
  • Verification could not demonstrate mouse-wheel scrolling in the unhighlighted buffer; scrollbar dragging worked and was used instead. Whether that is an automation artifact or real editor behavior is undetermined and untested either way.

Verdict

Checks: build pass, tests pass (13/13 in syntax_tree, deterministic), CI green — though the Formatting+Clippy and test jobs report skipping on this draft rather than having actually run — visual proof present (macOS 26.3.1 arm64, recording and stills in the description)

Found: 0 critical, 0 important outstanding (4 important raised in review and fixed in b9570cd), 0 suggestions, 0 nits, 2 questions

Responding as wilson: Open session · View factory task

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