Skip to content

TSX: a malformed JSX file never finishes parsing (tree-sitter error-recovery loop); on the native kernel the CLI never exits #3274

Open

Description

@inth3shadows

The bug

A TSX file in a certain malformed state never finishes parsing. The smallest input I have is 16 bytes:

<a {{b:>c:d(e f)

A half-typed JSX attribute like this is easy to end up with while you're editing.

  • wasm path (default): each attempt runs until the hard-kill timeout. The worker is killed, the file is retried, and in the end it's dropped from the index. A two-file project takes 92 s to index.
  • Native kernel (CODEGRAPH_KERNEL=1): the same timeouts fire and the file is dropped. The CLI then prints Done, but the process never exits: every attempt leaves a native thread spinning at full CPU.

Reproduce

On main at f72c9d2 (Linux x64, Node 22), after npm ci && npm run build, run this from the repo root as repro.cjs:

const { Parser, Language } = require('web-tree-sitter');
(async () => {
  await Parser.init();
  const lang = await Language.load('dist/extraction/wasm/tree-sitter-tsx.wasm');
  const parser = new Parser();
  parser.setLanguage(lang);
  console.time('parse');
  parser.parse('<a {{b:>c:d(e f)');
  console.timeEnd('parse');
})();
$ timeout 20 node repro.cjs; echo "exit $?"
exit 124                       # never returns
# the same script with '<a {{b:>c:d(e)' instead:
parse: 8.702ms

Through the CLI, with a project holding src/poc.tsx (the input above) and one valid src/ok.tsx:

$ time codegraph init -i .                       # wasm
◆  Indexed 1 files (1 could not be parsed)
real    1m32.388s
# .codegraph/errors.log:  src/poc.tsx: Parse timed out after 30000ms

$ CODEGRAPH_KERNEL=1 timeout 120 codegraph init -i .
◆  Indexed 1 files (1 could not be parsed)
└  Done
exit 124                                         # killed by timeout; it does not exit on its own

While the kernel run hangs, its process has three threads at 100% CPU. They started about 30 s apart, one for each timed-out attempt. A control project with only valid files indexes in 1.3 s on the kernel and exits normally.

Expected

The file parses in milliseconds, with an ERROR node, as it does once the runtime has the fix below. Even when a parse does hang, the CLI should exit.

Root cause

This is a bug in the tree-sitter runtime, not in the grammar or in codegraph.

  • The loop: ts_parser__do_all_potential_reductions (lib/src/parser.c) removes a stack version and then advances the loop index. Removing a version shifts the next one into its slot, so that version is never checked. Error recovery can then re-enter the same cycle forever without consuming input.
  • Introduced: tree-sitter/tree-sitter@d3c85f28, "Start work on repairing errors by inserting missing tokens" (2017-12-28). It is in every tagged release from 0.14.0 on.
  • Fixed: fix(lib): check shifted stack versions during error recovery tree-sitter/tree-sitter#5997 (tree-sitter/tree-sitter@a5add39b, a three-line continue;). It shipped in 0.27.1 and 0.27.2. It was not backported to the release-0.25 or release-0.26 branches.
  • Not the grammar: the same tsx wasm parses the input in 9 ms on a 0.25.10 runtime built with that one fix applied. I tried both codegraph's shipped tsx grammar and a newer fork of it, with the same result. The upstream grammar report is bug: Infinite loop in tsx parser tree-sitter/tree-sitter-typescript#323. Its original snippet no longer hangs on the grammar codegraph ships, but the minimized input above does.

Edit (2026-11-11): Native A/B, isolating the three-line fix.

I built four native parsers with the same compiler (gcc 13.3, -O2). Each one links the same object files, compiled from codegraph's own tsx grammar source (the tree-sitter-typescript crate 0.23.2 in codegraph-kernel/Cargo.lock), against tree-sitter's lib/src/lib.c at a different revision. Within each pair, diff -r lib/ reports one file, parser.c, differing only in the fix's three lines.

tree-sitter lib/ <a {{b:>c:d(e f) control <a {{b:>c:d(e)
v0.25.10 (what codegraph ships) killed at 20 s 6 ms
v0.25.10 plus only the #5997 hunk 6 ms 8 ms
a5add39b^ (just before the fix) killed at 20 s 6 ms
a5add39b (the fix) 12 ms 7 ms

The fixed builds return a tree with has_error=1, as the control does. No codegraph code and no wasm is involved, so this rules out the grammar, the build toolchain and the wasm runtime as causes.

How to reproduce
// driver.c
#include <stdio.h>
#include <string.h>
#include <tree_sitter/api.h>
const TSLanguage *tree_sitter_tsx(void);
int main(int argc, char **argv) {
  TSParser *p = ts_parser_new();
  ts_parser_set_language(p, tree_sitter_tsx());
  TSTree *t = ts_parser_parse_string(p, NULL, argv[1], strlen(argv[1]));
  printf("parsed, has_error=%d\n", ts_node_has_error(ts_tree_root_node(t)));
  return 0;
}
# G = tree-sitter-typescript 0.23.2 source (crate or v0.23.2 tag); TS = a tree-sitter checkout
git -C $TS archive v0.25.10 lib | tar -x -C base
git -C $TS archive v0.25.10 lib | tar -x -C patched
git -C $TS show a5add39b -- lib/src/parser.c | (cd patched && patch -p1)
gcc -O2 -c -I $G/tsx/src $G/tsx/src/parser.c  -o tsx-parser.o
gcc -O2 -c -I $G/tsx/src $G/tsx/src/scanner.c -o tsx-scanner.o
for v in base patched; do
  gcc -O2 -I $v/lib/include -I $v/lib/src $v/lib/src/lib.c driver.c tsx-parser.o tsx-scanner.o -o drv-$v
  timeout 20 ./drv-$v '<a {{b:>c:d(e f)'; echo "$v exit $?"
done

Edit (2026-11-11): How common the trigger is, and other languages.

How I checked this, and what it doesn't cover

Checks on the results above:

  • Positive control: the deadline driver cancels the original trigger on stock 0.25.10, and the patched build parses it.
  • True hangs, not slow parses: I sampled 100 of the 1,718 at random and gave each a 10 s deadline. All 100 still hadn't finished.
  • Native matches codegraph: I re-ran 40 neighbours (20 that hang, 20 that don't), chosen at random, through codegraph's shipped wasm with stock web-tree-sitter 0.25.10. All 40 agree with the native result.
  • The method reproduces: running the scripts below from scratch gives 1,718 of 3,151 on stock and 0 on patched.

What it doesn't cover:

  • Fuzzing was not coverage-guided: there was no clang or libFuzzer on the machine. The runs were:
    • 5,000 random sequences of each grammar's literal tokens, for all 20 grammars;
    • on tsx only: 300,000 random strings over its punctuation, 40,000 mutated windows of real .tsx files, and a 4-minute search that kept and mutated inputs which reach the skipped-version branch.
    • None of these found a hang.
  • Grammars covered: the 20 grammars are the C sources of the tree-sitter-* crates in codegraph-kernel/Cargo.lock plus codegraph-kernel/grammars/. The wasm-only grammars weren't fuzzed.

1. Build the runtime twice, stock and patched (TS = a tree-sitter checkout):

git -C $TS archive v0.25.10 lib | tar -x -C stock
git -C $TS archive v0.25.10 lib | tar -x -C patched
git -C $TS show a5add39b -- lib/src/parser.c | (cd patched && patch -p1)

2. Build each grammar as a shared library (G = a grammar's src/ directory, for example tree-sitter-typescript 0.23.2's tsx/src):

gcc -O2 -fPIC -shared -I $G $G/parser.c $G/scanner.c -o tsx.so

3. Build a driver that parses each input under a deadline, using the 0.25 progress callback. A cancelled parse returns NULL. Build it against both runtimes:

for v in stock patched; do gcc -O2 -I $v/lib/include -I $v/lib/src $v/lib/src/lib.c drv.c -ldl -o drv-$v; done
// drv <grammar.so> <symbol> <inputs.bin> <deadline_ms>: prints "idx ms cancelled" for slow parses, then a summary.
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
#include <tree_sitter/api.h>
static double now_ms(void){ struct timespec t; clock_gettime(CLOCK_MONOTONIC,&t); return t.tv_sec*1e3+t.tv_nsec/1e6; }
static double deadline;
static bool progress(TSParseState *s){ (void)s; return now_ms() > deadline; }
typedef struct { const char *buf; uint32_t len; } Src;
static const char *rd(void *p, uint32_t i, TSPoint pt, uint32_t *n){ (void)pt; Src *s=p; if(i>=s->len){*n=0;return "";} *n=s->len-i; return s->buf+i; }
int main(int argc, char **argv){
  void *h = dlopen(argv[1], RTLD_NOW); if(!h){fprintf(stderr,"%s\n",dlerror());return 2;}
  const TSLanguage *(*lang)(void) = dlsym(h, argv[2]); if(!lang){fprintf(stderr,"no sym\n");return 2;}
  FILE *f = fopen(argv[3],"rb"); double lim = atof(argv[4]);
  TSParser *p = ts_parser_new(); if(!ts_parser_set_language(p, lang())){fprintf(stderr,"abi mismatch\n");return 2;}
  uint32_t len; long idx=0, slow=0, cancelled=0; size_t cap=1<<20; char *buf=malloc(cap);
  while(fread(&len,4,1,f)==1){
    if(len > cap){ cap = len; buf = realloc(buf, cap); }
    if(fread(buf,1,len,f)!=len) break;
    Src s={buf,len}; TSInput in={&s, rd, TSInputEncodingUTF8, NULL};
    TSParseOptions o={NULL, progress};
    double t0=now_ms(); deadline=t0+lim;
    TSTree *t = ts_parser_parse_with_options(p, NULL, in, o);
    double ms=now_ms()-t0;
    if(!t){ cancelled++; printf("%ld %.0f cancelled\n", idx, ms); ts_parser_reset(p); }
    else { if(ms>100){ slow++; printf("%ld %.0f slow\n", idx, ms);} ts_tree_delete(t); }
    idx++;
  }
  printf("SUMMARY inputs=%ld cancelled=%ld slow=%ld\n", idx, cancelled, slow);
  return 0;
}

4. Generate the neighbours and run them:

python3 nbr.py nbr.bin
./drv-stock   ./tsx.so tree_sitter_tsx nbr.bin 300 | tail -1   # SUMMARY inputs=3151 cancelled=1718
./drv-patched ./tsx.so tree_sitter_tsx nbr.bin 300 | tail -1   # SUMMARY inputs=3151 cancelled=0
# Every single-character edit (insert, delete, replace; printable ASCII + newline) of the trigger,
# written as [u32 little-endian length][bytes] records for drv.
import struct, sys
poc = '<a {{b:>c:d(e f)'; al = [chr(c) for c in range(32, 127)] + ['\n']
vs = set()
for i in range(len(poc) + 1):
    for c in al: vs.add(poc[:i] + c + poc[i:])
for i in range(len(poc)):
    vs.add(poc[:i] + poc[i+1:])
    for c in al: vs.add(poc[:i] + c + poc[i+1:])
vs.discard(poc)
with open(sys.argv[1], 'wb') as f:
    for v in sorted(vs): b = v.encode(); f.write(struct.pack('<I', len(b)) + b)
print(len(vs), 'variants')

Edit (2026-11-11): Coverage-guided fuzzing finds a second language, COBOL. This supersedes "Other languages: I found no trigger" in the edit above.

I fuzzed each grammar with libFuzzer (clang 18):

  • the tree-sitter runtime and the grammar are both instrumented;
  • the dictionary is the grammar's literal tokens, and the seeds come from real files;
  • a 2 s timeout counts as a finding;
  • each grammar ran 10 minutes with 4 workers.
grammar inputs hangs
tsx (calibration run, 6 workers, seeds from real .tsx only) 596,554 5. All 5 run past 60 s on stock and parse quickly with the #5997 hunk; the first was found after 48 s
cobol (512-byte inputs, for fixed format) 1,784,638 1
vbnet 441,541 0
c, c_sharp, cpp, dart, go, java, javascript, kotlin, lua, luau, php, php_only, python, r, ruby, rust, scala, swift, typescript 219,185 to 975,877 each 0

COBOL. libFuzzer minimized the finding to 18 bytes: 6100 MOVE( H L:2 followed by a NUL byte. The NUL is required: the same text ending in a newline, a space, \x01 or \xff parses normally. It still hangs inside an otherwise valid program:

printf '       IDENTIFICATION DIVISION.\n       PROGRAM-ID. X.\n       PROCEDURE DIVISION.\n6100  MOVE( H L:2\0' > src/x.cbl
codegraph init -i .
  • On main at f72c9d2: the run takes 92 s and drops the file (src/x.cbl: Parse timed out after 30000ms).
  • With the patched runtime: the same run takes 2 s and indexes the file. That is the same tree-sitter-cobol.wasm, sha256 a72425462f3fffc827dee97498b613ddd2d3d56e20c1e3913a6538d7026d3155.
  • Native: stock 0.25.10 runs past 60 s, and with only the #5997 hunk it parses at once. So this is the same runtime bug, not the COBOL grammar.

How likely this is depends on a NUL byte reaching the parser. A COBOL source with LOW-VALUES pasted into a literal, or a damaged mainframe transfer, could do that. I haven't seen one in the wild.

How I built and ran it
  • COBOL and VB.NET ship only as wasm, so I built them from the commits and patches recorded in docs/grammars/tree-sitter-cobol.md and docs/grammars/tree-sitter-vbnet.md, with tree-sitter-cli 0.25.10. Their trees match the shipped wasm on 60 of the COBOL repo's test programs and 20 VB.NET files.
  • The other 19 grammars are the C sources of the crates in codegraph-kernel/Cargo.lock plus codegraph-kernel/grammars/.

The fuzz target:

#include <stdint.h>
#include <stddef.h>
#include <tree_sitter/api.h>
const TSLanguage *LANG(void);
static TSParser *p;
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
  if (!p) { p = ts_parser_new(); ts_parser_set_language(p, LANG()); }
  TSTree *t = ts_parser_parse_string(p, NULL, (const char *)data, (uint32_t)size);
  if (t) ts_tree_delete(t);
  return 0;
}
# stock = tree-sitter v0.25.10 lib/ (as in the Native A/B above); G = the grammar's src/
clang -O1 -g -fsanitize=fuzzer-no-link -c -I stock/lib/include -I stock/lib/src stock/lib/src/lib.c -o lib-stock.o
clang -O1 -g -fsanitize=fuzzer -DLANG=tree_sitter_COBOL -I stock/lib/include -I $G target.c $G/parser.c $G/scanner.c lib-stock.o -o fz-cobol
./fz-cobol corpus seeds -fork=4 -ignore_timeouts=1 -timeout=2 -max_len=512 -dict=cobol.dict -max_total_time=600
./fz-cobol -minimize_crash=1 -timeout=2 timeout-<hash>     # minimizes a finding

What this doesn't cover:

  • arkts, cfml, cfquery, cfscript, erlang, nix, pascal and terraform also ship only as wasm, and their source commits aren't recorded, so I didn't fuzz them.
  • Ten minutes with no finding is not proof that none exists. For comparison, tsx's first hang came after 48 s.

codegraph is affected on both paths:

  • wasm: web-tree-sitter is pinned at 0.25.10.
  • Kernel: it uses the tree-sitter crate 0.25.10.

Why the kernel run never exits

This part is codegraph's own.

  • The kernel parse is one synchronous native call inside a parse worker. The kernel calls parser.parse(source, None) with no progress callback (no progress_callback anywhere in codegraph-kernel/src).
  • When the hard timeout fires, ParseWorkerPool.onHardTimeout calls w.terminate(). Worker.terminate() can stop JavaScript and wasm, but not native code that is running, so the thread keeps spinning.
  • The retry pass then starts another worker, which hangs the same way.
  • At exit, Node waits for those worker threads, which never finish. (Inferred from the code and the thread CPU above; I didn't attach a debugger.)

This holds for any input that makes a native parse run away, not only this one.

Fix options

  1. Patch the runtime at 0.25.10.
  2. Move to web-tree-sitter 0.27.x.
    • This gets the fix with no patch.
    • But 0.27 refuses wasm files with the legacy dylink section. When I tried 0.27.1, that dropped about 20 of the shipped grammars (the tree-sitter-wasms 0.1.x ones), so they would need rebuilding first.
  3. Bound native parses, independently of 1 and 2.
    • The 0.25 crate's ParseOptions::progress_callback can cancel a parse from inside the native call. A deadline there would let the kernel give up the way the wasm path does, and stop the exit hang for any future runaway parse.

I'm happy to send a PR for whichever you prefer.

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions