Fix O(2^n) regex backtracking in consolidateBlockDiffs that freezes the extension host - #75
Conversation
…he extension host The fullWrapRegex used a (BLOCK(?:\s*BLOCK)*) form whose block unit is ambiguous (a lazy [\s\S]*? that closes on any block tag). When an <ins>/<del> region wraps a long run of sibling block elements, the trailing </ins>/</del> usually fails to align, so the engine explores ~2^n partitions before giving up. That pegs one core and freezes the single-threaded extension host, taking down every extension in the window. Reproduces on a real 129 KB document after an edit that inserts ~20+ contiguous block elements; a synthetic 343-byte input already takes ~4 s. - Replace the nested-quantifier regex with a lazy (<(ins|del)\b[^>]*>)([\s\S]*?)(<\/\2>) match plus a linear isBlockLevelRun() validation in the callback. - Behavior preserved: genuine block runs still get the diff-block class; inline content is still left untouched. - Verified: isolated regex hang -> 1 ms; full computeDiff hang -> ~290 ms on the reproducing document. Fixes phine-apps#74 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Hi @wolframarnold, Thank you so much for another great PR and the thorough repro in #74! Eliminating the During review, I spotted one edge case with nested block elements (e.g. Tracking tag depth (similar to How would you like to proceed?
Let me know your preference. Thanks again for tackling this! |
Review follow-up for phine-apps#74/phine-apps#75. The previous isBlockLevelRun used a lazy blockElementPattern whose close group matched any block tag, so it stopped at the first inner </...> (e.g. </ul> inside <blockquote><ul>...</ul></blockquote>), left the outer close tag unmatched, and returned false -- dropping the diff-block class on nested block structures. - Rewrite isBlockLevelRun to consume each paired block via findClosing (depth tracking, mirroring the domUtils helper); still O(N), no backtracking. - Handle self-closing blocks (<hr>) explicitly; remove the now-unused blockElementPattern. - Add unit tests: nested block/list get diff-block; inline content is untouched; a 40-block run with a non-block tail stays linear (the former O(2^n) case). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @phine-apps — good catch, that's exactly right. Went with Option A: |
Fixes #74.
Problem
consolidateBlockDiffs()insrc/markdown/structuralDiff.tsbuilds a regex with a(BLOCK(?:\s*BLOCK)*)shape that backtracks exponentially (O(2ⁿ) in the numberof block elements) when an
<ins>/<del>region wraps a long run of siblingblock-level elements. On a real document edit this pegs one CPU core at 100% and
never returns, freezing the single-threaded VS Code extension host — and with it
every extension in that window. Full analysis, repro, and measurements are in #74.
Root cause
blockElementPatternis ambiguous — a lazy[\s\S]*?that closes on any blocktag — so a run of n blocks has ~2ⁿ partitions. Repeating it with
(?:\s*BLOCK)*and then requiring a trailing</\2>that usually doesn't alignmakes the engine try them all. A synthetic 343-byte input already takes ~4 s
(runtime doubles per added block); ~40 blocks is effectively forever.
Fix
Match the region with a lazy inner (linear), then validate that the content is a
block run with a linear left-to-right scan (
isBlockLevelRun). This removes thenested repetition and the exponential blow-up while preserving behavior.
Verification
computeDiff()on the reproducing 129 KB document: hang → ~290 ms, validdiff HTML produced.
<ins>wrapping a<table>+<h2>run is still markeddiff-block; an<ins>around inline text is still left untouched.Notes for review
</ins>/</del>. This runs afterbalanceDiffTags, and the original\2backreference had the same first-closebehavior, so pairing should be equivalent — worth a test if diff tags can nest.
test harness weren't installed). Please run
npm test; a regression fixtureasserting
consolidateBlockDiffscompletes for, say, N=40 blocks would lock thisin.
(?>…)/*+) instead —V8 rejects both even on Node 24 (throws at construction), so that path would break
the extension. The lazy-plus-validate form is portable.
🤖 Generated with Claude Code