@@ -17,6 +17,12 @@ const c = @cImport({
1717 @cInclude ("llama.h" );
1818});
1919
20+ /// libc bits used only to locate the installed model in tests.
21+ const libc = @cImport ({
22+ @cInclude ("stdlib.h" );
23+ @cInclude ("unistd.h" );
24+ });
25+
2026// MiniLM-L6-v2 embedding width. The vec_drawers virtual table is declared
2127// float[384], so the active model MUST match this. Validated in init().
2228pub const EMBEDDING_DIM = 384 ;
@@ -193,36 +199,71 @@ var global_emb: ?Embedder = null;
193199/// Points at config/env memory — not freed here.
194200var pending_model_path : ? [:0 ]const u8 = null ;
195201
202+ /// `global_emb` is a by-value `?Embedder`, so *assigning* it rewrites the
203+ /// struct in place — including `lock_flag`, the very spinlock `embed()` uses to
204+ /// serialize the llama_context. Lazy-init therefore MUST be serialized itself:
205+ /// the miner calls `embed()` from one concurrent task per file, and an
206+ /// unguarded `if (global_emb == null) global_emb = init()` let every task see
207+ /// null, build its own Embedder, and overwrite the global underneath the
208+ /// others. In-flight calls had their `ctx` swapped mid-encode and their lock
209+ /// reset, which surfaced as a storm of EncodeFailed/NoEmbedding and silently
210+ /// dropped ~90% of chunks when mining a directory (single-file mining has one
211+ /// task, so it never raced and always looked fine).
212+ ///
213+ /// `emb_ready` is the atomic publication flag — `global_emb` itself is a wide
214+ /// struct and can't be loaded atomically. Once published, `global_emb` is never
215+ /// reassigned until `deinitGlobal`, so `&global_emb.?` stays stable.
216+ var emb_ready : std .atomic .Value (bool ) = .init (false );
217+ var init_lock : std .atomic .Value (bool ) = .init (false );
218+
219+ /// Initialize the process-global embedder exactly once. Safe to call from many
220+ /// threads concurrently; losers of the race wait and observe the winner's.
221+ fn initGlobalOnce (model_path : [:0 ]const u8 ) ! void {
222+ if (emb_ready .load (.acquire )) return ;
223+
224+ while (init_lock .cmpxchgWeak (false , true , .acquire , .monotonic ) != null ) {
225+ std .atomic .spinLoopHint ();
226+ }
227+ defer init_lock .store (false , .release );
228+
229+ // Double-check under the lock: another thread may have published while we
230+ // were spinning.
231+ if (emb_ready .load (.acquire )) return ;
232+
233+ global_emb = try Embedder .init (model_path );
234+ emb_ready .store (true , .release );
235+ }
236+
196237/// Remember where the model lives without loading it. Mine can then skip the
197238/// ~0.5s Metal init entirely when every chunk is already stored (incremental).
198239pub fn setModelPath (model_path : [:0 ]const u8 ) void {
199240 pending_model_path = model_path ;
200241}
201242
202243pub fn initGlobal (model_path : [:0 ]const u8 ) ! void {
203- if (global_emb != null ) return ;
204244 pending_model_path = model_path ;
205- global_emb = try Embedder . init (model_path );
245+ try initGlobalOnce (model_path );
206246}
207247
208248pub fn deinitGlobal () void {
209249 if (global_emb != null ) {
210250 global_emb .? .deinit ();
211251 global_emb = null ;
212252 }
253+ emb_ready .store (false , .release );
213254 pending_model_path = null ;
214255}
215256
216257/// True once a model is loaded. Lets callers degrade gracefully (e.g. keyword
217258/// search) instead of hard-failing when no model is configured.
218259pub fn isReady () bool {
219- return global_emb != null ;
260+ return emb_ready . load ( .acquire ) ;
220261}
221262
222263fn ensureReady () ! void {
223- if (global_emb != null ) return ;
264+ if (emb_ready . load ( .acquire ) ) return ;
224265 const path = pending_model_path orelse return error .EmbedderNotInitialized ;
225- global_emb = try Embedder . init (path );
266+ try initGlobalOnce (path );
226267}
227268
228269pub fn embed (text : []const u8 , allocator : Allocator ) ! []f32 {
@@ -232,3 +273,57 @@ pub fn embed(text: []const u8, allocator: Allocator) ![]f32 {
232273 }
233274 return error .EmbedderNotInitialized ;
234275}
276+
277+ // ═══════════════════════════════════════════════════════════════════
278+ // Regression tests
279+ // ═══════════════════════════════════════════════════════════════════
280+
281+ test "concurrent embed() does not race the lazy global init" {
282+ const allocator = std .testing .allocator ;
283+
284+ // Needs the real model. Skip where it isn't installed (clean CI checkout).
285+ const home_raw = libc .getenv ("HOME" ) orelse return error .SkipZigTest ;
286+ const home = std .mem .span (home_raw );
287+ const path = std .fmt .allocPrintSentinel (allocator , "{s}/.memxt/lib/minilm.gguf" , .{home }, 0 ) catch return error .SkipZigTest ;
288+ defer allocator .free (path );
289+ if (libc .access (path .ptr , 0 ) != 0 ) return error .SkipZigTest ;
290+
291+ deinitGlobal ();
292+ setModelPath (path );
293+
294+ // The miner spawns one concurrent task per file, all landing in embed().
295+ // Pre-fix, each saw `global_emb == null`, built its own Embedder and
296+ // overwrote the global — swapping the llama_context (and resetting
297+ // `lock_flag`, the spinlock guarding it) underneath in-flight calls. Most
298+ // chunks then died with EncodeFailed/NoEmbedding and were silently dropped:
299+ // mining src/ui (22 files) stored 4 drawers instead of 504, and still
300+ // exited 0. Hammer the cold-start path from many threads at once; every
301+ // call must succeed.
302+ const N = 8 ;
303+ const Worker = struct {
304+ fn run (ok : * std .atomic .Value (u32 ), bad : * std .atomic .Value (u32 ), idx : usize ) void {
305+ var buf : [64 ]u8 = undefined ;
306+ const text = std .fmt .bufPrint (& buf , "regression sentence number {d}" , .{idx }) catch return ;
307+ const a = std .testing .allocator ;
308+ if (embed (text , a )) | vec | {
309+ a .free (vec );
310+ _ = ok .fetchAdd (1 , .monotonic );
311+ } else | _ | {
312+ _ = bad .fetchAdd (1 , .monotonic );
313+ }
314+ }
315+ };
316+
317+ var ok : std .atomic .Value (u32 ) = .init (0 );
318+ var bad : std .atomic .Value (u32 ) = .init (0 );
319+ var threads : [N ]std.Thread = undefined ;
320+ for (& threads , 0.. ) | * t , i | {
321+ t .* = std .Thread .spawn (.{}, Worker .run , .{ & ok , & bad , i }) catch return error .SkipZigTest ;
322+ }
323+ for (threads ) | t | t .join ();
324+
325+ try std .testing .expectEqual (@as (u32 , 0 ), bad .load (.monotonic ));
326+ try std .testing .expectEqual (@as (u32 , N ), ok .load (.monotonic ));
327+
328+ deinitGlobal ();
329+ }
0 commit comments