Root cause + recipe for the 5.2GB RSS leak (2026-08-19). Source of truth:
scripts/stack/serve-brain and scripts/docker-entrypoint (same pattern, two places).
Running serve.go alone (single process):
- Every search loaded the 512MB embedding matrix in-process.
Close()frees only the tokenizer, not the matrix. - Spawned ~20 zombie
[serve]subprocesses (os.Executablecan't handle theservesubcommand). - RSS grew to 5.2GB.
The embed daemon and the API must be separate processes:
brain-search serve 17830— loads the model once, keeps it resident (~1.1GB RSS), exposes/embed.brain-serve— thin HTTP/MCP API (~100MB) that proxies embedding requests to the daemon.
Measured: daemon 1.1GB + API 100MB, zero zombies.
- Docker: the entrypoint already does this
(
scripts/docker-entrypointservebranch startsbrain-search servebeforebrain-serve). - Bare host:
scripts/stack/serve-brain— builds static binaries (bin-build/brain-{serve,search,index}) with Zig CGO, then starts both processes with reuse logic (curl health, keep running daemon/API).
Root cause + recipe for the second, larger leak. internal/brain/search.go
embedQuery (the no-daemon fallback) called loadModel() + defer model.Close() on every search; StaticModel.Close() freed only the
tokenizer, not the safetensors matrix. So each request retained ~490 MB inuse
(~980 MB allocated: read buffer + matrix) until GC pressure caught up.
Measured live before (container 2dph-brain-1, DB kb.lbug 11 MB):
- 13 sequential searches: RSS 147 MB → 6.8 GB, swap 0 → 0.5 GB.
- pprof
alloc_spacetotal 16.98 GB,loadMatrix13.84 GB (81.5%) (inuse_spaceloadMatrix488.6 MB). - Long run: 13 GB (RSS 1.8 + swap 11.2) over 6 days.
Fix: the process-wide singleton cache from #110 was generalized
(internal/brain/embedmodel.go, formerly ingestmodel.go) and now backs
/ingest, the /search in-process fallback and ANN extract. embedQuery →
embedInProcess(); the shared model is never closed (embedder has no Close).
StaticModel.Close() now releases mat for the one-shot CLI tools. pprof is
pinned to loopback and the container has a 2 GiB memory limit
(deploy.resources.limits.memory).
Measured live after (same host, KBSEARCH_NO_DAEMON=1, test/stress --mix search:1 --c 1): 49,448 sequential searches, 0 errors, p50 6.0 ms
(vs 1429 ms before), alloc_space loadMatrix 977 MB over the whole run =
exactly one load (before: 13.84 GB over 13 requests). RSS: 143 MB cold →
1.68 GiB after warmup (shared model + 512 MiB pool + ANN), then 1.79 → 1.82
GiB over ~39k further searches (delta ~36 MiB, < 50 MiB); swap flat at
~4.5 MiB (before: 0.5 GiB after 13 requests). Container stays under the 2 GiB
limit. Tests: internal/brain/embedmodel_test.go.
Sustained concurrent searches pinned the whole Ladybug FTS/vector buffer
pool: every conn.Query/conn.Execute result that was not closed kept its
C buffer pages pinned. After a load burst the pool was permanently full
(Buffer manager exception: Unable to allocate memory! The buffer pool is full and no memory could be freed!) and every endpoint — even /stats
— returned 502 until a process restart.
Fixed in internal/brain/*.go: defer res.Close() on every QueryResult
(read + one-shot statements via qClose). Regression:
TestConcurrentSearchesDontExhaustBufferPool (red without the fix).
Live proof: test/stress at c=8/16 sustained now holds 0% errors.
KB_BUFFER_POOL(bytes) sizes the Ladybug buffer pool. Compose caps it at 512 MiB (was 4 GiB) — the served DB is ~11 MB, so a large pool only wastes memory. Rebuild (--profile index) uses its own larger pool. Smaller pools exhaust faster under concurrency; raise it only for hosts with a large DB.KB_PPROF(port) enablesnet/http/pprofon the API for cpu/heap/goroutine profiles (go tool pprof). It binds 127.0.0.1 only (never the API host): leave it unset to disable.deploy.resources.limits.memory(compose, brain) = 2 GiB hard ceiling so a memory regression is contained instead of eating host swap.
KBSEARCH_PORT(default 17830) — embed daemon port.KBSEARCH_NO_DAEMON=1— force in-process embedding (only for testing).KB_PORT(default 8630),KB_WORKERS(API worker pool).