Skip to content

feat(chunker): WASM/wazero tree-sitter backend — replaces gotreesitter - #81

Merged
dvcdsys merged 6 commits into
developfrom
feat/chunker-wasm-treesitter
Jun 22, 2026
Merged

feat(chunker): WASM/wazero tree-sitter backend — replaces gotreesitter#81
dvcdsys merged 6 commits into
developfrom
feat/chunker-wasm-treesitter

Conversation

@dvcdsys

@dvcdsys dvcdsys commented Jun 7, 2026

Copy link
Copy Markdown
Owner

What

Replace the pure-Go gotreesitter chunker backend with the official tree-sitter
C runtime + 31 grammars
, compiled to a single wasm32-wasi module
(ts-core.wasm.br, brotli ~3 MB) and driven from Go via wazero. No cgo — the
binary stays CGO_ENABLED=0 and the CPU image stays distroless/static.

Also bundles the supporting server/indexer fixes made on this branch:

  • dashboard-tunable chunker concurrency + llama --cache-ram (host prompt-cache OOM fix)
  • batched big-project wipes to stop the SQLITE_BUSY writer-starvation storm

Why

The bugs that drove this migration are still open upstream (re-verified 2026-06-22,
odvcencio/gotreesitter), so reverting to gotreesitter is not viable:

Latest upstream release is v0.20.2 (2026-06-06); post-release commits are only
swift/csharp/go recovery fixes — none touch the three issues above.

WASM-via-wazero gives us: correctness (0 ERROR trees on the corpora that broke
gotreesitter), crash-isolation (a guest trap → recoverable Go error, host
stays alive, where cgo would SIGSEGV the whole server — unacceptable with ~85
projects on one box), CGO_ENABLED=0 builds, and a bounded memory profile.

This supersedes the cgo backend (PR #80), which is abandoned.

How

  • One module, 31 grammars compiled by tswasm/build.sh (official tree-sitter
    amalgamation, wasm32-wasi, no TREE_SITTER_FEATURE_WASM so no stubs). Driven
    through wazero with a batched ts_dump_tree export (flat NodeRec[], one
    Memory.Read) — no per-node host↔guest boundary calls.
  • Memory design (measured on prod-shaped churn): mmap-backed linear memory
    (experimental.WithMemoryAllocator) → no realloc-copy garbage on growth,
    munmap-on-close returns memory to the OS immediately (churn heapSys
    1135→391 MB, peak RSS 1070→535 MB; full-repo peak RSS 1516→787 MB). Engine pool
    with a hard, dashboard-tunable concurrency cap, 256 MiB per-instance ceiling,
    high-water-mark recycling, 1 idle instance.
  • Chunker quality: minified/bundled js/ts/css (.min., .bundle.js,

    2 KiB lines) skip the parser straight to the sliding window (the pathological
    input class that ballooned instances); a declaration's doc comment now attaches
    to its chunk (language-agnostic via tree-sitter's extra flag) — generated
    files stop spraying comment-only micro-chunks (openapi.gen.go 893→517 chunks,
    symbols/refs byte-identical).

  • Memory-stress harnesses are committed but gated behind CIX_MEMSTRESS=1.

go build ./... clean. Per-language fixture tests + the official tree-sitter CLI
oracle are the gate (the cgo branch is not differentiated against).

Build-image testing (CPU distroless + CUDA :cu128) will be done on develop
after merge
, per plan — not in this PR.

Type of change

  • New feature
  • Refactor

Checklist

  • go build ./... passes
  • Chunker / indexer unit tests green (per-language AST-name fixtures, langdetect)
  • Build-image testing (CPU + CUDA) — deferred to develop post-merge
  • No secrets or API keys committed

Commits:

  • 7384cb1 poc(chunker): WASM/wazero tree-sitter backend — speed + stability vs cgo
  • ddd16e9 feat(chunker): wasm/wazero tree-sitter backend — mmap memory, minified skip, doc-comment attachment
  • 285713d feat(server): dashboard-tunable chunker concurrency + llama --cache-ram (OOM fix)
  • 675ddc7 fix(indexer): batch big-project wipes to stop SQLITE_BUSY writer starvation

Follow-on in this PR: full_sync_required flag (migration 18)

Because the new chunker emits different chunks/symbols/signatures (hence different
vectors) than gotreesitter, an existing index is format-stale but nothing in
the reindex logic notices a chunker change (incremental is content-hash gated;
full-wipe only triggers on an embedding-model change). So existing projects would
silently serve a stale mix after deploy.

Added a persistent, self-clearing, reason-carrying flag:

  • Migration 18 adds projects.full_sync_required + full_sync_reason
    (idempotent via columnExists) and backfills every existing project to
    require a full resync. Fresh installs default to 0 (in sync).
  • Informational, not a trigger — it drives a dashboard "Out of sync — full
    resync required" badge (mirrors the stale-model badge); the admin starts the
    resync (dashboard force-full for git projects, cix reindex <path> for local).
  • Self-clearing: BeginIndexing records session.full; FinishIndexing
    clears the flag when the completed run was full — on success only (a full
    run that crashes mid-way stays flagged). Incremental/reconcile runs leave it
    set. Both the git/clone and local CLI paths converge on FinishIndexing, so
    the clear is universal regardless of where the resync was triggered.
  • Spec-first OpenAPI (regenerated Go types; dashboard TS types regenerate at
    build). Tests: migration backfill, full-clears/incremental-keeps, loader
    round-trip.

After merge + deploy on develop, existing projects will show the badge; a
full reindex per project clears it. The batched-wipe commit (675ddc7) keeps
the bulk full-reindex from starving the SQLite writer.

🤖 Generated with Claude Code

dvcdsys and others added 4 commits June 7, 2026 23:49
Alternative to feat/chunker-cgo-treesitter: the official tree-sitter C runtime
+ TypeScript grammar compiled to a standalone wasm32-wasi reactor module
(build.sh, via zig cc) and driven from Go through wazero — no cgo, no JS, no
third-party parser. Only the wazero host (wasmts.go) is bespoke; the parser is
unmodified upstream C. wasm_store.c is gated by TREE_SITTER_FEATURE_WASM (we
don't define it), so the stock amalgamation compiles to wasi with no stubs.

Measured on the same 852-file vscode TypeScript corpus (full-tree walk):

  backend                     wall    files/s  ERROR trees  editorOptions.ts
  gotreesitter (pure-Go)     13.83s     62        13        8.77s -> ERROR
  WASM (wazero, pure-Go)     ~2.5s     ~330        0         49ms
  cgo (native)                1.26s    675         0         17ms

- WASM ~2x slower than cgo, ~5x faster than gotreesitter, correct (0 errors).
- Overhead is the per-node host<->guest call boundary (~3 calls/node x 2.68M
  nodes), not memory — slot-pooling barely moved it. A batched "serialize
  subtree" export would close most of the gap (future work).
- Stability: tree-sitter is robust on adversarial input under both backends;
  WASM additionally CONTAINS faults (resource/guest trap -> recoverable Go
  error, host alive) where cgo would SIGSEGV the whole process. Insurance vs
  unknown C bugs, not a fix for an observed crash.

Trade-off vs cgo: ~2x parse cost (largely invisible end-to-end since embeddings
dominate) in exchange for CGO_ENABLED=0 builds, crash-isolation, and a likely
smaller binary; cost is the engineering effort to build/bundle all 31 grammars
and flesh out the node API. README.md has the full comparison.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d skip, doc-comment attachment

Replace gotreesitter with the official tree-sitter C runtime + 31 grammars
compiled to one wasm32-wasi module (ts-core.wasm.br, brotli ~3MB) driven via
wazero. No cgo: traps are contained (parse falls back to sliding window, the
process survives), and the binary stays CGO_ENABLED=0.

Memory design (measured on the prod-shaped churn workload):
- linear memory is mmap-backed (experimental.WithMemoryAllocator) instead of
  wazero's default Go-heap append-grow: no realloc-copy garbage on growth and
  munmap-on-close returns recycled instances' memory to the OS immediately.
  Churn heapSys 1135→391MB, peak RSS 1070→535MB; full-repo chunking peak RSS
  1516→787MB.
- engine pool: hard concurrency cap (dashboard-tunable), 256MiB per-instance
  linear-memory ceiling (2× headroom over the worst measured instance at the
  indexer's 512KiB file cap), high-water-mark recycling, 1 idle instance.

Chunker quality fixes:
- minified/bundled js/ts/css (.min., .bundle.js, >2KiB lines) skip the parser
  straight to sliding window — the pathological input class that ballooned
  instances for near-zero semantic value.
- a declaration's doc comment now attaches to its chunk (language-agnostic via
  tree-sitter's extra flag + same-row wrapper climb; verified for Go, TS, C,
  Python, Rust, Java). Generated files stop spraying comment-only micro
  chunks: openapi.gen.go 893→517 chunks, median 114→256B, symbols/refs
  byte-identical.

Memory-stress harnesses are committed but gated behind CIX_MEMSTRESS=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…am (OOM fix)

Two new runtime-config fields, end to end (DB migrations 16/17 → runtimecfg →
admin API → openapi → dashboard):

- chunk_max_concurrent — the wasm chunker's instance-concurrency cap,
  decoupled from embedding concurrency; resizes the live limiter without a
  restart. Env: CIX_CHUNK_MAX_CONCURRENT; per-instance memory knobs stay
  env-only (CIX_CHUNK_MEM_LIMIT_PAGES, CIX_CHUNK_RECYCLE_GROWTH_MB,
  CIX_CHUNK_MAX_IDLE).

- llama_cache_ram_mib — llama-server's HOST prompt cache cap (--cache-ram).
  Upstream defaults this to 8 GiB (ggml-org/llama.cpp#16391), which is pure
  waste for an embeddings-only sidecar: prompts are never reused, but the
  cache fills anyway. Observed on prod: llama-server RSS 365MB→11.3GB within
  minutes of indexing vscode@main, then cgroup OOM kill — twice at the 10G
  limit, again at 16G. With --cache-ram 0 (our default; -1 = unlimited) it
  plateaus at ~900MB under the same load. Env: CIX_LLAMA_CACHE_RAM; shown in
  the dashboard's Runtime parameters card, applied via Save & Restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vation

A full-reindex wipe ran as ONE transaction: DELETE of all refs/symbols/
file_hashes plus the trigram-FTS rows. On a vscode-sized project (~445k refs,
tens of thousands of FTS rows — each FTS delete re-tokenizes its content)
that held SQLite's single writer for minutes, starving every concurrent
writer past busy_timeout. Prod symptom: the jobs worker logged
`claim failed: SQLITE_BUSY` on every 5s poll tick for the whole wipe.

- BeginIndexing full wipe: file_hashes first (its own statement — once gone,
  every file looks dirty, so a crash mid-wipe just resumes on the next run),
  then symbols/refs in 20k-row batches, then chunks_fts/chunks_meta via the
  batched chunksfts.DeleteByProject (500 rows per tx — FTS deletes are the
  expensive ones). The writer is released between batches.
- projects.Delete: same batched FTS wipe, project row deleted last so a
  failed wipe is resumable.
- jobs worker: SQLITE_BUSY on claim is expected contention, not a fault —
  log the streak start as WARN with a once-a-minute heartbeat instead of an
  ERROR per tick, and log when it clears.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dvcdsys dvcdsys changed the title poc: WASM/wazero tree-sitter backend (speed + stability vs cgo PR #80) feat(chunker): WASM/wazero tree-sitter backend — replaces gotreesitter Jun 22, 2026
@dvcdsys
dvcdsys marked this pull request as ready for review June 22, 2026 10:03
dvcdsys and others added 2 commits June 22, 2026 12:18
… after chunker swap

The WASM tree-sitter chunker emits different chunks/symbols/signatures (hence
different embedding vectors) than gotreesitter, so an index built by the old
chunker is format-stale. Nothing in the reindex logic notices a *chunker*
change (incremental is content-hash gated; full-wipe only triggers on an
embedding-model change), so existing projects would silently serve a stale mix.

Add a persistent, self-clearing flag so the dashboard can surface this and an
admin can act on it:

- db: migration 18 adds projects.full_sync_required + full_sync_reason
  (idempotent via columnExists) and backfills every existing project to require
  a full resync. Fresh installs default to 0 (in sync).
- indexer: BeginIndexing records session.full; FinishIndexing clears the flag
  when the completed run was full — on success only, so a full run that crashes
  mid-way stays flagged. Incremental/reconcile runs leave it set. Both the
  git/clone and local CLI paths converge here, so the clear is universal.
- The flag is INFORMATIONAL: it drives the badge but triggers nothing. The
  admin starts the resync (dashboard force-full for git projects, `cix reindex`
  for local).
- projects/httpapi: carry the two fields through the loader + project payload.
- openapi: spec fields + regenerated Go types (dashboard TS types regenerate at
  build via gen:api).
- dashboard: "Out of sync" badge (ProjectCard) + "full resync required" alert
  (ProjectDetailPage), mirroring the existing stale-model badge.
- tests: migration backfill, full-clears/incremental-keeps, loader round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t page

A failed reindex/sync was invisible: POST /projects/{hash}/reindex returns 200
("enqueued", success toast), but the clone/index job then fails asynchronously
in a background worker (e.g. "clone: fetch: authentication required" for a
private repo with a bad/expired GitHub token). recordFailure already persists
the reason to git_repos.last_error and sets projects.status='error', but the
dashboard only showed a grey "error" badge — no reason, no notification.

- ProjectDetailPage: prominent destructive Alert when status='error', showing
  git_repos.last_error (external) verbatim, or a generic hint + `cix reindex`
  for local projects. Mirrors the existing drift / full_sync alerts.
- Toast on the live not-error → error transition (a watched job just failed);
  prevStatus starts undefined so landing on an already-errored project shows
  only the inline alert, no spurious toast.
- hooks: useProject now polls while 'indexing' OR 'error' (was indexing-only)
  so a retry's outcome appears live and the reason stays current; bounded to
  the open/focused page by react-query. useProjectGitRepo gains an opt-in
  `poll` arg, driven while a job is in flight / errored, so last_error refreshes
  without a manual reload.

Frontend-only — the error was already persisted and exposed via
GET /projects/{hash}/git-repo; this just routes it to the operator. Enqueue
semantics unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dvcdsys
dvcdsys merged commit 64dbeaf into develop Jun 22, 2026
1 check passed
@dvcdsys
dvcdsys deleted the feat/chunker-wasm-treesitter branch June 22, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant