Skip to content

Commit f128e41

Browse files
Delete tree cursors (#3378)
1 parent 02e191f commit f128e41

6 files changed

Lines changed: 176 additions & 35 deletions

File tree

‎packages/lib-engine/src/actions/ShowParseTree.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ function parseTree(
4343
const resultPlayground: string[] = [];
4444
const resultQuery: string[] = [];
4545

46-
parseCursor(resultPlayground, resultQuery, contentRange, tree.walk(), 0);
46+
const cursor = tree.walk();
47+
try {
48+
parseCursor(resultPlayground, resultQuery, contentRange, cursor, 0);
49+
} finally {
50+
cursor.delete();
51+
}
4752

4853
return [
4954
`## ${document.filename} [${contentRange}]\n`,

‎packages/lib-engine/src/languages/TreeSitterQuery/getChildNodesForFieldName.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ export function getChildNodesForFieldName(
66
): Node[] {
77
const nodes = [];
88
const treeCursor = node.walk();
9-
let hasNext = treeCursor.gotoFirstChild();
9+
try {
10+
let hasNext = treeCursor.gotoFirstChild();
1011

11-
while (hasNext) {
12-
if (treeCursor.currentFieldName === fieldName) {
13-
nodes.push(treeCursor.currentNode);
12+
while (hasNext) {
13+
if (treeCursor.currentFieldName === fieldName) {
14+
nodes.push(treeCursor.currentNode);
15+
}
16+
hasNext = treeCursor.gotoNextSibling();
1417
}
15-
hasNext = treeCursor.gotoNextSibling();
16-
}
1718

18-
return nodes;
19+
return nodes;
20+
} finally {
21+
treeCursor.delete();
22+
}
1923
}

‎packages/lib-engine/src/languages/TreeSitterQuery/isEven.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,22 @@ export function isEven(node: Node, fieldName: string): boolean {
1313
}
1414

1515
const treeCursor = node.parent.walk();
16-
let hasNext = treeCursor.gotoFirstChild();
17-
let even = true;
16+
try {
17+
let hasNext = treeCursor.gotoFirstChild();
18+
let even = true;
1819

19-
while (hasNext) {
20-
if (treeCursor.currentFieldName === fieldName) {
21-
if (treeCursor.currentNode.id === node.id) {
22-
return even;
20+
while (hasNext) {
21+
if (treeCursor.currentFieldName === fieldName) {
22+
if (treeCursor.currentNode.id === node.id) {
23+
return even;
24+
}
25+
even = !even;
2326
}
24-
even = !even;
27+
hasNext = treeCursor.gotoNextSibling();
2528
}
26-
hasNext = treeCursor.gotoNextSibling();
27-
}
2829

29-
throw new Error(`Node not found in parent for field: ${fieldName}`);
30+
throw new Error(`Node not found in parent for field: ${fieldName}`);
31+
} finally {
32+
treeCursor.delete();
33+
}
3034
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import assert from "node:assert/strict";
2+
import { mock } from "node:test";
3+
import type { Tree, TreeCursor } from "web-tree-sitter";
4+
import { Node } from "web-tree-sitter";
5+
import { FakeIDE, InMemoryTextEditor } from "@cursorless/lib-common";
6+
import { getChildNodesForFieldName } from "../languages/TreeSitterQuery/getChildNodesForFieldName";
7+
import { isEven } from "../languages/TreeSitterQuery/isEven";
8+
import { TestTreeSitter } from "../testUtil/TestTreeSitter";
9+
import { getChildNodesForFieldName as getChildren } from "./treeSitterUtils";
10+
11+
suite("Tree-sitter cursor lifetime", () => {
12+
const treeSitter = new TestTreeSitter();
13+
let tree: Tree;
14+
const deletions: {
15+
cursor: TreeCursor;
16+
deletion: ReturnType<typeof mock.method>;
17+
}[] = [];
18+
19+
suiteSetup(async () => {
20+
await treeSitter.loadLanguage("clojure");
21+
});
22+
23+
setup(() => {
24+
const { document } = new InMemoryTextEditor({
25+
ide: new FakeIDE(),
26+
content: "{:a 1 :b 2}",
27+
languageId: "clojure",
28+
});
29+
tree = treeSitter.getTree(document);
30+
const walk = Node.prototype.walk;
31+
mock.method(Node.prototype, "walk", function (this: Node) {
32+
const cursor = walk.call(this);
33+
deletions.push({ cursor, deletion: mock.method(cursor, "delete") });
34+
return cursor;
35+
});
36+
});
37+
38+
teardown(() => {
39+
try {
40+
assert.ok(deletions.length > 0, "Expected traversal to create a cursor");
41+
for (const { deletion } of deletions) {
42+
assert.equal(deletion.mock.callCount(), 1);
43+
}
44+
} finally {
45+
// Also clean up cursors if a regression leaves them unfreed. Their WASM
46+
// finalizers can crash the process, obscuring the assertion failure.
47+
for (const { cursor, deletion } of deletions) {
48+
if (deletion.mock.callCount() === 0) {
49+
cursor.delete();
50+
}
51+
}
52+
deletions.length = 0;
53+
mock.restoreAll();
54+
tree.delete();
55+
}
56+
});
57+
58+
test("releases the cursor on an early return from isEven", () => {
59+
const node = tree.rootNode.descendantsOfType("kwd_lit")[0];
60+
assert.equal(isEven(node, "value"), true);
61+
});
62+
63+
test("releases the cursor when isEven throws for a missing field", () => {
64+
const node = tree.rootNode.descendantsOfType("kwd_lit")[0];
65+
assert.throws(() => isEven(node, "missing"), /Node not found in parent/u);
66+
});
67+
68+
for (const [name, getNodes] of [
69+
["query", getChildNodesForFieldName],
70+
["utility", getChildren],
71+
] as const) {
72+
test(`${name} traversal releases the cursor after collecting children`, () => {
73+
const node = tree.rootNode.descendantsOfType("map_lit")[0];
74+
assert.deepEqual(
75+
getNodes(node, "value").map((child) => child.text),
76+
[":a", "1", ":b", "2"],
77+
);
78+
});
79+
80+
test(`${name} traversal releases the cursor for a leaf`, () => {
81+
const node = tree.rootNode.descendantsOfType("num_lit")[0];
82+
assert.deepEqual(getNodes(node, "value"), []);
83+
});
84+
}
85+
});

‎packages/lib-engine/src/util/treeSitterUtils.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,25 @@ export function getChildNodesForFieldName(
1919
fieldName: string,
2020
): Node[] {
2121
const treeCursor = node.walk();
22-
treeCursor.gotoFirstChild();
22+
try {
23+
treeCursor.gotoFirstChild();
2324

24-
const ret = [];
25+
const ret = [];
2526

26-
let hasNext = true;
27+
let hasNext = true;
2728

28-
while (hasNext) {
29-
if (treeCursor.currentFieldName === fieldName) {
30-
ret.push(treeCursor.currentNode);
29+
while (hasNext) {
30+
if (treeCursor.currentFieldName === fieldName) {
31+
ret.push(treeCursor.currentNode);
32+
}
33+
34+
hasNext = treeCursor.gotoNextSibling();
3135
}
3236

33-
hasNext = treeCursor.gotoNextSibling();
37+
return ret;
38+
} finally {
39+
treeCursor.delete();
3440
}
35-
36-
return ret;
3741
}
3842

3943
/**

‎packages/test-vscode-e2e/src/suite/recorded.vscode.test.ts‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {
1111
} from "@cursorless/lib-node-common";
1212
import {
1313
getReusableEditor,
14+
getParseTreeApi,
1415
getTestHelpers,
16+
resetReusableEditor,
1517
runCursorlessCommand,
1618
} from "@cursorless/lib-vscode-common";
1719
import { endToEndTestSetup, sleepWithBackoff } from "../endToEndTestSetup";
@@ -36,19 +38,56 @@ suite("recorded test cases", function () {
3638
this.ctx.skip();
3739
}
3840

39-
await runRecordedTest({
40-
path,
41-
spyIde: getSpy(),
42-
openNewTestEditor,
43-
sleepWithBackoff,
44-
testHelpers: await getTestHelpers(),
45-
runCursorlessCommand,
46-
});
41+
try {
42+
await runRecordedTest({
43+
path,
44+
spyIde: getSpy(),
45+
openNewTestEditor,
46+
sleepWithBackoff,
47+
testHelpers: await getTestHelpers(),
48+
runCursorlessCommand,
49+
});
50+
} catch (error) {
51+
// A retry must not inherit the failed attempt's selections or
52+
// incrementally updated parse tree.
53+
resetReusableEditor();
54+
await logFailureContext(name);
55+
throw error;
56+
}
4757
}),
4858
);
4959
}
5060
});
5161

62+
async function logFailureContext(name: string): Promise<void> {
63+
const editor = vscode.window.activeTextEditor;
64+
if (editor == null) {
65+
console.error(`Recorded test failed: ${name}; no active editor`);
66+
return;
67+
}
68+
69+
const { document, selections } = editor;
70+
console.error(`Recorded test failed: ${name}`, {
71+
uri: document.uri.toString(),
72+
languageId: document.languageId,
73+
version: document.version,
74+
content: document.getText(),
75+
selections: selections.map(({ anchor, active }) => ({ anchor, active })),
76+
});
77+
78+
try {
79+
const parseTree = await getParseTreeApi();
80+
console.error(
81+
"Parse tree:",
82+
parseTree.getTreeForUri(document.uri).rootNode.toString(),
83+
);
84+
} catch (error) {
85+
// Diagnostics should preserve the original test failure, including for
86+
// languages without Tree-sitter support.
87+
console.error("Could not read parse tree:", error);
88+
}
89+
}
90+
5291
async function openNewTestEditor(
5392
content: string,
5493
languageId: string,

0 commit comments

Comments
 (0)