Summary
tree-sitter 0.26.0 segfaults (SIGSEGV, exit 139) under a normal symbol-indexing / code-navigation traversal: run a query, hold the capture nodes, then for each captured definition/call ascend .parent() to the enclosing definition and re-walk the ancestor chain (.children, .parent, .start_byte, .end_byte, .child_by_field_name, .start_point). When this is repeated across several real, medium-to-large modules in one process, a Node starts reading freed native memory and CPython crashes on a plain .start_byte/.end_byte access.
The exact same workload runs clean indefinitely on 0.25.0 / 0.25.1 / 0.25.2 (identical tree-sitter-python wheel), so this is a regression introduced in 0.26.0.
Environment
|
|
| tree-sitter |
0.26.0 (bad) vs 0.25.2 (good) |
| tree-sitter-python |
0.25.0 (unchanged across the bisect; ABI 15) |
| CPython |
3.14.2 (also reproduced on 3.12.12) |
| Platform |
Linux x86-64 (WSL2, glibc 2.43) |
gc.disable() does not prevent it. Reproduces identically on 3.12 and 3.14, so it is not a Python-ABI mismatch.
Reproducer
Self-contained; depends only on tree-sitter + tree-sitter-python, and parses a handful of stdlib modules (argparse/_pydecimal/pydoc/inspect/typing) so nothing extra is needed. Small or synthetic sources never accumulate enough node churn to trip it — several real modules do.
pip install "tree-sitter==0.26.0" tree-sitter-python
python repro.py # -> Segmentation fault (exit 139), usually within the first few parses
pip install "tree-sitter==0.25.2"
python repro.py # -> "DONE (no crash)"
I see it crash on 8/8 runs on 0.26.0 and 0/N on 0.25.2.
repro.py (~210 lines)
#!/usr/bin/env python3
"""Minimal SIGSEGV reproducer for py-tree-sitter 0.26.0 (clean on 0.25.x).
pip install "tree-sitter==0.26.0" tree-sitter-python
python repro_clean.py # parses the stdlib's argparse.py by default
python repro_clean.py FILE.py # ...or any real ~1000+ line module
Segfaults (SIGSEGV, exit 139) deterministically within the first few parses in
one process. Swap to tree-sitter==0.25.2 (same tree-sitter-python wheel) and it
runs clean to completion. Reproduced on CPython 3.12 and 3.14, Linux x86-64.
A large *real* module is needed (stdlib argparse.py works everywhere); small or
synthetic sources never accumulate enough node churn to trip the corruption.
Shape: a code-navigation / symbol-indexing pass. For each parsed file, run a
query, keep the capture nodes, then for every captured definition and every
captured call ascend `.parent()` to the enclosing definition and re-walk the
ancestor chain (reading `.children`, `.parent`, `.start_byte`, `.end_byte`,
`.child_by_field_name`, `.start_point`), plus `child_by_field_name("body")`
walks for a docstring/signature. Hundreds of full root-ward re-ascents per
parse. Small or synthetic trees never trip it; several real modules do.
On 0.26.0 a Node's native backing is freed while a live Python `Node` still
references it: a later access of `.start_byte` / `.end_byte` reads freed memory
and CPython segfaults. The crash reliably lands somewhere in this walk but the
exact frame drifts run-to-run (capture-key build, def-name lookup, node text),
consistent with heap corruption from a use-after-free rather than one bad call.
Under 0.25.x the identical workload runs clean indefinitely.
"""
import faulthandler
import sys
from pathlib import Path
faulthandler.enable()
from tree_sitter import Language, Parser, Query, QueryCursor
import tree_sitter_python
LANG = Language(tree_sitter_python.language())
DEF_TYPES = {"function_definition", "class_definition"}
QUERY = Query(
LANG,
"""
(function_definition name: (identifier) @def.function)
(class_definition name: (identifier) @def.class)
(class_definition superclasses: (argument_list (identifier) @base))
(call function: (identifier) @call)
(call function: (attribute attribute: (identifier) @call))
""",
)
def key(node):
return (node.start_byte, node.end_byte)
def enclosing_def(node):
cur = node.parent
while cur is not None:
if cur.type in DEF_TYPES:
return cur
cur = cur.parent
return None
def def_name_node(def_node, name_keys):
for child in def_node.children:
if key(child) in name_keys:
return child
field = def_node.child_by_field_name("name")
if field is not None and key(field) in name_keys:
return field
return None
def qualified(def_node, name_keys):
nn = def_name_node(def_node, name_keys)
if nn is None:
return None
parts = [name_keys[key(nn)]]
parent_def = enclosing_def(def_node)
while parent_def is not None: # re-ascend per def
pnn = def_name_node(parent_def, name_keys)
if pnn is not None:
parts.append(name_keys[key(pnn)])
parent_def = enclosing_def(parent_def)
parts.reverse()
return ".".join(parts)
def signature(def_node, raw):
body = def_node.child_by_field_name("body")
if body is not None:
return raw[def_node.start_byte : body.start_byte].decode("utf-8", "replace")
return raw[def_node.start_byte : def_node.end_byte].decode("utf-8", "replace")
def docstring(def_node, raw):
body = def_node.child_by_field_name("body")
if body is None:
return None
for child in body.children:
if child.type == "expression_statement":
inner = child.children[0] if child.children else None
if inner is not None and inner.type == "string":
return raw[inner.start_byte : inner.end_byte].decode("utf-8", "replace")
break
return None
def decorators(def_node, raw):
parent = def_node.parent
if parent is not None and parent.type == "decorated_definition":
return [
raw[c.start_byte : c.end_byte].decode("utf-8", "replace")
for c in parent.children
if c.type == "decorator"
]
return None
def class_bases(def_node, base_nodes, raw):
bases = []
target = key(def_node)
for node in base_nodes:
cur = node.parent
while cur is not None:
if key(cur) == target:
bases.append(raw[node.start_byte : node.end_byte].decode("utf-8", "replace"))
break
cur = cur.parent
return bases
def walk(root, raw):
captures = [(name, n) for name, ns in QueryCursor(QUERY).captures(root).items() for n in ns]
name_keys = {
key(n): raw[n.start_byte : n.end_byte].decode("utf-8", "replace")
for name, n in captures
if name in ("def.function", "def.class")
}
base_nodes = [n for name, n in captures if name == "base"]
symbols, contains, inherits, calls = [], [], {}, []
seen = set()
for name, node in captures:
if name not in ("def.function", "def.class"):
continue
def_node = node.parent
while def_node is not None and def_node.type not in DEF_TYPES:
def_node = def_node.parent
if def_node is None:
continue
qn = qualified(def_node, name_keys)
if qn is None or qn in seen:
continue
seen.add(qn)
symbols.append(
(
qn,
def_node.start_point.row + 1,
def_node.end_point.row + 1,
signature(def_node, raw),
docstring(def_node, raw),
decorators(def_node, raw),
)
)
parent_def = enclosing_def(def_node)
container = qualified(parent_def, name_keys) if parent_def is not None else "<module>"
contains.append((container, qn))
if name == "def.class":
b = class_bases(def_node, base_nodes, raw)
if b:
inherits[qn] = b
for name, node in captures: # call pass — the churn
if name != "call":
continue
cname = raw[node.start_byte : node.end_byte].decode("utf-8", "replace")
def_node = enclosing_def(node)
origin = "<module>"
if def_node is not None:
q = qualified(def_node, name_keys)
if q is not None:
origin = q
calls.append((origin, cname))
return symbols
def default_sources():
# A handful of large real stdlib modules — mirrors indexing a whole repo
# (many files, one process), which is where this bites in the wild.
import argparse, _pydecimal, pydoc, inspect, typing
return [Path(m.__file__) for m in (argparse, _pydecimal, pydoc, inspect, typing)]
def main():
if len(sys.argv) > 1:
paths = [Path(p) for p in sys.argv[1:]]
else:
paths = default_sources()
n = 0
for sweep in range(5):
for path in paths:
raw = path.read_bytes()
tree = Parser(LANG).parse(raw)
syms = walk(tree.root_node, raw)
n += 1
print(f"parse {n}: {path.name} -> {len(syms)} symbols", file=sys.stderr)
print("DONE (no crash)", file=sys.stderr)
if __name__ == "__main__":
main()
Crash output (0.26.0)
parse 4: inspect.py -> ...
Fatal Python error: Segmentation fault
Current thread (most recent call first):
File "repro.py", line 138 in walk # p.start_byte / p.end_byte on a capture node
File "repro.py", line 206 in main
The Python frame drifts run-to-run — the crash lands wherever the traversal next dereferences a node (key()/capture-key build, def_name_node's .children scan, signature's child_by_field_name, etc.), which is the signature of heap corruption from a use-after-free rather than one specific bad call. In a variant that accesses node members via getattr(node, "end_byte"), the SIGSEGV actually fires inside the following callable(val) — i.e. the returned bound object already has an invalid type pointer, before the attribute is ever called.
Analysis / working theory
A live Python Node (or one reached from it via .parent()/.children()) ends up referencing native tree memory that has already been freed, so a later .start_byte/.end_byte reads freed memory. It only manifests once node churn crosses some threshold — the traversal here does an O(n²)-ish re-ascent (each captured symbol/call walks .parent() to the root, re-reading children at each level), and only after several real files in the same process. Keeping the Parser/Tree alive for the whole traversal does not help, and using a fresh Language per file does not help, so it isn't premature collection of those Python objects — it looks like an internal lifetime bug in the 0.26.0 Node/Tree binding.
Impact
Any tool that indexes real repositories (symbol graphs, code navigation, blast-radius analysis) parses many medium/large files per process and does exactly this kind of ancestor re-walk, so 0.26.0 hard-crashes in production. Pinning tree-sitter>=0.25,<0.26 is a complete workaround.
Happy to test patches or provide core dumps / additional traces.
Summary
tree-sitter0.26.0 segfaults (SIGSEGV, exit 139) under a normal symbol-indexing / code-navigation traversal: run a query, hold the capture nodes, then for each captured definition/call ascend.parent()to the enclosing definition and re-walk the ancestor chain (.children,.parent,.start_byte,.end_byte,.child_by_field_name,.start_point). When this is repeated across several real, medium-to-large modules in one process, aNodestarts reading freed native memory and CPython crashes on a plain.start_byte/.end_byteaccess.The exact same workload runs clean indefinitely on 0.25.0 / 0.25.1 / 0.25.2 (identical
tree-sitter-pythonwheel), so this is a regression introduced in 0.26.0.Environment
gc.disable()does not prevent it. Reproduces identically on 3.12 and 3.14, so it is not a Python-ABI mismatch.Reproducer
Self-contained; depends only on
tree-sitter+tree-sitter-python, and parses a handful of stdlib modules (argparse/_pydecimal/pydoc/inspect/typing) so nothing extra is needed. Small or synthetic sources never accumulate enough node churn to trip it — several real modules do.I see it crash on 8/8 runs on 0.26.0 and 0/N on 0.25.2.
repro.py (~210 lines)
Crash output (0.26.0)
The Python frame drifts run-to-run — the crash lands wherever the traversal next dereferences a node (
key()/capture-key build,def_name_node's.childrenscan,signature'schild_by_field_name, etc.), which is the signature of heap corruption from a use-after-free rather than one specific bad call. In a variant that accesses node members viagetattr(node, "end_byte"), the SIGSEGV actually fires inside the followingcallable(val)— i.e. the returned bound object already has an invalid type pointer, before the attribute is ever called.Analysis / working theory
A live Python
Node(or one reached from it via.parent()/.children()) ends up referencing native tree memory that has already been freed, so a later.start_byte/.end_bytereads freed memory. It only manifests once node churn crosses some threshold — the traversal here does an O(n²)-ish re-ascent (each captured symbol/call walks.parent()to the root, re-reading children at each level), and only after several real files in the same process. Keeping theParser/Treealive for the whole traversal does not help, and using a freshLanguageper file does not help, so it isn't premature collection of those Python objects — it looks like an internal lifetime bug in the 0.26.0Node/Treebinding.Impact
Any tool that indexes real repositories (symbol graphs, code navigation, blast-radius analysis) parses many medium/large files per process and does exactly this kind of ancestor re-walk, so 0.26.0 hard-crashes in production. Pinning
tree-sitter>=0.25,<0.26is a complete workaround.Happy to test patches or provide core dumps / additional traces.