Repository navigation
Description
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 printsDone, 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 therelease-0.25orrelease-0.26branches. - 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 $?"
doneEdit (2026-11-11): How common the trigger is, and other languages.
- The trigger is a family, not one string. I tried every single-character edit of
<a {{b:>c:d(e f)(insert, delete or replace, using printable ASCII or a newline): 3,151 variants. On the tsx grammar, 1,718 of them hang on stock tree-sitter 0.25.10, against a 300 ms deadline. All 1,718 parse in time with the #5997 hunk applied. None hang on the typescript or javascript grammars. - It hangs in code shaped like a real component file, and with valid code before or after it. Each of these hangs on codegraph's own shipped
tree-sitter-tsx.wasmwithweb-tree-sitter0.25.10 (no result in 20 s), and parses in under 10 ms on the patched runtime:export function App() { return <div {{ color: > red: fn(a b) }}></div> }
<div style={{color:>red:fn(a b),<Button {{size:>sm:cx(base active),const X = () => <div {{a:>b:c(d e)}} />, and the trigger withimport React from "react"on the line before it orexport const ok = 1on the line after. - Other languages: I found no trigger. The fuzzing below found no hang in any of the 20 grammars the kernel compiles. On tsx, where we know a trigger exists, it never rediscovered it, so treat that as no evidence either way. Six hang reports from other tree-sitter grammar repos that include code all parse quickly on the grammar versions codegraph ships. They are bug: possible infinite loop on grammar tree-sitter/tree-sitter-cpp#302, Parser Hangs forever Parsing JS Code tree-sitter/tree-sitter-javascript#322, process hang in deal some php file tree-sitter/tree-sitter-php#42, Parser gets stuck tree-sitter/tree-sitter-scala#392 (the same file as Tree sitter gets stuck tree-sitter/py-tree-sitter#226), tsx parser stuck in an infinite loop tree-sitter/tree-sitter-typescript#265 and bug: parser stuck in infinite loop with multiple open clauses tree-sitter/tree-sitter-typescript#281. The seventh, Java parser locks up on large invalid input tree-sitter/tree-sitter-java#187, is a 1.2 MB file of comma-separated numbers. It runs past 10 minutes on stock and patched alike, so the #5997 fix doesn't change it. Its maintainer described it as slow error recovery on a very large invalid file, not a loop.
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-sitter0.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
.tsxfiles, 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 incodegraph-kernel/Cargo.lockpluscodegraph-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.so3. 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
mainat 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, sha256a72425462f3fffc827dee97498b613ddd2d3d56e20c1e3913a6538d7026d3155. - 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.mdanddocs/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.lockpluscodegraph-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 findingWhat 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-sitteris pinned at 0.25.10. - Kernel: it uses the
tree-sittercrate 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 (noprogress_callbackanywhere incodegraph-kernel/src). - When the hard timeout fires,
ParseWorkerPool.onHardTimeoutcallsw.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
- Patch the runtime at 0.25.10.
- Apply the three-line fix to a vendored
web-tree-sitter.wasm(loaded throughParser.init({ locateFile })) and to the crate for the kernel. - Every existing grammar keeps loading.
- My fork does this in fix(extraction): vendor tree-sitter runtime 0.25.10 + #5997 (error-recovery livelock) inth3shadows/codegraph#18: wasm and kernel, with a canary test.
- Apply the three-line fix to a vendored
- Move to web-tree-sitter 0.27.x.
- This gets the fix with no patch.
- But 0.27 refuses wasm files with the legacy
dylinksection. When I tried 0.27.1, that dropped about 20 of the shipped grammars (thetree-sitter-wasms0.1.x ones), so they would need rebuilding first.
- Bound native parses, independently of 1 and 2.
- The 0.25 crate's
ParseOptions::progress_callbackcan 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.
- The 0.25 crate's
I'm happy to send a PR for whichever you prefer.
Activity