Repository navigation
Description
A 20-byte C++ input makes Parser.Parse loop without end on the plain (DFA lexer) route. The input is ;e s d>f(T);template. With no parser timeout, the process was still looping after 60 s, at 100,000,000 turns of the loop, and the state sampled at every power of ten was the same. I expect it never returns; I observed 60 s. Locked C parses the same 20 bytes in 0.025 ms. The registered token source returns a tree for it in under 3 ms.
The 1 KB C++ input from a review of the action-cell transaction work (a mutation of upstream corpus case concepts_002.cpp) is the same loop. Its parse stops at iteration 146 whether the timeout is 1 s or 40 s, because the loop does not count iterations. Delta debugging shrinks all four inputs that timed out in that review to 20 to 43 bytes. The four originals and the 20-byte input spin in the same two places; I did not instrument the other three minimized inputs.
Lane: Legacy fixes (Q item). The loop sits on the fall-through of the C-order action-cell transaction: it needs the transaction path (packed version order) and no C recovery port, so it concerns the Q-transaction work (see "Is the transaction involved?").
Measurements
Main f015f68. Go 1.25.14, linux/amd64 (20 host CPUs, container limit 4 CPUs and 6 GiB), Docker image gotreesitter/cgo-harness:go1.25-local. Locked C: tree-sitter runtime 0.27.1, grammar tree-sitter-cpp at c0092228, through cgo_harness at f015f68. "Plain" is Parser.Parse. "Registry" is ParseWithTokenSource with the cpp TokenSourceFactory, which is what grammars.ParseFile and LangEntry.Parse do.
The 1,068-byte input. concepts_002.cpp (1,069 bytes) with byte 593 deleted, so line 18 reads template<typename T requires Addable<T> && Subtractable<T> with no closing >. SHA-256 adc49fc9a0b999deb02bd0509cff651dda7d86ed2723033583c4dcc8e8f51297. It is cpp_concepts_002_del1_5of9.cpp in an earlier run's results (stress.jsonl, sweepS1.main.cpp.jsonl).
| Parser | Result | Time (min / median) | Iterations | Notes |
|---|---|---|---|---|
| Go, plain | timeout at every limit tried: 1, 2, 4, 10 and 40 s |
the limit (40 s run: 40,000 ms wall, 41,082 ms CPU) | 146 at every limit | stuck on token template at bytes 658 to 666 |
| Go, registry | accepted, covers 1,068 bytes, 1 ERROR node |
0.903 / 1.649 ms over 20 parses on one Parser; first parse 33.7 ms | 250 | tokens 250 |
| Locked C | covers 1,068 bytes, 4 ERROR nodes | 0.182 / 0.204 ms over 200 parses | not applicable | each parse also builds and closes a Parser |
- Registry is 5.0 times (min) and 8.1 times (median) the C time on this input.
- Plain was never faster than the limit. Against C's median it is at least 196,000 times slower at 40 s, and there is no upper bound.
The four inputs that timed out in the review, plain route, 2 s limit. All four finish on the registry route.
| Input | Bytes | Plain (2 s) | Plain iterations | Last token | Registry | C median |
|---|---|---|---|---|---|---|
concepts_002.cpp del1@5/9 |
1,068 | timeout | 146 | template |
accepted, 32.9 ms cold | 0.282 ms |
concepts_002.cpp del1@7/9 |
1,068 | timeout | 213 | template |
accepted, 32.5 ms cold | 0.249 ms |
reflection_001.cpp ins @# at 54 |
489 | timeout | 50 | auto |
no_stacks_alive, 53.1 ms cold |
0.205 ms |
reflection_001.cpp ins @# at 162 |
489 | timeout | 116 | typename |
no_stacks_alive, 57.1 ms cold |
0.274 ms |
Minimized. Delta debugging with the predicate "the plain route times out at 500 ms" (538 to 1,419 parses each):
| From | To (bytes) | Minimized input |
|---|---|---|
| del1@5/9 | 23 | (T);n s e>f(T);template |
| del1@7/9 | 20 | ;e s d>f(T);template |
| ins@1/9 | 43 | using l=[:s:]<([:v:])>;^^n#t o=[:^^x:];auto |
| ins@3/9 | 34 | [:^^x:];o o^v:k=[:^^::m:];typename |
The 20-byte input (;e s d>f(T);template, SHA-256 2453cceeb12cc6e13f6cf2240c68087d8a98f2ce1f6ba61e6b268d0ab07e5a8f):
| Parser | Result | Time | Iterations |
|---|---|---|---|
| Go, plain, 300 ms limit | timeout |
300.3 ms | 11 |
| Go, plain, no limit | still running at the 60 s watchdog; 100,000,000 turns | over 60 s | 11 |
| Go, registry | no_stacks_alive, returns a tree |
2.0 ms cold | 14 |
| Locked C | covers 20 bytes, 2 ERROR nodes | 0.025 / 0.027 ms | not applicable |
What triggers it. Each row is ;e s d>f(T); plus the text shown; 300 ms limit, plain route. Dropping the leading ; or any other part of the prefix makes the parse finish.
| Text after the prefix | Plain route |
|---|---|
template, typename, auto, class, struct, namespace, using, operator, const, static, return, if |
timeout, iteration 11 |
template<, template;, template x, template<typename T> |
timeout, iteration 11 |
int, x |
no_stacks_alive in 0.3 ms |
nothing, or ; |
accepted in 0.2 ms |
Twelve keywords trigger it. The type keyword int and the identifier x do not: they stop the parse with no_stacks_alive at once. I did not work out what separates the two groups.
Cause
I counted every goto retryAction in parseInternal with a debug overlay on a scratch copy (main stays untouched). On all five inputs, two sites account for every turn, in lockstep:
| Site | Call before it | What it does to the token |
|---|---|---|
parser.go:6813 |
tryRelexCurrentStateDFA (parser.go:2111) |
identifier (symbol 1) becomes the keyword (template 189, auto 180 or typename 152) |
parser.go:6990 |
relexTokenForStackLexState (parser_recover_c.go:6174) |
the keyword becomes the identifier (symbol 1) |
- The stack is always the second of two (
si=1,stacks=2), in state 113. The token bytes never change. - At the 1 s mark each site had run 1,000,000 times or more, on each of the four larger inputs.
- On the 20-byte input the entry to
retryAction(parser.go:6492) was reached 100,000,000 times in 60 s. - Nothing counts or bounds these turns.
Iterationsstays at 11. ThezeroWidthRescueBudgetbounds only the zero-width rescue. - Goroutine dumps of the 20-byte input at 2, 3 and 4 s show
parseInternalin the reduce step (cAppendReductionActionVersions), inrelexTokenForStackLexState(insideLexer.scanInto) and intryRelexCurrentStateDFA(insidepromoteKeyword). That fits a loop with one reduce step and two relex steps per turn. - The CPU profile of the 1,068-byte input at 10 s has 99.8% of its samples inside
retryFullParseForOrigin. So in the 1 KB case the loop is reached in a retry pass. In the 20-byte case it is reached in the first pass (no retry frame in the dumps). Cumulative shares:
| Function | Cumulative |
|---|---|
tryRelexCurrentStateDFA |
41.0% |
dfaTokenSource.scanPreferredTokenForState |
35.9% |
cAppendActionCellReductionVersions |
26.6% |
Lexer.scanInto |
22.8% |
cReductionCandidatesForActionInto |
20.6% |
tryRelexBroadDFA |
13.7% |
dfaTokenSource.promoteKeyword |
13.4% |
relexTokenForStackLexState |
8.3% |
Why the actions are rejected on each turn, and why the relex helpers each believe the other reading has an action, I did not establish.
Is the transaction involved?
Yes. The loop needs the transaction path. Each turn calls the transaction once and gets nothing back, and the two relex steps then flip the token.
| Switch on the plain route | 20-byte input | 1,068-byte input |
|---|---|---|
| none (main) | timeout, iteration 11 |
timeout, iteration 146 |
Language.CompactPackedGSSVersionOrderCertified = false (packed version order off, so no C-order transaction) |
no_stacks_alive after 15 iterations, 18.6 ms |
accepted after 264 iterations, 17.5 ms |
GOT_C_RECOVERY=cpp (C recovery port on) |
accepted after 13 iterations, 17.4 ms |
accepted after 251 iterations, 20.1 ms |
| both | accepted after 13 iterations, 16.3 ms |
not run |
Times are the first parse in a fresh process, so they include warm-up.
The route into the loop is in the code. For an action cell that needs the transaction (more than one action, or a reduce), parseInternal calls cAppendActionCellReductionVersions (parser.go:6544). When no version survives and there is no terminal action, it sets actions = nil and continues "through the ordinary no-action path so keyword repair, re-lexing, and recovery retain their existing ownership" (parser.go:6611 to 6615). That path holds both goto retryAction sites above. The cell in state 113 has two actions for each of the two readings: a reduce to translation_unit_repeat1 and a shift to state 1962 or 1207. With the packed order off, the ordinary dispatch applies them as GLR forks and the parse survives.
I printed what the transaction call at parser.go:6544 returns on each turn. Of the first 1,000,000 calls, 999,976 on the 20-byte input and 998,932 on the 1,068-byte input have one shape: the second of two stacks, state 113, a cell with two actions, and the call returns no terminal action (terminalOrdinal=-1) and no new version (lastReductionVersion=-1). So on every turn the transaction finds nothing for that cell, the code drops into the no-action path, and the two relex steps flip the token. The same call finds nothing for the first dispatch of the keyword too (calls 22 and 25 on the 20-byte input).
Why the transaction finds nothing for a cell that holds a shift, I did not establish.
An earlier profile said the time is in relex and retry. That is where the CPU goes. The data here adds that the loop only exists on the transaction path. The Q-transaction work does not change it: an earlier run's stress.jsonl records the same timeout and the same digest on main and on its qabcd variant. I also applied the shipping variant qa-wave-scratch to main through -overlay (its two files differ from main by exactly the variant's 176 changed lines). The three inputs above still time out at 500 ms with the same iteration counts (11, 11 and 14), and the 1,068-byte input still stops at iteration 146 after 2 s.
Reproduction
The inputs, harness and probe sources, and raw outputs are in issue-cpp-parse-livelock-f015f6802.tar.gz (SHA-256 in issue-cpp-parse-livelock-f015f6802.sha256 next to it).
- Use a source copy of main f015f68 as the module
github.com/odvcencio/gotreesitter(the files inrepro/go.moduse areplacedirective). - Build the standalone program (
repro/b_livelock/main.goin the evidence archive):go build -tags grammar_subset,grammar_subset_cpp -o b_livelock ./b_livelock. - Run
./b_livelock. The input is;e s d>f(T);template, the timeout is 2 s. Expected: the registry line showsstop=no_stacks_aliveafter about 20 ms (the first parse in the process). The plain line showsstop=timeout wall=2000.x ms iterations=11. - Run
./b_livelock -timeout 0 -watchdog 20. Expected: the registry line, thenWATCHDOG: Parse did not return within 20 sand exit status 9. - For the 1 KB input and the profile: run
bin/oak probe -lang cpp -file b/inputs/stress_concepts_002_del1.cpp -route plain -timeout-ms 10000 -cpuprofile cpu.pprof, thengo tool pprof -top -cum bin/oak cpu.pprof. The harness sources are in the archive. - For the loop counters: build the harness with the overlay
ov/dbg.json(the diffs are in the archive) and run it withOAK_LOOP=1. It prints a line each time a site's count reaches a power of ten.
Lane
Legacy fixes (Q item). The loop sits on the fall-through of the C-order action-cell transaction, the path the Q-transaction work changes. Two places could stop it, and I tested neither:
- Make the transaction return a version for the two-action cell, as the ordinary dispatch does.
- Bound the fall-through: a limit on relex retries for one stack and one token, so the two relex steps cannot undo each other without end.
Constraints from the design: no language-name test (D6); report counters before and after in the PR (D10). The cpp rows in the counter ledger do not exercise this path, so I expect no ledger change.
Not the Core lane unless the owner decides the compact route (D1) makes a legacy fix pointless. I did not test the compact route.
Related issues
I searched issues and pull requests (open and closed) for: infinite loop, livelock, hang, tryRelexCurrentStateDFA, relexTokenForStackLexState, retryAction, "parse timeout cpp", template hang. Nothing covers this. Closest:
- # field reports from downstream user (Windows GUI toolkit, pure-Go editor widget, multiple syntaxes) on latest release versions #454 (downstream field reports, open): mentions cpp coverage and timing, not this loop.
- c: large designated-initializer arrays parse ~100-400x slower than expected (v0.51.0) #1100 (closed): c large designated-initializer arrays parse 100 to 400 times slower than expected. Different cause.
- fix(parser): Share one timeout deadline across parse routes #1131 (merged): shared one timeout deadline across parse routes. Earlier work on the same timeout; not about this loop.
A possible link to #1559 (cpp truncation)
In #1559, two of the five files first differ from the registry token stream at a word that the registry source returns as a keyword (default) and the plain route returns as an identifier. Here the loop flips a word between its keyword and identifier readings. Both may come from keyword handling in the DFA token source (promoteKeyword). I did not test that.
What I did not verify
- Other languages. The harness has only cpp built in. Any language with a keyword capture token and the DFA relex steps could loop the same way.
- The compact route.
- Whether the loop is reachable through the registry route. Twelve keyword spellings after the same prefix all finish there, but I did not search further.
- A fix. I did not change engine code.
- The cold first-parse times. The registry column shows the first parse of a process, which includes warm-up, next to warmed numbers where I have them.
- Timings come from a shared host and are directional. A 40 s timeout against a 0.2 ms parse is far outside any load effect.
- Every Go result here comes from an unmodified source copy of main f015f68.
Activity
xidus90 commentedon Nov 12, 2026
Re-check on
main934a39e (Windows amd64, go1.27.0): still reproduces.;e s d>f(T);+template,typename,auto, …,if) stop withtimeout.intandxstop withno_stacks_alivein about 1 ms, as described.issue-cpp-parse-livelock-f015f6802.tar.gzthat timed out still time out at 5 s, including the 1,068-byte retry-pass input (stress_concepts_002_del1.cpp, 146 tokens).Fix: #1630. Each stack now remembers the
(state, symbol, span)readings it has dispatched for the shared lookahead. A no-action re-lex is admitted only if it produces a new reading, so the keyword/identifier flip ends in the ordinary no-action path. With the fix, all inputs above return in 0.5 to 7 ms. The counter ledger is unchanged (412 rows), and only the rows that used to time out change. Tree parity with locked C is not part of that PR: these inputs now end withno_stacks_aliveoracceptedwith an error, while C recovers with 2 to 4ERRORnodes.fix(parser): stop no-action re-lex steps from undoing each other
fix(parser): stop no-action re-lex steps from undoing each other
test(grammars): pin retry-pass termination on the odvcencio#1558 stre…
merge(parser): Merge remotes/origin/main into codex/refresh-pr-1618-f…