Skip to content

cpp: Parser.Parse stops with no_stacks_alive and a truncated root on five real files that locked C parses to the end #1559

Open

Description

@odvcencio

On main f015f68, Parser.Parse (the plain DFA-lexer route) stops with no_stacks_alive on five real C++ files. In every case the published root has one child, an ERROR that wraps the part of the file the parser reached. That part is 1.2% to 75.6% of the file. Locked C covers 100% of all five. melonds_GPU.cpp is the sharpest case: locked C and the registered token source both parse it with no error, and the plain route stops at 75.6%.

The registered token source (grammars.ParseFile, LangEntry.Parse) reaches the end of all five, but on four of them its tree is also mostly one ERROR. No existing issue covers any of this.

Lane: Legacy fixes (Q item) for a bounded change to the legacy loop or DFA lexer. Core (recover) if the owner decides to turn the C recovery port on for cpp.

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. cgo_harness at the same commit.

Plain route. gotreesitter.NewParser(lang).Parse(src). Line is 1-based. Column is a 0-based byte offset. "Root end" is the end byte of the returned root. The stop token is the lookahead that had no action. The retry ladder runs 5 or 9 passes; the table shows the pass that got farthest, which is the one published.

File Bytes Stop Root end (share) Stop token (symbol, text, bytes) Line:col Iterations Root children
basic_string.cpp 160,213 no_stacks_alive 1,855 (1.2%) identifier elif [1857,1861) 61:1 83 ERROR [0,1855)
melonds_DSi.cpp 106,872 no_stacks_alive 25,322 (23.7%) identifier n [25325,25326) 931:50 5,619 ERROR [0,25322)
melonds_GPU.cpp 47,904 no_stacks_alive 36,225 (75.6%) ( [36228,36229) 1373:41 6,296 ERROR [0,36225)
stl_tree.cpp 73,792 no_stacks_alive 43,702 (59.2%) identifier _M_t [43708,43712) 1636:5 6,844 ERROR [0,43702)
tsan_interceptors_posix.cpp 105,604 no_stacks_alive 1,721 (1.6%) identifier define [1723,1729) 45:1 165 ERROR [0,1721)

Truncated is true on all five. Three runs in one process gave the same digest each time.

Same files on the registry route and in locked C.

File Go registry route Locked C
basic_string.cpp accepted, covers; 4 root children, the first is ERROR [0,160148); 618 ERROR nodes covers; root is ERROR; 239 ERROR + 130 MISSING
melonds_DSi.cpp accepted, covers; 10 root children, the first is ERROR [0,91852); 49 ERROR nodes covers; root is translation_unit; 1 ERROR + 36 MISSING
melonds_GPU.cpp accepted, covers; 7 root children; 0 ERROR; 12,830 nodes covers; 0 ERROR; 12,830 nodes
stl_tree.cpp accepted, covers; 3 root children, the first is ERROR [0,73770); 347 ERROR nodes covers; root is ERROR; 133 ERROR + 12 MISSING
tsan_interceptors_posix.cpp accepted, covers; 2 root children, the first is ERROR [0,105588); 71 ERROR nodes covers; root is translation_unit; 24 ERROR
  • Locked C covers all five completely and reports errors on four. It reports none on melonds_GPU.cpp.
  • "Covers" on the registry route does not mean a useful tree. In four files one ERROR child spans 86% to 99.98% of the file. Locked C's tree for melonds_DSi.cpp has one small error. I did not compare any tree node by node.
  • Locked C parses these files in 8.1 to 29.9 ms (median of 20 parses, each with a new Parser). The plain route takes 1.8 to 332 ms to reach the stop (three parses each).

With the C recovery port forced on (GOT_C_RECOVERY=cpp, plain route), all five end accepted and cover the file. The ERROR counts are 290, 120, 15, 129 and 28 in the table's order. melonds_GPU.cpp then has 15 ERROR nodes where locked C has none. parser_recover_c.go:624 gives that as the reason cpp stays opted out: forced recovery still differs on edited expressions.

With the packed version order off (Language.CompactPackedGSSVersionOrderCertified = false, which removes the C-order action-cell transaction from the path), the five plain-route stops do not change. The root ends are the same: 1,855, 25,322, 36,225, 43,702 and 1,721. So the transaction is not involved here, unlike in the hang draft (finding B).

The shipping Q variant qa-wave-scratch, applied to main through -overlay, changes nothing here: the five stops and root ends are the same.

Where the published plain pass first differs from the registry token stream. The index counts tokens in the pass. The first pass stops at token 82, 1,089, 265, 882 and 164. In basic_string.cpp and tsan_interceptors_posix.cpp every pass stops at that same token. In the other three the published pass is a later retry pass that gets much farther (5,618, 6,294 and 6,840 tokens).

File Token index Plain Registry Source
basic_string.cpp 70 of 82 identifier [1668,1675) keyword default line 53, _GLIBCXX_VISIBILITY(default)
melonds_DSi.cpp 1,088 of 5,618 > [5718,5719) >> [5718,5720) line 260, (mbk[0] >> 8)
melonds_GPU.cpp 6,289 of 6,294 > [36220,36221) >> [36220,36222) line 1373, (DispStat[cpu] >> 8) | ...
stl_tree.cpp 67 of 6,840 identifier [2743,2750) keyword default line 75, _GLIBCXX_VISIBILITY(default)
tsan_interceptors_posix.cpp 156 of 164 identifier [1657,1659) #if [1656,1659) line 43, #if SANITIZER_FREEBSD || SANITIZER_APPLE

The first pass of melonds_GPU.cpp is an exception. It sees exactly the registry tokens and still dies at token 265, the ) [3308,3309) of NonStupidBitField<128*1024/VRAMDirtyGranularity>() on line 102. The retry passes get past that line. So for this file part of the difference is in what the parser does with the same tokens, not only in what the lexer returns.

Where the published pass stops, plain route against registry route, token by token:

File Plain route Registry route
tsan_interceptors_posix.cpp, line 45 identifier define [1723,1729); no #define token #define [1722,1729), preproc_arg, preproc_include_token2
basic_string.cpp, line 60 to 61 identifier define, identifier L [1854,1855) (the digits 201907 are skipped), identifier elif #define [1812,1820), preproc_arg 201907L, #elif [1856,1861)
melonds_DSi.cpp, line 931 ("%" PRIx64 "\n") identifier PRIx64, then identifier n [25325,25326); the " at 25323 and the \ at 25324 are skipped " [25323,25324), escape_sequence [25324,25326), " [25326,25327)
melonds_GPU.cpp, line 1373 ((a >> 8) | ((b & 0x80) << 1)) > > as two tokens, ) [36224,36225), then ( [36228,36229); the | at 36226 is skipped >> [36220,36222), | [36226,36227)
stl_tree.cpp, line 1636 (: _M_t(__t)) ) [43701,43702), then identifier _M_t [43708,43712); the : at 43706 is skipped : [43706,43707)

In all five files the final stop sits where the plain route returns a different token, or none, for text that the registry source lexes as an ordinary token. The parse then has no action, and with the C recovery port off for cpp it has no recovery either. Full token windows for each file are in results/tokens_around_stops.txt. This is my reading of the streams; I did not trace the lexer.

Small inputs. Delta debugging with the predicate "the plain route stops with no_stacks_alive while the registry route accepts and covers the input" shrinks each file to 4 to 9 bytes of text that is not C++:

From Bytes Smallest input found
melonds_GPU.cpp 4 [c]|
melonds_GPU.cpp, registry tree must also be error-free 7 i[]>>&;
tsan_interceptors_posix.cpp 9 (v)#f D#i
basic_string.cpp 6 X#g 1L
stl_tree.cpp 6 }d o#f
melonds_DSi.cpp 6 ,""P\n (a backslash and an n)

They show the plain route stopping on a bare #, | or >>, or on string text next to an identifier. They are not valid C++, so they do not show the failure on valid code.

I also tried 18 hand-made snippets of valid code taken from the stop lines (each inside a function). 17 parse on both routes. One stops the plain route: void f(){ log("a: %" PRIx64 "\n", x); } (38 bytes). It ends no_stacks_alive on the plain route and accepted on the registry route. It is the melonds_DSi.cpp pattern (a macro name between two string literals). The stops in melonds_GPU.cpp, stl_tree.cpp, basic_string.cpp and tsan_interceptors_posix.cpp did not reproduce in isolation, so they need the earlier part of each file.

A possible link to #1558 (cpp hang). The first token differences in basic_string.cpp and stl_tree.cpp are a word the registry source returns as the keyword default and the plain route returns as an identifier. #1558 is a loop that 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.

Reproduction

The inputs, harness and probe sources, and raw outputs are in issue-cpp-no-stacks-alive-f015f6802.tar.gz (SHA-256 in issue-cpp-no-stacks-alive-f015f6802.sha256 next to it). The five full source files are not attached, because they are third-party code under their own licenses; their names, sizes and SHA-256 values are in inputs.sha256. The 38-byte snippet above reproduces the melonds_DSi.cpp stop on its own.

  1. Use a source copy of main f015f68 as the module github.com/odvcencio/gotreesitter.
  2. Get the five files (not attached; see above) and put them in c/inputs/. Check them against inputs.sha256.
  3. Build the program in repro/c_five_files/main.go: go build -tags grammar_subset,grammar_subset_cpp -o c_five_files ./c_five_files.
  4. Run ./c_five_files c/inputs/*.cpp. Expected: for each file, the route=plain line shows stop=no_stacks_alive covers=false with the root end and stop token from the first table, and the route=registry line shows stop=accepted covers=true.
  5. For locked C: cprobe/zz_oak_probe_test.go is a Go test added to the read-only cgo_harness module with go test -c -tags treesitter_c_parity -overlay ov/cprobe.json. Run the test binary from cgo_harness with OAK_LIST=c/c_list.tsv OAK_OUT=out.jsonl.
  6. For the forced-recovery row: GOT_C_RECOVERY=cpp ./c_five_files c/inputs/*.cpp shows stop=accepted on the plain route.
  7. For the token streams: oak probe -lang cpp -file c/inputs/<file>.cpp -route plain -log prints one token sym= start= end= line per token.
  8. For the small inputs: oak ptest -list c/variants2.txt -timeout-ms 2000 prints stop reason and time for each snippet on both routes. The 38-byte snippet is the one with no_stacks_alive.

Lane

Legacy fixes (Q item) if the fix is a bounded change to how the legacy loop and DFA lexer handle a token that has no action. The design asks for no language-name test (D6). Counters must stay flat or improve (D10). The cpp ledger row uses a 2 KB sample, so it will not show a change.

Core (recover) if the choice is to enable the C recovery port for cpp. The recorded reason for the opt-out is at parser_recover_c.go:624.

Whether the plain route for cpp is in scope is the owner's decision. docs/api-guide.md says NewParser(lang).Parse always uses the DFA lexer and that the registered source "is not always the better lexer", so both routes are public.

Related issues

I searched issues and pull requests for: no_stacks_alive, basic_string.cpp, stl_tree.cpp, melonds, tsan_interceptors_posix, "cpp truncated", "cpp stop reason", "cpp recovery", "stops early cpp". Nothing covers these files. Closest:

What I did not verify

  • Why the DFA lexer produces these tokens in these states. I compared token streams and did not trace the lexer.
  • Other languages on the plain route.
  • The compact route.
  • Whether the files stop the same way on release v0.56.0.
  • Node-by-node equality between the registry trees and locked C's.
  • The licenses of the inputs before upload to the public evidence release. basic_string.cpp and stl_tree.cpp are libstdc++ headers (GPL-3 with the GCC runtime exception). melonds_*.cpp are melonDS (GPL-3). tsan_interceptors_posix.cpp is LLVM compiler-rt (Apache-2.0 with LLVM exception). I took this from the file headers and did not record upstream commits. The owner should decide whether to attach them.
  • Every Go result here comes from an unmodified source copy of main f015f68.

Activity

  1. added
    bugSomething isn't working
    area/parserParser runtime, recovery, and GLR behavior
    correctnessParser correctness and reference parity
    on Nov 1, 2026
  2. xidus90 commented on Nov 12, 2026

    @xidus90
    Contributor

    Re-check on main 934a39e (Windows amd64, go1.27.0): still reproduces. I did not have the five full files, so I checked the snippet and the minimized inputs.

    Effort: the cause sits in the DFA token source (#define/#if, >> and keyword promotion), and the fix probably reaches into recovery. I estimate well over an hour, so I am not opening a PR. Note that GOT_C_RECOVERY=cpp changes the picture, as the report says; whether to enable it is an owner decision.

  3. xidus90 commented on Nov 12, 2026

    @xidus90
    Contributor

    Root-cause findings (plain route, main 0577027, Windows amd64, go1.27.0)

    I traced the minimized inputs and the current upstream melonDS src/GPU.cpp (47,975 bytes; line 1373 is the same as in the report). There are at least three separate causes. The C references below come from py-tree-sitter with tree-sitter-language-pack, not from the locked commits.

    A. The DFA lexer silently drops bytes the current lex mode cannot match

    Explains [c]|, X#g 1L, }d o#f, (v)#f D#i and ,""P\n. In these, |, #, 1 and \ are skipped, and the next token starts after them. C runs ts_parser__lex again in ERROR_STATE instead. That C behaviour is already ported (Lexer.nextWithFrontier, errorModeRetry), but it is reachable only when C recovery is on: see setLexerErrorRunLexStateEnabled (parser_dfa_token_source.go:259) and nextTokenForLexState (:1211).

    Experiment, with both gates opened for every grammar:

    I am not proposing A until that false-clean root is understood.

    B. The > / >> width election kills every stack

    compareAngleTokenPreference always prefers a single > over a wider close-angle run. In i[]>>&;, after ], stack 0 (state 3926) wants >> and stack 1 (state 8556) wants > (sym 190), each with score 1. > wins, so stack 0 dies. Stack 1 reduces to state 9092, which has no action for > either, so no stack survives. C lexes each version separately and keeps the >> version.

    The rule cannot simply go: with it disabled, TestCppNestedTemplateCloseAngleMatchesC, TestApexGenericLocalDeclarationMatchesCShape, TestTypeScriptGenericLineageRegressions and TestTypeScriptShiftOperatorNotSplitAsCloseAngle fail.

    C. Template closer read as an operator (already covered by #1629)

    The first pass of melonds_GPU.cpp dies at line 102, NonStupidBitField<128*1024/VRAMDirtyGranularity>(): the closing > is elected as the binary operator (sym 36). This is the shared-lookahead election that #1629 fixes.

    Combined

    Variant melonds_GPU.cpp melonds_DSi.cpp
    main no_stacks_alive at 36,225 (75.5%) no_stacks_alive at 27,515
    #1629 same same
    angle rule off accepted, 30 ERROR no_stacks_alive
    #1629 + angle rule off accepted, 0 ERROR (C: 0) no_stacks_alive (needs A)

    I did not compare that tree with C node for node.

    Next

    I will open a PR for B, stacked on #1629. A stack with no action for the elected narrow > whose own lex mode reads a wider close-angle run with an action gets parked instead of being killed. If every other stack dies on that token, the parked stack continues alone with its own token, which in effect is what C's per-version lexing does. A stays open until the false-clean root is understood.

  4. xidus90 commented on Nov 12, 2026

    @xidus90
    Contributor

    Cause B: #1632 (stacked on #1629). With both, melonds_GPU.cpp parses to EOF with no ERROR on the plain route, as in C. Cause A (silent byte skip) is still open, so basic_string.cpp, melonds_DSi.cpp, stl_tree.cpp and tsan_interceptors_posix.cpp still stop early.

  5. xidus90 commented on Nov 12, 2026

    @xidus90
    Contributor

    Cause A (silent byte skip): what turning on the C lexer error mode does

    Experiment (main 0577027): open both gates so the plain DFA route always uses the ported ts_parser__lex failure handling, i.e. an ERROR_STATE retry and then an error-run token:

    • setLexerErrorRunLexStateEnabled at parser_dfa_token_source.go:259
    • nextTokenForLexState at :1211

    Results, together with #1635:

    Input main A + #1635 C (pack)
    6 minimized inputs + 39-byte snippet no_stacks_alive, stops early accepted, covers, HasError covers, error
    melonds_GPU.cpp stops at 36,225 covers, 45 ERROR (41 with #1632; 0 needs #1629 + #1632) 0 ERROR
    melonds_DSi.cpp stops at 27,515 covers, 189 ERROR 1 ERROR

    #1635 is needed first. Without it, }d o#f and ,""P\n publish a clean, childless root (trailing-EOF recovery drops its ERROR extras).

    Why I am not opening a PR for A. As a change for every grammar, it moves pinned gates:

    • perfcounterledger: doxygen candidate decline changes reason, from accepted-leaf-tiling-gap to compact route error: parser-core fresh-full runner did not accept EOF. Both decline; no work counter moves. This would need R3.
    • TestDoxygenDispatchBlockerRoutes: 4 pinned raw digests change.
    • TestCompactRouteAcceptedRootLeafCoverageGapDeclines: html <!--c-->> and friends are routed instead of declined.
    • TestRetryStopReasonSwallowedErrorFallbackRejected: its fallback is now adopted.
    • grammars/TestHaskellUnterminatedStringUsesInternalGapFallback: (haskell (ERROR (ERROR))) instead of (haskell (ERROR)).
    • TestSetLexerErrorRunLexStateUsesCRecoveryGate pins the current gating on purpose.

    Owner decision needed:

    1. Should the C error-mode lexing apply on the plain route for grammars outside the C recovery port?
    2. If yes, should it apply to all grammars at once (with R3 for the doxygen row and new receipts), or behind a per-grammar opt-in like the recovery port?

    Until then, the remaining four files (basic_string.cpp, melonds_DSi.cpp, stl_tree.cpp, tsan_interceptors_posix.cpp) still stop early on the plain route.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/parserParser runtime, recovery, and GLR behaviorbugSomething isn't workingcorrectnessParser correctness and reference parity

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions