8.4 KiB
Grammar cache thread-safety & allocator lifetime
Status: resolved. All three issues below are fixed. The cache was redesigned so the library caches only the immutable, shareable objects and callers own the per-thread ones. The rest of this document is kept as the rationale.
Resolution (what shipped)
The Grammar_Cache (which mixed shareable language/query with per-thread
parser/cursor) was split:
Grammarholds only the immutablelanguage+query. It lives in a process-lifetimeregistry(map[string]^Grammar) guarded by one mutex.grammar(lang)is the thread-safe, self-initializing, lazy loader — fixes #1a (all map access under the lock) and lets tests run with no setup.- The registry allocator is pinned with
os.heap_allocator()— fixes #2; the cache no longer captures a transientcontext.allocator. - Parsers/cursors are caller-owned:
open_parser(g)mints one, the callerdefersparser_delete/query_cursor_delete. No parser is shared, so #1b is gone.open_parseris the seam where a future pool / thread-local strategy plugs in without touching call sites. - Deleted:
ensure_parser,load_grammar,preload_grammar,grammar_worker,cache_mutex;preload_grammarsis now a serial registry warm-up. The minify tests dropped theirparse_muserialization and preload scaffolding entirely.
The later decision — pool vs per-thread parser reuse for genuine parallel
parsing — is still open, but intentionally so: it only matters once we parse in
parallel, and it now changes only open_parser, not the rest of the library.
The original investigation follows.
How we got here
While adding a feature to minify inline <style> blocks (route the CSS body
through minify_css), the new unit tests exercised minify_html / minify_css
for the first time. Those are the first tests to touch the tree-sitter parser
cache, and under the multi-threaded test runner (default 16 threads) they failed
intermittently with a rotating cast of symptoms:
panic: unable to insert into a map- tree-sitter internal aborts:
ts_subtree_retain: Assertion 'self.ptr->ref_count > 0' failed,_array__erase: Assertion 'index < *size' failed malloc(): unaligned tcache chunk detected- assertions passing on the wrong data (e.g. minified output missing
<style>)
The flakiness comes from three distinct problems, two thread-safety and one allocator-lifetime. They are orthogonal — fixing one does not fix the others.
The three issues
#1a — concurrent cache writes (data race)
ensure_parser (treesitter.odin) reads and writes grammar_store.cache
with no lock. cache_mutex exists but is only taken on the parallel preload
path (grammar_worker), never in the lazy ensure_parser / load_grammar
paths. Two threads first-touching "html"/"css" at once corrupt the map.
#1b — concurrent parser use (data race)
There is exactly one cached Grammar_Cache (and one parser) per language.
When two threads both call minify_html, they fetch the same gc.parser and
call parser_parse_string on it simultaneously. A tree-sitter TSParser
mutates its internal subtree pool during a parse, so concurrent parses corrupt
it — that's the ts_subtree_retain / _array__erase aborts.
This is not fixed by preloading/"loading html+css statically." Loading gives you one shared parser; the race is about using that one parser from two threads. It is fixed only by serializing the parse or giving each thread its own parser.
#2 — allocator lifetime (not a race)
init_persistent captures context.allocator into the process-lifetime
grammar_store. In main that happens to be the heap (it runs before the site
arena is installed at main.odin), so production is fine. But whatever
allocator is live at that instant becomes the cache's owner forever:
- Under the test runner,
context.allocatoris a per-test tracking allocator that is torn down when that test ends → the global cache is left pointing at dead memory (→unable to insert into a map, garbage reads). - A lazy first call during rendering would capture the site arena
(
context.allocatoraftermain.odinswaps it in), which is destroyed every watch-mode rebuild → dangling parsers across builds.
This would break even single-threaded; threading just decides which test wins the init race.
tree-sitter's threading model (the root fact)
tree-sitter objects have specific, documented threading rules:
| Object | Shareable across threads? | Rule |
|---|---|---|
TSLanguage (tree_sitter_html() etc.) |
Yes | Immutable; share freely. |
TSParser (gc.parser) |
No | One per thread; never parse on one parser from two threads. |
TSTree |
Only via ts_tree_copy |
Copy before using on another thread. |
TSQuery (gc.query) |
Yes | Immutable after compile. |
TSQueryCursor (gc.cursor) |
No | One per thread, like the parser. |
This explains the existing design:
- The preload fan-out (
preload_grammar/grammar_worker) creates a parser per worker and only publishes the finishedGrammar_Cacheundercache_mutex— because parsers can't be shared during construction/use. ensure_parser+ the shared cached parser is fine in production only because rendering is single-threaded.
A cached Grammar_Cache mixes shareable (language, query) and per-thread
(parser, cursor) objects in one shared struct, which makes it
single-threaded-use by construction — a fact that was previously undocumented.
Where this actually bites
Only in tests. Confirmed by grep:
- Production parses on one thread: every real caller —
minify.odin(html/css) andmarkdown/highlight.odin— runs insidemain's single-threaded render loop. No concurrentensure_parser/parser_parse_string. - The one threaded path in production,
preload_grammars, is already safe (own parser per worker +cache_mutexon the write) and only handles non-builtin grammars. - Only
minify_test.odintouches tree-sitter from tests, and the test runner runs those on many threads.
So production is correct by convention (single-threaded-from-heap usage), not by construction — nothing stops a future parallel render from breaking it, and until now nothing documented the constraint.
Chosen direction
For now: keep the single global grammar cache and make it safe by serializing the non-thread-safe behavior with a mutex — only one thread uses a parser at a time. This is simple, matches how production already behaves, and unblocks the tests.
Later (TODO): decide whether true multi-threaded parsing is worth it for performance. If yes, give each thread its own parser + cursor (as the preload workers already do) rather than sharing one behind a lock.
Implementation options (deferred — pick when we resume)
All assume the allocator fix (#2) below, which is independent and unambiguous.
- #2 allocator (do regardless): pin the heap allocator explicitly —
grammar_store.allocator = runtime.heap_allocator()(oros.heap_allocator()) instead ofcontext.allocator. Makes the cache persistent from any context; behavior-identical in production. Optionally makeinit_persistentidempotent and/or an@(init)so callers need no ordering.
Then, for the races:
-
Option A — library mutex (sustainable): take
cache_mutexinensure_parser(fixes #1a) and add a lockingparsehelper that wrapsparser_parse_string(fixes #1b), wired into the three call sites (minify.odin×2,markdown/highlight.odin×1). Tests then need no scaffolding. Serializes all parsing. -
Option B — test-side only (minimal): leave the library single-threaded by contract; make the tests not violate it. Either fold the minify tests into one
@(test)(no concurrency at all → no locks), or wrap theminify_*calls in a test-local mutex. Production stays lock-free.
Recommendation captured during the discussion: the allocator one-liner is worth doing immediately; for the races, Option A is the "correct" long-term answer but only necessary if/when we want concurrent parsing — otherwise Option B is enough.
Pointers
treesitter/treesitter.odin: doc comments onGrammar_Cache,cache_mutex,init_persistent,ensure_parserreference this file.- Original feature that surfaced this: minify inline
<style>bodies viaminify_css(reverted; to be re-landed alongside whichever fix we choose).