Files
thor/GRAMMAR_CACHE_THREADING.md
T

172 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
- **`Grammar`** holds only the immutable `language` + `query`. It lives in a
process-lifetime **`registry`** (`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 transient `context.allocator`.
- Parsers/cursors are **caller-owned**: `open_parser(g)` mints one, the caller
`defer`s `parser_delete` / `query_cursor_delete`. No parser is shared, so
**#1b** is gone. `open_parser` is 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_grammars` is now a serial registry warm-up. The minify
tests dropped their `parse_mu` serialization 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.allocator` is 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.allocator` after `main.odin` swaps 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 finished `Grammar_Cache` under
`cache_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)
and `markdown/highlight.odin` — runs inside `main`'s single-threaded render
loop. No concurrent `ensure_parser` / `parser_parse_string`.
- The one threaded path in production, `preload_grammars`, is already safe (own
parser per worker + `cache_mutex` on the write) and only handles non-builtin
grammars.
- Only `minify_test.odin` touches 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()` (or `os.heap_allocator()`)
instead of `context.allocator`. Makes the cache persistent from any context;
behavior-identical in production. Optionally make `init_persistent` idempotent
and/or an `@(init)` so callers need no ordering.
Then, for the races:
- **Option A — library mutex (sustainable):** take `cache_mutex` in
`ensure_parser` (fixes #1a) and add a locking `parse` helper that wraps
`parser_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 the `minify_*` 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 on `Grammar_Cache`, `cache_mutex`,
`init_persistent`, `ensure_parser` reference this file.
- Original feature that surfaced this: minify inline `<style>` bodies via
`minify_css` (reverted; to be re-landed alongside whichever fix we choose).