runtime: publish the vector index instead of guarding it #143
Reference in New Issue
Block a user
Delete Branch "fix/awareness-thread-engram-race"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Three read paths mutated five process-global statics.
engram_activate,eg_knn_for_node(whose own comment says "No writes.") andengram_geo_reify_run_jsonall calledeg_vindex_sync, which frees the index, reallocs the seen-map and inserts — on a read. Every frame of the production crash (engram_activate → eg_vindex_sync → vindex_insert → _realloc → _xzm_xzone_malloc_freelist_outlined) is accounted for.Three moves, in decreasing order of how much they dissolve
1. Misfiled scratch is not shared state.
visited/visit_epoch/visited_capwere never owned by the index — they are one traversal’s local, hoisted intostruct VIndexas an allocation optimisation. Two concurrent reads stomped each other purely because of this. They want neither a lock nor a capability nor a pool, just to go back in the call frame.2.
constIS the capability. Once the scratch leaves the struct, search reads and nothing else, sovindex_searchtakes aconst VIndex*. That is exactly what a capability-pointer ABI would have bought — a read path physically cannot callvindex_insert, compiler-enforced on every future caller — for one qualifier instead of an ABI swept across hundreds of builtins.3. What survives is publication, not ownership. HNSW insert is not an append: it rewires the neighbour links of already-existing elements and reallocs
elems[], so the store’s append-only property does not transfer to the index derived from it.eg_vindex_syncsplits intoeg_vindex_maintain(exclusive, sole mutator) andeg_vindex_view(shared, returnsconst VIndex*). A read path may demand a current snapshot exist — a request to the owner, not a mutation by the reader.Write-side owner.
eg_vindex_note_embeddedhooks the embedding-assignment sites rather than the append sites: a node with no embedding cannot be in a vector index, so embedding assignment is the event that owns membership. One O(log n) insert, no O(node_count) presence scan. Retires the staleness note where a lazily-embedded older node stayed invisible toroute_nearest/autoconnect until a full rebuild (the embed-gap #20 shape).Evidence
The old harness conflated two hazards, which is why fixing half of it read as failure. Split four ways:
engram_vindex.c:195RESULT: PASS.recall@10 = 0.9365atef_search=128(gate ≥0.90); determinism byte-identical across two independent builds. Re-run green after rebase ontodev(on top of #141 and #142), with all three fixes verified coexisting in one binary.The bare-index half is now permanently expected to race, deliberately — it is the executable proof that the boundary must live above the data structure.
fb32d15is KEPT — correcting this design’s own §5The design said delete the guard. Measured, it covers two structures and only one was converted here:
g->nodes/g->edgesare realloc’d in place (el_runtime.c:7618,7629) andengram_activate_inner’s embed-backfill writesn->embthrough exactly such a borrowed pointer. Deleting it reintroduces a measured 11171→9579 edge loss. Kept, with its comment narrowed to the RAM graph and the deletion precondition named.That corrects the ordering claim too: the residual is not one ABI that dissolves everything at once — it is a property applied per structure. Residues evaporate in the order the property is applied, and a residue whose structure has not been converted must be left standing.
The soul daemon had two engram callers and only one of them locked. soul.el:729 starts the HTTP server via http_serve_async (spawning http_worker threads); soul.el:731 then runs awareness_run() on the MAIN thread. awareness.el's perceive() -> engram_activate_json() -> engram_activate() -> eg_vindex_sync() -> vindex_insert() mutates the same g->nodes/g->edges and the process-global _eg_vindex HNSW index that the workers touch. g_engram_req_lock existed to serialize exactly this, but it was only ever taken inside http_worker: engram_req_lock/engram_req_unlock appear in ZERO .el sources, so the awareness loop ran lock-free beside the workers on every tick (SOUL_TICK_MS=1000). Result was a crash-loop under launchd KeepAlive: five crashes in ~4 minutes on 2026-08-16 with varying faulting frames -- search_layer<-vindex_insert <-eg_vindex_sync, engram_activate, abort, and one inside xzm_realloc's own freelist. Varying sites plus a fault in allocator metadata means heap corruption. The SIGSEGV address 0x65646f4e6d617267 is little-endian ASCII "gramNode": string bytes dereferenced as an Elem vector pointer. Diagnosed by bisection rather than inspection: - Replaying all 13,820 real dim-768 vectors harvested from the live store through the index single-threaded under ASan is 100% clean, which rules out an HNSW logic/bounds bug. - Two threads on one index trip ThreadSanitizer immediately at engram_vindex.c:195 (visited_reset), reached from both vindex_search and vindex_insert. VIndex keeps a SHARED visited-epoch scratch buffer, so even two concurrent READS corrupt each other's traversal and walk bogus element indices. So this is purely a concurrency defect, not an HNSW logic error. (An inspection-derived hypothesis about an out-of-bounds reverse-link write at engram_vindex.c:340 was disproved by the single-threaded run.) Fix: a thread-local ownership depth (_eg_req_depth) lets engram entry points self-guard. engram_activate() becomes a wrapper over engram_activate_inner() that acquires g_engram_req_lock when called with depth 0 (the awareness thread) and passes through when depth > 0 (nested inside an http_worker that already holds it), so the non-recursive mutex cannot self-deadlock. The depth is a plain counter, never a recursive-mutex count, preserving engram_self_reify_beat_json's contract of genuinely releasing the lock mid-beat.Promotes the two throwaway sanitizer harnesses used to diagnose the 2026-08-16 soul crash into engram/test/ so the bug cannot silently regress. The harness has two halves and the PAIR is the point — it is what localises the defect to concurrency rather than to HNSW logic: single 3000 clustered vectors, one thread, ASan+UBSan. The CONTROL. Must always be clean. During diagnosis this cleared all 13,820 real dim-768 vectors from the live store, which DISPROVED an inspection-derived hypothesis about an out-of-bounds reverse-link write at engram_vindex.c:340. concurrent writer + reader on one shared index, TSan. Currently reports a race at engram_vindex.c:195 (visited_reset) reached from both vindex_search and vindex_insert, because VIndex still owns its visited[]/visit_epoch scratch — so even two concurrent READS corrupt each other's traversal. Verified: half 1 passes, half 2 reproduces the race. Gated on EXPECT_RACE, default 1, so the concurrent half documents the known defect without failing the suite today. When the visited set moves to a per-query checkout pool (hnswlib VisitedListPool style — NOT thread_local, since http_worker is a thread per connection and a __thread buffer would leak ~55KB per connection), flip EXPECT_RACE=0 and it becomes a real gate.will.anderson referenced this pull request2026-08-16 17:20:41 +00:00