-
-
Notifications
You must be signed in to change notification settings - Fork 58
ADFA-5401: Guard the shared tree-sitter query #1776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
99255a0
66f3742
eeb5636
98c2acb
61fa2ab
ae196e3
3a98482
059f691
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,53 +35,53 @@ internal val log = LoggerFactory.getLogger("TsUtilsKt") | |
| * This method does not close the [TSQueryCursor] instance. | ||
| */ | ||
| inline fun <ResultT> TSQueryCursor.safeExecQueryCursor( | ||
| query: TSQuery, | ||
| tree: TSTree?, | ||
| recycleNodeAfterUse: Boolean = true, | ||
| crossinline matchCondition: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline whileTrue: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline onClosedOrEdited: () -> Unit = {}, | ||
| debugName: String = "", | ||
| debugLogging: Boolean = false, | ||
| crossinline action: (TSQueryMatch) -> ResultT | ||
| query: TSQuery, | ||
| tree: TSTree?, | ||
| recycleNodeAfterUse: Boolean = true, | ||
| crossinline matchCondition: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline whileTrue: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline onClosedOrEdited: () -> Unit = {}, | ||
| debugName: String = "", | ||
| debugLogging: Boolean = false, | ||
| crossinline action: (TSQueryMatch) -> ResultT | ||
| ): ResultT? { | ||
|
|
||
| if (tree == null || !tree.canAccess()) { | ||
| if (debugLogging) { | ||
| log.debug("$debugName: Cannot execute query, tree is null or not accessible", "tree=$tree", | ||
| "tree.canAccess=${tree?.canAccess()}") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| val rootNode = tree.rootNode | ||
| if (!rootNode.canAccess() || rootNode.hasChanges()) { | ||
| if (debugLogging) { | ||
| log.debug( | ||
| "$debugName, Cannot execute query, tree's root node is not accessible or has been edited", | ||
| "rootNode=$rootNode", "rootNode.canAccess=${rootNode.canAccess()}", | ||
| "rootNode.hasChanges=${rootNode.canAccess() && rootNode.hasChanges()}") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| return safeExecQueryCursor( | ||
| query = query, | ||
| node = rootNode, | ||
| recycleNodeAfterUse = recycleNodeAfterUse, | ||
| matchCondition = { | ||
| val result = tree.canAccess() && matchCondition(it) | ||
| if (!result && debugLogging) { | ||
| log.debug("$debugName: tree.canAccess=${tree.canAccess()}") | ||
| } | ||
| result | ||
| }, | ||
| whileTrue = whileTrue, | ||
| onClosedOrEdited = onClosedOrEdited, | ||
| debugName = debugName, | ||
| debugLogging = debugLogging, | ||
| action = action | ||
| ) | ||
| if (tree == null || !tree.canAccess()) { | ||
| if (debugLogging) { | ||
| log.debug("$debugName: Cannot execute query, tree is null or not accessible", "tree=$tree", | ||
| "tree.canAccess=${tree?.canAccess()}") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| val rootNode = tree.rootNode | ||
| if (!rootNode.canAccess() || rootNode.hasChanges()) { | ||
| if (debugLogging) { | ||
| log.debug( | ||
| "$debugName, Cannot execute query, tree's root node is not accessible or has been edited", | ||
| "rootNode=$rootNode", "rootNode.canAccess=${rootNode.canAccess()}", | ||
| "rootNode.hasChanges=${rootNode.canAccess() && rootNode.hasChanges()}") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| return safeExecQueryCursor( | ||
| query = query, | ||
| node = rootNode, | ||
| recycleNodeAfterUse = recycleNodeAfterUse, | ||
| matchCondition = { | ||
| val result = tree.canAccess() && matchCondition(it) | ||
| if (!result && debugLogging) { | ||
| log.debug("$debugName: tree.canAccess=${tree.canAccess()}") | ||
| } | ||
| result | ||
| }, | ||
| whileTrue = whileTrue, | ||
| onClosedOrEdited = onClosedOrEdited, | ||
| debugName = debugName, | ||
| debugLogging = debugLogging, | ||
| action = action | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -92,95 +92,99 @@ inline fun <ResultT> TSQueryCursor.safeExecQueryCursor( | |
| * This method does not close the [TSQueryCursor] instance. | ||
| */ | ||
| inline fun <ResultT> TSQueryCursor.safeExecQueryCursor( | ||
| query: TSQuery, | ||
| node: TSNode, | ||
| recycleNodeAfterUse: Boolean = true, | ||
| crossinline matchCondition: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline whileTrue: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline onClosedOrEdited: () -> Unit = {}, | ||
| debugName: String = "", | ||
| debugLogging: Boolean = false, | ||
| crossinline action: (TSQueryMatch) -> ResultT | ||
| query: TSQuery, | ||
| node: TSNode, | ||
| recycleNodeAfterUse: Boolean = true, | ||
| crossinline matchCondition: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline whileTrue: (TSQueryMatch?) -> Boolean = { true }, | ||
| crossinline onClosedOrEdited: () -> Unit = {}, | ||
| debugName: String = "", | ||
| debugLogging: Boolean = false, | ||
| crossinline action: (TSQueryMatch) -> ResultT | ||
| ): ResultT? { | ||
|
|
||
| return doSafeExecQueryCursor( | ||
| query = query, | ||
| node = node, | ||
| recycleNodeAfterUse = recycleNodeAfterUse, | ||
| matchCondition = { match -> | ||
| match != null && canAccess() && node.canAccess() && !node.hasChanges() && matchCondition( | ||
| match) | ||
| }, | ||
| whileTrue = whileTrue, | ||
| onClosedOrEdited = onClosedOrEdited, | ||
| debugName = debugName, | ||
| debugLogging = debugLogging, | ||
| action = action) | ||
| return doSafeExecQueryCursor( | ||
| query = query, | ||
| node = node, | ||
| recycleNodeAfterUse = recycleNodeAfterUse, | ||
| matchCondition = { match -> | ||
| // query.canAccess() belongs here, not only in the pre-loop check below: the query is shared | ||
| // between the analyzer, the span generator and the bracket matcher, and whoever frees it does | ||
| // so on another thread. Without it, a free landing mid-loop makes the next nextMatch() | ||
| // dereference a dangling TSQuery and take the process down (ADFA-5401). | ||
| match != null && canAccess() && query.canAccess() && node.canAccess() && !node.hasChanges() && | ||
| matchCondition(match) | ||
| }, | ||
| whileTrue = whileTrue, | ||
| onClosedOrEdited = onClosedOrEdited, | ||
| debugName = debugName, | ||
| debugLogging = debugLogging, | ||
| action = action) | ||
| } | ||
|
|
||
| @PublishedApi | ||
| internal inline fun <ResultT> TSQueryCursor.doSafeExecQueryCursor( | ||
| query: TSQuery, | ||
| node: TSNode, | ||
| recycleNodeAfterUse: Boolean = true, | ||
| crossinline matchCondition: (TSQueryMatch?) -> Boolean, | ||
| crossinline whileTrue: (TSQueryMatch?) -> Boolean, | ||
| crossinline onClosedOrEdited: () -> Unit, | ||
| debugName: String = "", | ||
| debugLogging: Boolean = false, | ||
| crossinline action: (TSQueryMatch) -> ResultT | ||
| query: TSQuery, | ||
| node: TSNode, | ||
| recycleNodeAfterUse: Boolean = true, | ||
| crossinline matchCondition: (TSQueryMatch?) -> Boolean, | ||
| crossinline whileTrue: (TSQueryMatch?) -> Boolean, | ||
| crossinline onClosedOrEdited: () -> Unit, | ||
| debugName: String = "", | ||
| debugLogging: Boolean = false, | ||
| crossinline action: (TSQueryMatch) -> ResultT | ||
| ): ResultT? { | ||
|
|
||
| if (!query.canAccess()) { | ||
| if (debugLogging) { | ||
| log.debug("$debugName: Cannot execute query, query is not accessible") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| if (!node.canAccess() || node.hasChanges()) { | ||
| if (debugLogging) { | ||
| log.debug("$debugName: Cannot execute query, node is not accessible or has been edited", | ||
| "node.canAccess=${node.canAccess()}", | ||
| "node.hasChanges=${node.canAccess() && node.hasChanges()}") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| exec(query, node) | ||
| var match = nextMatch() | ||
| while (matchCondition(match) && whileTrue(match)) { | ||
|
|
||
| val result = action(match) | ||
|
|
||
| if (!matchCondition(match)) { | ||
| if (debugLogging) { | ||
| log.debug( | ||
| "$debugName: Cannot proceed with query operation.", | ||
| "cursor.canAccess=${canAccess()}", | ||
| "query.canAccess=${query.canAccess()}", | ||
| "node.canAccess=${node.canAccess()}", | ||
| "node.hasChanges=${node.canAccess() && node.hasErrors()}" | ||
| ) | ||
| } | ||
| onClosedOrEdited() | ||
| break | ||
| } | ||
|
|
||
| (match as? TreeSitterQueryMatch?)?.recycle() | ||
|
|
||
| // if the action does not produce any output and simply returns Unit (void) | ||
| // then ignore the result and continue with the capture | ||
| if (result != Unit && result != null) { | ||
| return result | ||
| } | ||
|
|
||
| match = nextMatch() | ||
| } | ||
|
|
||
| if (recycleNodeAfterUse && node is TreeSitterNode && !node.isRecycled) { | ||
| node.recycle() | ||
| } | ||
|
|
||
| return null | ||
| } | ||
| if (!query.canAccess()) { | ||
| if (debugLogging) { | ||
| log.debug("$debugName: Cannot execute query, query is not accessible") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| if (!node.canAccess() || node.hasChanges()) { | ||
| if (debugLogging) { | ||
| log.debug("$debugName: Cannot execute query, node is not accessible or has been edited", | ||
| "node.canAccess=${node.canAccess()}", | ||
| "node.hasChanges=${node.canAccess() && node.hasChanges()}") | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| exec(query, node) | ||
| var match = nextMatch() | ||
| while (matchCondition(match) && whileTrue(match)) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW — the new guard makes this top-of-loop exit reachable, and that exit publishes partial results. When Consequence: Filed as ADFA-5414, but it is cheap to close here: make the top-of-loop exit go through |
||
|
|
||
| val result = action(match) | ||
|
|
||
| if (!matchCondition(match)) { | ||
| if (debugLogging) { | ||
| log.debug( | ||
| "$debugName: Cannot proceed with query operation.", | ||
| "cursor.canAccess=${canAccess()}", | ||
| "query.canAccess=${query.canAccess()}", | ||
| "node.canAccess=${node.canAccess()}", | ||
| "node.hasChanges=${node.canAccess() && node.hasErrors()}" | ||
| ) | ||
| } | ||
| onClosedOrEdited() | ||
| break | ||
| } | ||
|
|
||
| (match as? TreeSitterQueryMatch?)?.recycle() | ||
|
|
||
| // if the action does not produce any output and simply returns Unit (void) | ||
| // then ignore the result and continue with the capture | ||
| if (result != Unit && result != null) { | ||
| return result | ||
| } | ||
|
|
||
| match = nextMatch() | ||
| } | ||
|
|
||
| if (recycleNodeAfterUse && node is TreeSitterNode && !node.isRecycled) { | ||
| node.recycle() | ||
| } | ||
|
|
||
| return null | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 23794
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 23346
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 40012
🌐 Web query:
"com.itsaky.androidide.treesitter" TSQuery canAccess nextMatch💡 Result:
In the
android-tree-sitterlibrary (com.itsaky.androidide.treesitter),canAccess()andnextMatch()are methods associated with safe execution and query traversal [1][2]. 1.canAccess(): This method is used to verify the accessibility or validity of a native object (such as aTSQuery,TSNode, or similar structure) before interaction [1][2]. It ensures that the underlying native pointer is not null and that the object is in a state where it can be safely accessed, often preventing native crashes [2]. 2.nextMatch(): Found inTSQueryCursor, this method retrieves the next match for a query [1]. In updated versions of the library, the implementation includes internal checks—specificallycheckAccess()andcheckExecuted("nextMatch")—to ensure the cursor is properly initialized and valid before calling the nativenextMatchfunction [1]. These checks were part of stability improvements added to the library (which is now archived and unmaintained) to handle native memory management and state validation more strictly [2][3][4]. If you are using this library, ensure you are interacting with these objects within their lifecycle to avoidIllegalStateExceptionor similar errors triggered by these checks [1].Citations:
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 17569
🌐 Web query:
site:github.com/AndroidIDEOfficial/android-tree-sitter "class TSQueryCursor" "nextMatch"💡 Result:
The TSQueryCursor.nextMatch method is a function found in Tree-sitter language bindings, specifically observed in the android-tree-sitter library [1][2]. It is used to iterate through matches generated by a query against a syntax tree [1]. When called, nextMatch retrieves the next available match from the query cursor [1][2]. In implementations like the Android Tree-sitter library, this method is typically expected to be called after executing a query via an exec method [1][2]. Recent implementations of this method often include logic to verify the execution status of the query [3][4] and, where applicable, process predicates associated with the matched pattern [1][2]. When no further matches are available, the method returns null [1][2].
Citations:
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 211
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 1052
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 760
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 420
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 16801
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 12726
Protect the
TSQuerylifetime during cursor traversal.doSafeExecQueryCursor()checksquery.canAccess()only beforeexec().TSQueryCursor.nextMatch()checks the cursor, not its executedTSQuery, before calling native code. A concurrentTSQuery.close()can delete the query after the check and leave the cursor with a dangling native query, which can causeSIGSEGVat eithernextMatch()call. Synchronize query closure withexec()and allnextMatch()calls, or guarantee query ownership for the full traversal. Keep the predicate guard as a secondary check and add a regression test.🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, and the PR body says the same thing in weaker words — I'll take the sharper framing.
query.canAccess()in the per-iteration condition is a secondary check, not a lifetime guarantee: it is still check-then-use, and aclose()landing between the check andnextMatch()still dereferences a dangling query.Keeping it, because it is strictly better than what was there (the query was checked once, before the loop, and never again) and it costs nothing. But it does not close the window, and I would rather not have the PR imply otherwise. Filing a separate ticket for real ownership of the shared
TSQuery— ordering the spec close behind its users, or refcounting — since that spansTsLanguageSpec, the generator's executor and the main thread, and is a bigger change than this ticket.On the regression test:
editor-treesitterhas no test source set, and adding one means new test dependencies in vendored sora-editor code. I have not found a way to pin this that does not amount to a race-timing test, and I would rather say that than ship one that passes regardless.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@davidschachterADFA, agreed. The updated framing is accurate.query.canAccess()improves the current behavior, but it does not protect theTSQuerylifetime between the check andnextMatch(). A full fix needs explicit ownership or shutdown ordering acrossTsLanguageSpec, the generator executor, and the main thread.I also agree that a timing-dependent test would not provide reliable regression coverage. The missing test source set makes a deterministic test disproportionate for this PR.
Please link the follow-up ticket here after you create it. 🐇
✏️ Learnings added
🧠 Learnings used
You are interacting with an AI system.