Skip to content

Fix catastrophic backtracking in implicit link detection - #613

Open
jizhi0v0 wants to merge 2 commits into
migueldeicaza:mainfrom
jizhi0v0:fix/implicit-link-mouseup-perf
Open

Fix catastrophic backtracking in implicit link detection#613
jizhi0v0 wants to merge 2 commits into
migueldeicaza:mainfrom
jizhi0v0:fix/implicit-link-mouseup-perf

Conversation

@jizhi0v0

Copy link
Copy Markdown

The implicit link pattern can backtrack exponentially, blocking the main thread for seconds to minutes on ordinary terminal output. I hit this in a SwiftUI terminal app: releasing a drag-selection froze the whole UI, and sample showed 2933 of 2933 main-thread frames in

TerminalView.mouseUp -> linkForClick -> Terminal.implicitLinkMatch
  -> NSRegularExpression.matches -> icu::RegexMatcher::MatchAt

The two bombs

1. ipv6URLPattern\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\]

The inner (?:X*)+ is pure redundancy (it accepts exactly the language of X+) but adds an exponential when a scheme is followed by an unterminated bracket. Both of these are shapes real output produces:

input time
ssh://[2001:0db8:85a3:0000:0000 (truncated address, 31 chars) 1.3 s
https://[2001:db8::1%25eth0]/x (valid RFC 6874 zone-ID URL) never terminates

The RFC 6874 case never terminates because %25 is not in the hex class, so it blocks the closing ].

2. Scheme body — (?:IPV6|CHARS+SUFFIX?)+

A + directly inside a +: a run of N body characters can be split across iterations in exponentially many ways. ICU normally escapes early, because the trailing (?<![,.]) rejects only the one end position whose preceding character is . or , — backing off by one succeeds immediately. But when the match ends in a run of ./,, every end position fails and the full enumeration is forced. That shape is common: a URL followed by an ellipsis, dot leaders, or empty CSV fields.

input length time
https://example.com/a/b/c + 16 dots 41 547 ms
https://example.com/a/b/c + 18 dots 43 3,689 ms
mailto:a@b.com + 19 dots 33 9,698 ms
https://example.com/a/b/c + 20 dots 45 25,871 ms

~2.6× per additional character. Note how short these are — a length cap is not a viable mitigation, and NSRegularExpression exposes no time or step limit (ICU's uregex_setTimeLimit is not surfaced by Foundation, and enumerateMatches' stop flag only fires between matches, so it cannot interrupt backtracking inside one).

The fix

Consume one token per iteration, so there is exactly one way to match a given run. Two details keep the grammar identical to CHARS+SUFFIX?:

  • the first token can never be a bracketed suffix
  • a suffix is admitted only directly after a CHARS character (the lookbehind)

Without that lookbehind a suffix could also follow an IPv6 literal or another suffix, which the original cannot produce — differential testing found exactly those cases and nothing else, which is what motivated adding it.

All inputs above now complete in under 0.05 ms.

Verification

  • Differential testing: old vs new pattern over 300,000 random terminal-like strings (varied alphabets and scheme prefixes) plus hand-picked edge cases — IPv6 with port and path, trailing-punctuation trimming, parenthesised suffixes, unmatched brackets, every path branch. Zero differences in the matches produced.
  • Test suite: 376 tests / 33 suites pass (BENCHMARK_DISABLE_JEMALLOC=true swift test; the Benchmarks target otherwise needs jemalloc installed).

Second commit

linkForClick always requested .explicitAndImplicit and only afterwards consulted linkVisibleForClick, which discards implicit matches outright in several modes. On macOS the default is .hoverWithModifier, so every plain click and every drag-selection release ran the implicit regex over the whole wrapped line group and then threw the result away. This decides up front whether an implicit match could be accepted, mirroring the early-out updateHoverLink already performs on the hover path. The gate is a necessary condition and never narrower than linkVisibleForClick; explicit hyperlinks are unaffected.

Not included

updateHoverLink and reportLink still run implicit detection on every mouseMoved while a modifier is held. With the pattern fixed that is no longer a hang risk, just steady avoidable work — it seemed better suited to a separate change than folded in here.

jizhi0v0 added 2 commits July 29, 2026 14:19
The implicit link pattern contains two nested quantifiers that make
NSRegularExpression (ICU) backtrack exponentially, blocking whichever
thread runs the match. Both are reachable from ordinary terminal output
via linkForClick / updateHoverLink / reportLink, i.e. from mouseUp,
mouseMoved and flagsChanged.

1. The IPv6 literal was `\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\]`. The inner
   `(?:X*)+` is pure redundancy -- it accepts exactly the language of
   `X+` -- but it explodes on a scheme followed by an unterminated
   bracket, which real URLs produce: a truncated address such as
   `ssh://[2001:0db8:85a3:0000:0000` takes 1.3s, and the RFC 6874 form
   `https://[2001:db8::1%25eth0]/x` never terminates, because the `%25`
   zone-ID escape is not in the hex class and so blocks the closing `]`.

2. The scheme body was `(?:IPV6|CHARS+SUFFIX?)+`, a `+` directly inside a
   `+`, so a run of N body characters could be split across iterations in
   exponentially many ways. ICU normally escapes early because the
   trailing `(?<![,.])` rejects only one end position, but when the match
   ends in a *run* of `.` or `,` -- a URL followed by an ellipsis, dot
   leaders, or empty CSV fields -- every end position fails and the full
   enumeration is forced. Measured on the old pattern:

       https://example.com/a/b/c + 16 dots (41 chars) ->    547 ms
       https://example.com/a/b/c + 18 dots (43 chars) ->  3,689 ms
       mailto:a@b.com            + 19 dots (33 chars) ->  9,698 ms

   Each additional character multiplies the time by ~2.6.

Consuming one token per iteration removes the ambiguity: there is now
exactly one way to match a given run. Two details preserve the original
grammar -- the first token can never be a bracketed suffix, and a suffix
is admitted only directly after a CHARS character (the lookbehind);
without it a suffix could also follow an IPv6 literal or another suffix,
which `CHARS+SUFFIX?` cannot produce.

All the inputs above now complete in under 0.05 ms. Differential testing
of the old and new patterns over 300,000 random terminal-like strings
plus hand-picked edge cases (IPv6 with port and path, trailing-punctuation
trimming, parenthesised suffixes, every path branch) reports zero
difference in the matches produced.
linkForClick always asked for `.explicitAndImplicit` and only afterwards
consulted linkVisibleForClick, which -- depending on linkHighlightMode --
discards implicit matches unconditionally. On macOS the default is
`.hoverWithModifier`, so every plain click and every drag-selection
release ran the implicit regex over the whole wrapped line group and then
threw the result away.

Decide up front whether an implicit match could be accepted, and ask for
`.explicitOnly` when it could not. This mirrors the early-out that
updateHoverLink already performs on the hover path.

The gate is a necessary condition, never narrower than
linkVisibleForClick: `.always` and `.alwaysWithModifier` both return
`match.isExplicit`, so an implicit match can never qualify there; the
hover modes additionally require linkHighlightRange to be non-nil, since
comparing it against a non-optional array of row ranges is false when it
is nil. Explicit hyperlinks are unaffected -- they are still resolved in
every mode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant