Skip to content

Segfault (use-after-free) in 0.26.0 during repeated Node ancestor traversal across files (regression from 0.25.x) #472

Description

@blarghmatey

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions