Skip to content

Commit 0fd1865

Browse files
fix: concurrency races in WASM init + grammar loading, transaction safety
- ensureInit: promise-as-lock pattern prevents concurrent WASM double-init - getLanguage: promise-as-lock prevents duplicate grammar loading for same language - SqliteSyncStorage.transaction: protected ROLLBACK in catch to handle closed connections - persistMerkleState: removed unnecessary read-back of just-written hashes
1 parent 2e26e58 commit 0fd1865

2 files changed

Lines changed: 59 additions & 50 deletions

File tree

packages/core/src/chunker/ast.ts

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ const log = createLogger('chunker');
1212

1313
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1414
let ParserClass: any;
15-
let initialized = false;
15+
let initPromise: Promise<void> | null = null;
1616

1717
async function ensureInit(): Promise<void> {
18-
if (initialized) return;
19-
const mod = await import('web-tree-sitter');
20-
ParserClass = mod.default;
21-
await ParserClass.init();
22-
initialized = true;
18+
if (initPromise) return initPromise;
19+
initPromise = (async () => {
20+
const mod = await import('web-tree-sitter');
21+
ParserClass = mod.default;
22+
await ParserClass.init();
23+
})();
24+
return initPromise;
2325
}
2426

2527
// --- Grammar loading (WASM files from tree-sitter-wasms package) ---
@@ -39,18 +41,25 @@ const GRAMMAR_FILES: Record<string, string> = {
3941

4042
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4143
const languages = new Map<string, any>();
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
const languageLoading = new Map<string, Promise<any>>();
4246

4347
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4448
async function getLanguage(language: string): Promise<any | undefined> {
4549
if (languages.has(language)) return languages.get(language)!;
50+
if (languageLoading.has(language)) return languageLoading.get(language)!;
4651

4752
const file = GRAMMAR_FILES[language];
4853
if (!file) return undefined;
4954

5055
const wasmPath = resolve(wasmsDir, file);
51-
const lang = await ParserClass.Language.load(wasmPath);
52-
languages.set(language, lang);
53-
return lang;
56+
const p = ParserClass.Language.load(wasmPath).then((lang: unknown) => {
57+
languages.set(language, lang);
58+
languageLoading.delete(language);
59+
return lang;
60+
});
61+
languageLoading.set(language, p);
62+
return p;
5463
}
5564

5665
// --- Top-level node types to extract per language ---
@@ -100,43 +109,42 @@ async function chunkAST(source: string, filePath: string, language: string): Pro
100109
const parser = new ParserClass();
101110
parser.setLanguage(lang);
102111

112+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
113+
let tree: any;
103114
try {
104-
const tree = parser.parse(source);
105-
106-
try {
107-
const chunks: Chunk[] = [];
108-
109-
for (const node of tree.rootNode.children) {
110-
if (!nodeTypes.has(node.type)) continue;
111-
112-
const content = node.text.trim();
113-
if (content.length === 0) continue;
114-
115-
chunks.push({
116-
content,
117-
filePath,
118-
lineStart: node.startPosition.row + 1,
119-
lineEnd: node.endPosition.row + 1,
120-
language,
121-
type: 'ast',
122-
});
123-
}
124-
125-
if (chunks.length === 0 && source.trim().length > 0) {
126-
log.warn(`AST parsed but found 0 matching nodes for "${language}" in: ${filePath}`);
127-
return [fallbackChunk(source, filePath, language)];
128-
}
129-
130-
return chunks;
131-
} finally {
132-
tree.delete();
115+
tree = parser.parse(source);
116+
117+
const chunks: Chunk[] = [];
118+
119+
for (const node of tree.rootNode.children) {
120+
if (!nodeTypes.has(node.type)) continue;
121+
122+
const content = node.text.trim();
123+
if (content.length === 0) continue;
124+
125+
chunks.push({
126+
content,
127+
filePath,
128+
lineStart: node.startPosition.row + 1,
129+
lineEnd: node.endPosition.row + 1,
130+
language,
131+
type: 'ast',
132+
});
133+
}
134+
135+
if (chunks.length === 0 && source.trim().length > 0) {
136+
log.warn(`AST parsed but found 0 matching nodes for "${language}" in: ${filePath}`);
137+
return [fallbackChunk(source, filePath, language)];
133138
}
139+
140+
return chunks;
134141
} catch (err: unknown) {
135142
log.error(
136143
`tree-sitter parse failed for ${filePath}: ${err instanceof Error ? err.message : err}`,
137144
);
138145
return [fallbackChunk(source, filePath, language)];
139146
} finally {
147+
tree?.delete();
140148
parser.delete();
141149
}
142150
}

packages/core/src/lib/sync.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,18 @@ class SqliteSyncStorage implements SyncStorage {
8484
}
8585

8686
async transaction<T>(fn: () => Promise<T>): Promise<T> {
87-
// better-sqlite3 transactions are synchronous. Since SqliteSyncStorage
88-
// methods wrap sync calls in async, all awaits resolve in the same
89-
// microtask. We use manual BEGIN/COMMIT to support the async fn.
9087
const db = getDb();
9188
db.exec('BEGIN');
9289
try {
9390
const result = await fn();
9491
db.exec('COMMIT');
9592
return result;
9693
} catch (err) {
97-
db.exec('ROLLBACK');
94+
try {
95+
db.exec('ROLLBACK');
96+
} catch {
97+
// Connection may already be closed or rolled back
98+
}
9899
throw err;
99100
}
100101
}
@@ -267,9 +268,9 @@ async function computeChanges(
267268
const currentFileSet = new Set(files);
268269
const storedHashes = await storage.getAllFileHashes();
269270
const deleted: string[] = [];
270-
for (const [filePath] of storedHashes) {
271-
if (!currentFileSet.has(filePath)) {
272-
deleted.push(filePath);
271+
for (const storedPath of storedHashes.keys()) {
272+
if (!currentFileSet.has(storedPath)) {
273+
deleted.push(storedPath);
273274
}
274275
}
275276

@@ -300,13 +301,13 @@ async function persistMerkleState(
300301
}
301302
}
302303

303-
// Rebuild tree using only files with persisted hashes to avoid
304-
// caching dir hashes that include failed files
304+
// Rebuild tree using only successful files — no need to read back
305+
// from storage since we just wrote these exact hashes above.
305306
const persistedHashMap = new Map<string, string>();
306307
for (const file of files) {
307-
const stored = await storage.getFileHash(file);
308-
if (stored) {
309-
persistedHashMap.set(file, stored);
308+
if (successfulFiles.has(file)) {
309+
const hash = fileHashMap.get(file);
310+
if (hash) persistedHashMap.set(file, hash);
310311
}
311312
}
312313

0 commit comments

Comments
 (0)