Skip to content

Break loops between non-consuming sets - #706

Open
tontinton wants to merge 1 commit into
trishume:masterfrom
tontinton:fix-non-consuming-set-loop
Open

tontinton wants to merge 1 commit into
trishume:masterfrom
tontinton:fix-non-consuming-set-loop

Conversation

@tontinton

@tontinton tontinton commented Sep 12, 2026 •

Copy link
Copy Markdown

DISCLAIMER: fully AI generated, some nice bug I found, told AI to fix quickly.

The loop guard in parse_next_token only watches a non-consuming push followed by a non-consuming pop, and the comment next to it admitted that a non-consuming set can loop the same way but left it alone.

A set is neither the push that arms the guard nor the pop that trips it, and it leaves the stack depth alone, so two contexts that set each other at the same byte spin forever. The stack stays flat and nothing is allocated, so it looks like a hang rather than a runaway.

Repro

Older JavaScript packages (the ones bat ships, via two-face) hit this on a line as small as x /*:

maki_highlight::highlight_code("js", "x /*", "")  // never returns

expression-statement-continuation matches empty because the / of /* is in its operator class, and the "rest of the line is blanks and comments then EOL" rule also matches empty, and the two hand the position back to each other. Current Sublime packages replaced that set pair with a branch (see sublimehq/sublime_text#5853), which is why nothing in testdata catches it today. Anything loading older or third party syntaxes still hits it. Found in the wild by a terminal app that pinned a core forever when it restored a session containing that line.

Fix

Remember the (byte, context stack) a non-consuming set was taken from and, on a second visit to the same state, advance one character, the same recovery the push/pop guard already uses.

The stack has to be part of the key. include: shares Context objects, so the same rule legitimately fires at one byte from many states; keying on the rule alone looked simpler but cost several hundred syntest assertions across JavaScript, D and Python.

advance_one_char is pulled out as a helper so both guards use the same byte-safe step instead of duplicating the char_indices().nth(1) dance.

Testing

  • New can_parse_non_consuming_sets_that_would_loop, which hangs without the fix.
  • cargo test --lib parsing::parser: 87 passed, existing loop tests stay green.
  • make syntest and make syntest-fancy: no new failures, both baselines byte for byte unchanged.
  • No new clippy warnings, cargo fmt --check clean.

The loop guard only watches a non-consuming push followed by a non-consuming pop, and the comment next to it admitted that a non-consuming `set` can loop the same way but left it alone.

A `set` is neither the push that arms the guard nor the pop that trips it, and it leaves the stack depth alone, so two contexts that `set` each other at the same byte spin in `parse_next_token` forever, with a flat stack and no allocation to show for it.

Older JavaScript packages hit this on a line like `x /*`, where `expression-statement-continuation` and the "rest of the line is blanks and comments" rule both match empty and hand the position back to each other; current Sublime packages dodge it with a `branch` instead, which is why nothing in `testdata` ever caught it.

The fix remembers the `(byte, context stack)` a non-consuming `set` was taken from and, on a second visit to the same state, advances one character like the push/pop guard does. The stack has to be in the key because `include:` shares contexts, so the same rule legitimately fires at one byte from many states, and keying on the rule alone costs hundreds of syntest assertions.

Both syntest baselines are unchanged.
tontinton added a commit to tontinton/maki that referenced this pull request Sep 12, 2026
Opening a session sent a core to 100% and kept it there until maki was
killed, and nothing in the transcript looked unusual.

The thread named `highlight` was stuck inside a single
`ParseState::parse_line` call on a 20 line JavaScript snippet, and the
minimal case turned out to be `x /*`: two rules in the JavaScript syntax
`bat` ships both match empty at the byte after the identifier and `set`
each other's context back, forever, because syntect's loop guard only
watches a push followed by a pop, and a `set` is neither.

Flat memory the whole time, so it is a real cycle rather than
backtracking, and the queue in `maki-highlight/src/pool.rs` is a single
thread with no deadline, so that one line also starved every highlight
job that came after it.

Pins syntect to a `v5.3.0` backport of trishume/syntect#706, which
remembers the `(byte, context stack)` a non-consuming `set` was taken
from and advances a character on the second visit, exactly what the
push/pop guard already does; both syntest baselines are unchanged
upstream.
tontinton added a commit to tontinton/maki that referenced this pull request Sep 12, 2026
Opening a session sent a core to 100% and kept it there until maki was
killed, and nothing in the transcript looked unusual.

The thread named `highlight` was stuck inside a single
`ParseState::parse_line` call on a 20 line JavaScript snippet, and the
minimal case turned out to be `x /*`: two rules in the JavaScript syntax
`bat` ships both match empty at the byte after the identifier and `set`
each other's context back, forever, because syntect's loop guard only
watches a push followed by a pop, and a `set` is neither.

Flat memory the whole time, so it is a real cycle rather than
backtracking, and the queue in `maki-highlight/src/pool.rs` is a single
thread with no deadline, so that one line also starved every highlight
job that came after it.

Pins syntect to a `v5.3.0` backport of trishume/syntect#706, which
remembers the `(byte, context stack)` a non-consuming `set` was taken
from and advances a character on the second visit, exactly what the
push/pop guard already does; both syntest baselines are unchanged
upstream.
tontinton added a commit to tontinton/maki that referenced this pull request Sep 12, 2026
Opening a session sent a core to 100% and kept it there until maki was
killed, and nothing in the transcript looked unusual.

The thread named `highlight` was stuck inside a single
`ParseState::parse_line` call on a 20 line JavaScript snippet, and the
minimal case turned out to be `x /*`: two rules in the JavaScript syntax
`bat` ships both match empty at the byte after the identifier and `set`
each other's context back, forever, because syntect's loop guard only
watches a push followed by a pop, and a `set` is neither.

Flat memory the whole time, so it is a real cycle rather than
backtracking, and the queue in `maki-highlight/src/pool.rs` is a single
thread with no deadline, so that one line also starved every highlight
job that came after it.

Pins syntect to a `v5.3.0` backport of trishume/syntect#706, which
remembers the `(byte, context stack)` a non-consuming `set` was taken
from and advances a character on the second visit, exactly what the
push/pop guard already does; both syntest baselines are unchanged
upstream.
tontinton added a commit to tontinton/maki that referenced this pull request Sep 12, 2026
Opening a session sent a core to 100% and kept it there until maki was
killed, and nothing in the transcript looked unusual.

The thread named `highlight` was stuck inside a single
`ParseState::parse_line` call on a 20 line JavaScript snippet, and the
minimal case turned out to be `x /*`: two rules in the JavaScript syntax
`bat` ships both match empty at the byte after the identifier and `set`
each other's context back, forever, because syntect's loop guard only
watches a push followed by a pop, and a `set` is neither.

Flat memory the whole time, so it is a real cycle rather than
backtracking, and the queue in `maki-highlight/src/pool.rs` is a single
thread with no deadline, so that one line also starved every highlight
job that came after it.

Pins syntect to a `v5.3.0` backport of trishume/syntect#706, which
remembers the `(byte, context stack)` a non-consuming `set` was taken
from and advances a character on the second visit, exactly what the
push/pop guard already does; both syntest baselines are unchanged
upstream.
@oetiker

oetiker commented Sep 22, 2026

Copy link
Copy Markdown

Independent reproducer, and a data point on the engine question.

mdmost, a Markdown pager, hits this with two lines in a fence tagged js:

  | { type: "a" }
  /** x */

ParseState::parse_line on the second line does not return: 180 s of CPU, resident memory flat at 2 MB, no progress.

Versions: syntect 5.3.0 with default-fancy, two-face 0.5.2+bat-0.26.1.

The same two lines also hang in a separate standalone build linked with syntect-onig, selecting the JavaScript syntax by name. Two independent regex engines fail to finish on the same input, which is consistent with your diagnosis that the loop is in how the rules hand the position back to each other rather than in regex matching.

Same shape as your x /* — an expression continuation followed by /*.

I have not reviewed the patch itself.

@oetiker

oetiker commented Sep 24, 2026

Copy link
Copy Markdown

mdmost now carries this patch in a vendored copy of syntect 5.3.0, with two changes to the key. You may want them here:

  1. Hash the stack instead of cloning it. The key is (usize, Vec<ContextId>), so every non-consuming set allocates a new Vec of the stack's ContextIds. We hash the stack into a u64 and key on (usize, u64). This removes the allocation. A collision makes the guard treat a new state as a repeat and skip that set; with a 64-bit hash over one line's states this is negligible, and it cannot hang. In our measurements the time difference was smaller than the run-to-run noise, so the reason is to avoid the allocation, not speed.

  2. Include prototypes in the key. The key is built from level.context only. Two stacks that have the same contexts but different with_prototypes (StateLevel::prototypes) are different parser states, but they get the same key. The second one is then treated as a repeat, and its set is skipped. We hash prototypes together with context. We leave captures out, because Region is not Hash. Two states that differ only in captures then also look like a repeat, and the guard skips that set. The parser still advances, so it cannot hang.

If you want either change, I can send a patch against this branch.

@tontinton

Copy link
Copy Markdown
Author

bump

@tontinton

Copy link
Copy Markdown
Author

bump

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.

2 participants