Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/codegraphcontext/utils/tree_sitter_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,26 +319,53 @@ def create_parser(lang: str) -> Parser:
return get_tree_sitter_manager().create_parser(lang)


# Serialises query construction and execution across threads.
#
# `cgc index` runs parsers on a thread pool, and building a `Query` / running a
# `QueryCursor` concurrently on different languages crashes the native
# extension — a hard `Fatal Python error: Segmentation fault`, not a Python
# exception, so nothing above can catch or retry it (#1370). Reported
# tracebacks show three threads inside this function at once, on three
# different grammars.
#
# The lock is process-wide rather than per-language on purpose: the reports
# involve *different* languages crashing together, so a per-language lock would
# not have prevented them. Parsing itself (`parser.parse`) stays parallel; only
# the query step is serialised, and it is a small fraction of per-file work.
_QUERY_LOCK = threading.Lock()


def execute_query(language: Language, query_string: str, node):
"""
Execute a tree-sitter query and return captures in backward-compatible format.

This function provides compatibility with the old tree-sitter 0.20.x API where
you could call query.captures(node). The new 0.22+ API uses QueryCursor.


Thread-safe: query construction and execution are serialised (see
`_QUERY_LOCK`), because doing them concurrently segfaults the native
extension.

Args:
language: Tree-sitter Language object
query_string: Query string in tree-sitter query syntax
node: Tree-sitter Node to query

Returns:
List of (node, capture_name) tuples, compatible with old API
"""
try:
from tree_sitter import Query
except ImportError as e:
raise _missing_tree_sitter_error(e) from e


with _QUERY_LOCK:
return _execute_query_locked(Query, language, query_string, node)


def _execute_query_locked(Query, language: Language, query_string: str, node):
"""Body of `execute_query`; callers must hold `_QUERY_LOCK`."""

# 1. Create the Query object
try:
# New API (0.22+)
Expand Down
125 changes: 125 additions & 0 deletions tests/unit/utils/test_tree_sitter_query_concurrency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Query execution must be serialised across threads (#1370).

Building a `Query` / running a `QueryCursor` concurrently on different
languages crashes the native extension — a `Fatal Python error: Segmentation
fault`, not a Python exception, so nothing above can catch or retry it. The
reported traceback shows three threads inside `execute_query` at once, on
three different grammars.

These tests pin the guard rather than the crash: a segfault cannot be asserted
on from inside the process that takes it, and the race is timing-dependent
enough that a passing run proves nothing on its own.
"""

from __future__ import annotations

import concurrent.futures
import threading

from codegraphcontext.utils import tree_sitter_manager
from codegraphcontext.utils.tree_sitter_manager import execute_query


def test_a_process_wide_query_lock_exists():
"""Per-language would not be enough — the reports involve different
languages crashing together."""
assert hasattr(tree_sitter_manager, "_QUERY_LOCK")
assert isinstance(
tree_sitter_manager._QUERY_LOCK, type(threading.Lock())
), "expected a threading.Lock"


def test_execute_query_holds_the_lock_while_running():
"""The lock must cover query construction *and* execution, not just one."""
observed = []
real_body = tree_sitter_manager._execute_query_locked

def spy(*args, **kwargs):
observed.append(tree_sitter_manager._QUERY_LOCK.locked())
return real_body(*args, **kwargs)

tree_sitter_manager._execute_query_locked = spy
try:
from codegraphcontext.tools.tree_sitter_parser import TreeSitterParser

wrapper = TreeSitterParser("python")
tree = wrapper.parser.parse(b"def f():\n return 1\n")
execute_query(wrapper.language, "(function_definition) @fn", tree.root_node)
finally:
tree_sitter_manager._execute_query_locked = real_body

assert observed, "execute_query did not route through the locked body"
assert all(observed), "query body ran without the lock held"


def test_queries_never_overlap_under_concurrency():
"""No two threads may be inside the query body at the same time."""
from codegraphcontext.tools.tree_sitter_parser import TreeSitterParser

wrapper = TreeSitterParser("python")
tree = wrapper.parser.parse(b"def f(x):\n return x\n" * 20)

concurrent_peak = 0
inside = 0
counter_lock = threading.Lock()
real_body = tree_sitter_manager._execute_query_locked

def spy(*args, **kwargs):
nonlocal inside, concurrent_peak
with counter_lock:
inside += 1
concurrent_peak = max(concurrent_peak, inside)
try:
return real_body(*args, **kwargs)
finally:
with counter_lock:
inside -= 1

tree_sitter_manager._execute_query_locked = spy
try:
def run(_):
return execute_query(
wrapper.language, "(function_definition) @fn", tree.root_node
)

with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(run, range(64)))
finally:
tree_sitter_manager._execute_query_locked = real_body

assert len(results) == 64
assert all(r for r in results), "some queries returned nothing"
assert concurrent_peak == 1, (
f"{concurrent_peak} threads were inside the query body at once; "
"execution is not serialised"
)


def test_mixed_language_concurrency_completes():
"""The shape reported in #1370: several grammars queried at once."""
from codegraphcontext.tools.tree_sitter_parser import TreeSitterParser

langs = ["python", "javascript", "typescript"]
wrappers = {l: TreeSitterParser(l) for l in langs}
sources = {
"python": b"def f(x):\n return x\n" * 10,
"javascript": b"function g(a) { return a; }\n" * 10,
"typescript": b"export function h(a: string): string { return a; }\n" * 10,
}
queries = {
"python": "(function_definition) @fn",
"javascript": "(function_declaration) @fn",
"typescript": "(function_declaration) @fn",
}

def run(i):
lang = langs[i % len(langs)]
w = wrappers[lang]
tree = w.parser.parse(sources[lang])
return execute_query(w.language, queries[lang], tree.root_node)

with concurrent.futures.ThreadPoolExecutor(max_workers=12) as ex:
results = list(ex.map(run, range(120)))

assert len(results) == 120
assert all(len(r) > 0 for r in results)
Loading