semantex-core 1.1.0

Core library for semantex semantic code search (indexing, embeddings, search)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
//! Multi-client, branch-aware searcher lifecycle for the `semantex serve` daemon
//! (Wave 2 batch 2, contract §E/§F).
//!
//! # Background — the two gaps this closes
//!
//! Before this module, `SemantexServer::run` (see `server/mod.rs`) opened a
//! single [`HybridSearcher`] once at daemon startup and [`Listener`] served it
//! for the daemon's whole lifetime (up to the idle timeout, historically up to
//! 24h under `semantex watch`). Two consequences, both previously documented
//! inline at the `serve` command's branch-switch call site:
//!
//! 1. A `git switch` made *while the daemon is running* was invisible to it —
//!    every other entry point (`semantex index`/`watch`, MCP) reconciles a
//!    branch switch via [`branches::detect_and_handle_branch_switch`] on its
//!    own hot path, but the daemon's accept loop had no hook to do the same,
//!    so it kept serving the OLD branch's content until restarted.
//! 2. The daemon had no way to notice its OWN index being rebuilt underneath
//!    it (e.g. `semantex index` run again in another terminal for the same
//!    project) — the single long-lived `HybridSearcher` just kept its
//!    originally-opened view.
//!
//! # Design — mirrors `semantex-mcp`'s proven LRU searcher cache
//!
//! `semantex-mcp/src/server.rs` already solves an equivalent problem for the
//! in-process MCP path: a small `HashMap<PathBuf, Arc<CachedSearcher>>` LRU,
//! evicted by recency + a memory-pressure valve, with entries wrapped in `Arc`
//! so an in-flight query keeps a searcher alive even after it's evicted from
//! the map. [`SearcherCache`] mirrors that shape almost exactly, keyed by
//! `(project_root, branch_key)` instead of just `project_root` (Wave 2
//! §Multi-branch's `layout::current_branch_key`) so that a branch switch
//! naturally misses the cache under its new key rather than requiring the
//! caller to reason about generation counters.
//!
//! Per-request flow ([`SearcherCache::get`]):
//!
//! 1. **Branch-switch probe** (cheap: two small file reads, per
//!    [`branches::branch_switch_pending`]). If pending — and not already
//!    handled per the tombstone below — reconcile via
//!    [`branches::detect_and_handle_branch_switch`] — which takes its OWN
//!    exclusive `.semantex.lock` internally and blocks until it's free; we do
//!    NOT hold any lock of ours across that call — then drop every cached
//!    entry for this project (the live root's content just changed, or for
//!    the `SnapshottedOutgoing` case still needs a real incremental rebuild
//!    via `semantex index`/`watch` before it fully reflects the new branch;
//!    either way an old cached instance must not keep being served under a
//!    reused key).
//!
//!    **F1 — reconcile-once tombstone.** A `SnapshottedOutgoing` reconcile
//!    deliberately leaves the root sidecar recording the OLD branch (that
//!    pending-ness is the signal every other entry point uses to force the
//!    catch-up build), so `branch_switch_pending` stays `true` until a real
//!    build runs — which the daemon itself never does. Without a guard,
//!    EVERY subsequent request would re-enter the reconcile: take the
//!    exclusive index flock, drop all cached entries, and pay a full
//!    searcher reopen — permanent thrash. So after a `SnapshottedOutgoing`
//!    result we record a per-project `(from_key, to_key)` tombstone and skip
//!    re-entering the reconcile while the root sidecar still records
//!    `from_key` and HEAD still points at `to_key`. The tombstone
//!    self-invalidates the moment either side changes (HEAD moved again, or
//!    a real build restamped the sidecar). We do NOT stamp the sidecar with
//!    the new identity from the daemon — that would silently clear the
//!    pending signal the CLI/MCP entry points rely on.
//!
//!    **W1 — Windows graceful degradation.** The project's cached entries
//!    are dropped BEFORE the reconcile runs (closing our own `chunks.db` /
//!    tantivy handles — on Windows an open handle makes the restore's
//!    rename-over fail with error 32, where POSIX just keeps the old inode
//!    alive), and a reconcile that still fails (an in-flight request's
//!    searcher holding the files) degrades instead of poisoning the daemon:
//!    the error is logged, the current (possibly old-branch) index keeps
//!    being served, and the reconcile is retried after a short cooldown
//!    ([`RECONCILE_RETRY_COOLDOWN`]) once the blocking handles have had a
//!    chance to close. Safe to retry because
//!    `layout::restore_branch_dir_into_root` mutates `chunks.db` first (the
//!    exact file every open searcher holds → open-handle failures abort
//!    before any mutation) and writes the root `branch.json` sidecar last —
//!    so a failed restore leaves `branch_switch_pending` true and the next
//!    attempt redoes the whole restore idempotently.
//! 2. **Staleness probe** (cheap: one `stat` of `meta.json`). Compares the
//!    file's nanosecond mtime + length against the value recorded when the
//!    cached entry was opened. A mismatch means the index was rebuilt
//!    beneath us (another terminal ran `semantex index`, or a background
//!    catch-up finished) — evict and reopen. mtime-ns rather than the
//!    parsed `updated_at` field: the builder stamps `updated_at` in whole
//!    SECONDS, so two rebuilds within the same second (routine under
//!    `semantex watch` save-bursts) would be indistinguishable by content
//!    while the file mtime still moves at nanosecond resolution (F4).
//! 3. Cache hit → clone the `Arc` and touch `last_used`. Cache miss →
//!    **single-flight** per key (F3): concurrent misses for the SAME key
//!    serialize on a per-key in-progress marker so exactly ONE
//!    `HybridSearcher::open` runs and the racers reuse its result — N
//!    same-key racers must not pay N slow opens and N× transient searcher
//!    RAM (which could trip the daemon's RSS guard). The winner evicts the
//!    least-recently-used entry BEFORE opening (bounding peak memory, as
//!    the MCP cache does) and re-runs eviction again AT INSERT (distinct-key
//!    racers may have refilled the map while the slow open ran — without the
//!    re-check the map can end up permanently over cap, since hits never
//!    evict).
//!
//! Concurrency safety: every returned handle is `Arc<CachedSearcher>`. A
//! request holds its own clone for the whole connection, so even if another
//! thread's `get()` call evicts that entry from the map in the meantime, the
//! in-flight query keeps a live, valid `HybridSearcher` until it finishes and
//! drops its `Arc` — identical to the MCP cache's guarantee.
//!
//! # Known residual limitation (documented, not silently papered over)
//!
//! Immediately after a `SnapshottedOutgoing` reconcile (HEAD moved to a branch
//! with no saved snapshot yet), the live root's *content* is still the
//! OUTGOING branch's — only the snapshot bookkeeping changed — until a real
//! incremental build runs (`semantex index`/`watch`, unchanged from before
//! this module). Searches during that window (i.e. while the F1 tombstone is
//! active) are served under the new branch's key but old content. The daemon
//! has no in-protocol "building" signal to guard this today (unlike the MCP
//! path, which reports `IndexState::Building` and falls back to ripgrep);
//! documenting it here matches this module's brief, which is scoped to the
//! CHEAP reconcile hook, not a synchronous full rebuild on the search hot
//! path. When the catch-up build eventually runs, the staleness probe (step
//! 2) notices the fresh `meta.json` and reopens automatically.

use crate::config::SemantexConfig;
use crate::index::branches;
use crate::index::layout;
use crate::search::hybrid::HybridSearcher;
use anyhow::Result;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};

/// Default cap on resident `HybridSearcher` instances, overridable via
/// `SEMANTEX_DAEMON_MAX_SEARCHERS`. Kept small (2) — enough to keep the
/// current branch's searcher plus one recently-switched-away-from branch warm
/// (a quick `git switch main; ...; git switch feature` round trip is a common
/// dev workflow), without unbounded RAM growth for a daemon that may run for
/// hours.
const DEFAULT_MAX_SEARCHERS: usize = 2;

/// W1: how long to keep serving the previous (possibly stale) searcher after
/// a FAILED branch-switch reconcile before retrying. Long enough that a burst
/// of concurrent requests doesn't retry (and re-fail, and reopen) on every
/// single request while some in-flight query still holds the old files open
/// on Windows; short enough that the daemon converges within seconds of the
/// blocking handles closing.
const RECONCILE_RETRY_COOLDOWN: Duration = Duration::from_secs(2);

/// A cached, ready-to-query searcher plus the bookkeeping the LRU/staleness
/// logic needs. `Arc`-wrapped by [`SearcherCache::get`] so an in-flight query
/// survives eviction from the map (mirrors `semantex-mcp`'s `CachedSearcher`).
pub struct CachedSearcher {
    pub searcher: HybridSearcher,
    last_used: Mutex<Instant>,
    /// The index's `meta.json` mtime (nanoseconds) + length at open time —
    /// the cheap staleness marker compared on every [`SearcherCache::get`]
    /// call (see [`index_marker`] for why not the parsed `updated_at`).
    marker: String,
}

/// Cache key: a project can have at most one *live* branch at a time (the
/// storage layout keeps a single `.semantex/` root plus `indexes/<key>/`
/// snapshots), but the daemon may cycle through several during its lifetime
/// as the user switches branches — hence branch_key is part of the key rather
/// than an invalidation side-channel.
type CacheKey = (PathBuf, String);

/// LRU cache of [`HybridSearcher`] instances for the `semantex serve` daemon.
/// See the module docs for the full per-request reconcile/staleness flow.
pub struct SearcherCache {
    config: SemantexConfig,
    entries: Mutex<HashMap<CacheKey, Arc<CachedSearcher>>>,
    max_cached: usize,
    /// Serializes concurrent branch-switch reconcile attempts WITHIN this
    /// process. `detect_and_handle_branch_switch` is itself safe to call
    /// concurrently (it takes the exclusive index lock and re-checks under
    /// it), but there's no reason for every simultaneously-arriving request
    /// to redundantly race into it — the first one through does the work,
    /// the rest see either `branch_switch_pending() == false` or the F1
    /// tombstone once they acquire this gate right behind it.
    reconcile_gate: Mutex<()>,
    /// F1: per-project "already reconciled" tombstone for the
    /// `SnapshottedOutgoing` case — `project → (from_key, to_key)` of the
    /// exact switch this daemon already handled. `branch_switch_pending`
    /// deliberately stays `true` after that reconcile (the root sidecar keeps
    /// the OLD identity so other entry points force the catch-up build);
    /// without this tombstone the daemon would re-enter the reconcile —
    /// exclusive flock + full cache drop + full searcher reopen — on EVERY
    /// request until a manual `semantex index`. See the module docs.
    reconciled_outgoing: Mutex<HashMap<PathBuf, (String, String)>>,
    /// F3: per-key single-flight locks. A cache miss takes its key's lock
    /// and holds it across the slow `HybridSearcher::open`, so same-key
    /// racers wait for the winner's entry (re-checking the cache once they
    /// acquire the lock) instead of each opening their own — N slow opens +
    /// transient N× searcher RAM would risk tripping the daemon's RSS guard.
    /// Entries are never removed: the table is bounded by the distinct
    /// `(project, branch)` keys ever requested (a few bytes each), and a
    /// permanent per-key lock avoids the remove-vs-recreate races a
    /// self-cleaning map would need to reason about.
    flight_locks: Mutex<HashMap<CacheKey, Arc<Mutex<()>>>>,
    /// Diagnostic count of underlying `HybridSearcher::open` calls this cache
    /// has performed. Read via [`SearcherCache::open_count`]; the F1/F3 tests
    /// use it to prove thrash/duplicate opens can't happen.
    opens: AtomicU64,
    /// W1 (Windows graceful degradation): per-project timestamp of the last
    /// FAILED `detect_and_handle_branch_switch`. On Windows the restore's
    /// rename-over-`chunks.db` fails with error 32 while any open searcher
    /// holds the file (SQLite's win32 VFS opens without `FILE_SHARE_DELETE`;
    /// tantivy's mmapped segments likewise block rename-over). A failure must
    /// degrade — keep serving, retry later once handles have closed — not
    /// poison the daemon or thrash a retry on every request. Entries here
    /// suppress re-attempts for [`SearcherCache::reconcile_retry_cooldown`];
    /// cleared on the next successful reconcile.
    failed_reconciles: Mutex<HashMap<PathBuf, Instant>>,
    /// How long to keep serving the (possibly stale) cached searcher after a
    /// failed reconcile before retrying. Default
    /// [`RECONCILE_RETRY_COOLDOWN`]; tests shrink it via struct-update.
    reconcile_retry_cooldown: Duration,
}

impl SearcherCache {
    /// Construct an empty cache, reading `SEMANTEX_DAEMON_MAX_SEARCHERS` for
    /// the LRU cap (default [`DEFAULT_MAX_SEARCHERS`]; any non-positive or
    /// unparseable value falls back to the default).
    pub fn new(config: SemantexConfig) -> Self {
        Self {
            config,
            entries: Mutex::new(HashMap::new()),
            max_cached: max_searchers_from_env(),
            reconcile_gate: Mutex::new(()),
            reconciled_outgoing: Mutex::new(HashMap::new()),
            flight_locks: Mutex::new(HashMap::new()),
            opens: AtomicU64::new(0),
            failed_reconciles: Mutex::new(HashMap::new()),
            reconcile_retry_cooldown: RECONCILE_RETRY_COOLDOWN,
        }
    }

    /// Construct a cache pre-seeded with an already-open searcher for
    /// `project_root`'s CURRENT branch. Used by callers (`semantex serve`,
    /// `semantex watch`) that already pay the eager-open cost upfront (so
    /// startup fails fast on a broken index) and would otherwise waste that
    /// work by opening a second time on the first `get()` call.
    pub fn seeded(config: SemantexConfig, project_root: &Path, searcher: HybridSearcher) -> Self {
        let cache = Self::new(config);
        let canonical = canonicalize(project_root);
        let key = (canonical.clone(), layout::current_branch_key(&canonical));
        let marker = index_marker(&canonical);
        cache.entries.lock().insert(
            key,
            Arc::new(CachedSearcher {
                searcher,
                last_used: Mutex::new(Instant::now()),
                marker,
            }),
        );
        cache
    }

    /// Number of resident searchers (test/diagnostic hook).
    pub fn len(&self) -> usize {
        self.entries.lock().len()
    }

    /// Whether the cache currently holds no resident searchers.
    pub fn is_empty(&self) -> bool {
        self.entries.lock().is_empty()
    }

    /// Total number of underlying `HybridSearcher::open` calls this cache has
    /// performed (test/diagnostic hook). The F1 test uses it to prove a
    /// `SnapshottedOutgoing` switch reconciles exactly once; the F3 test uses
    /// it to prove same-key racing misses collapse to a single open.
    pub fn open_count(&self) -> u64 {
        self.opens.load(Ordering::Relaxed)
    }

    /// Resolve the ready-to-query searcher for `project_root`'s CURRENT
    /// branch, reconciling a pending branch switch and reopening on
    /// detected staleness first. See module docs for the full flow.
    pub fn get(&self, project_root: &Path) -> Result<Arc<CachedSearcher>> {
        let canonical = canonicalize(project_root);

        // Item 2: cheap per-request branch-switch probe + reconcile (with the
        // F1 reconcile-once tombstone for the SnapshottedOutgoing case).
        self.reconcile_pending_branch_switch(&canonical);

        let branch_key = layout::current_branch_key(&canonical);
        let marker = index_marker(&canonical);
        let key = (canonical.clone(), branch_key);

        // Fast path: cache hit with a matching staleness marker.
        if let Some(entry) = self.lookup(&key, &marker) {
            return Ok(entry);
        }

        // F3 single-flight: take this key's flight lock so same-key racers
        // wait for the first opener instead of each paying a slow open +
        // transient duplicate searcher RAM.
        let flight = Arc::clone(
            self.flight_locks
                .lock()
                .entry(key.clone())
                .or_insert_with(|| Arc::new(Mutex::new(()))),
        );
        let _flight_guard = flight.lock();

        // Re-check under the single-flight guard: if we were a racer, the
        // winner has inserted the entry by the time we acquire the guard.
        // (On a FAILED winner open, racers fall through and retry the open
        // themselves — serially, still one at a time.)
        if let Some(entry) = self.lookup(&key, &marker) {
            return Ok(entry);
        }

        // Evict BEFORE opening to bound PEAK memory (the MCP cache's
        // rationale, kept), then open outside the map lock — I/O + model
        // load is slow and other keys' lookups must not block on it.
        evict_lru_locked(&mut self.entries.lock(), self.max_cached);
        self.open_and_insert(&canonical, &key, marker)
    }

    /// Slow path of [`SearcherCache::get`]: open a fresh searcher and insert
    /// it, re-running eviction AT INSERT TIME (F3) — while our slow open ran,
    /// distinct-key racers may have inserted their own entries, and without
    /// this re-check the map would exceed the cap permanently (cache hits
    /// never evict).
    fn open_and_insert(
        &self,
        canonical: &Path,
        key: &CacheKey,
        marker: String,
    ) -> Result<Arc<CachedSearcher>> {
        let index_dir = SemantexConfig::project_index_dir(canonical);
        self.opens.fetch_add(1, Ordering::Relaxed);
        let searcher = HybridSearcher::open(&index_dir, &self.config)?;
        let entry = Arc::new(CachedSearcher {
            searcher,
            last_used: Mutex::new(Instant::now()),
            marker,
        });

        let mut entries = self.entries.lock();
        evict_lru_locked(&mut entries, self.max_cached);
        entries.insert(key.clone(), Arc::clone(&entry));
        Ok(entry)
    }

    /// Cache lookup with the staleness-marker check: a hit with a matching
    /// marker touches `last_used` and returns the entry; a hit with a STALE
    /// marker (index rebuilt beneath us — another terminal's `semantex
    /// index`, or a background catch-up completed) removes the entry and
    /// returns `None` so the caller reopens.
    fn lookup(&self, key: &CacheKey, marker: &str) -> Option<Arc<CachedSearcher>> {
        let mut entries = self.entries.lock();
        let entry = entries.get(key)?;
        if entry.marker == marker {
            *entry.last_used.lock() = Instant::now();
            return Some(Arc::clone(entry));
        }
        tracing::info!(
            path = %key.0.display(),
            "Daemon: index changed beneath cached searcher — reopening"
        );
        entries.remove(key);
        None
    }

    /// Item 2 + F1 + W1: probe for a pending branch switch and reconcile it
    /// at most ONCE per actual switch, with graceful degradation on failure.
    /// Only the (rare) pending case pays the reconcile-gate lock +
    /// `detect_and_handle_branch_switch`'s own exclusive-lock wait; the
    /// overwhelmingly common unchanged case is two small file reads and
    /// nothing else. An already-tombstoned `SnapshottedOutgoing` switch and a
    /// recently-FAILED reconcile (W1 cooldown) cost a couple more small
    /// reads/map lookups — no gate, no flock, no cache drop.
    fn reconcile_pending_branch_switch(&self, canonical: &Path) {
        if !branches::branch_switch_pending(canonical)
            || self.outgoing_switch_already_reconciled(canonical)
            || self.reconcile_recently_failed(canonical)
        {
            return;
        }
        let _gate = self.reconcile_gate.lock();
        // Re-check now that we hold the gate — another thread may have
        // already reconciled (or just failed to reconcile) this exact switch
        // while we were waiting.
        if !branches::branch_switch_pending(canonical)
            || self.outgoing_switch_already_reconciled(canonical)
            || self.reconcile_recently_failed(canonical)
        {
            return;
        }

        // W1: drop this project's cached entries BEFORE running the
        // reconcile, not after. The restore path renames over `chunks.db`
        // and replaces `sparse/`/`dense/`; on POSIX open handles keep the
        // old inodes alive and the renames succeed regardless, but on
        // Windows a file that any open searcher holds (SQLite's win32 VFS
        // opens without FILE_SHARE_DELETE; tantivy's mmapped segments block
        // rename-over even with it) makes the rename fail with error 32. Our
        // OWN cached handles are the ones we can close — dropping them first
        // means an idle daemon reconciles cleanly on Windows; only handles
        // held by requests actually in flight can still block it (handled by
        // the Err arm below). Entries would be dropped after a switch anyway,
        // so this reordering costs nothing on POSIX. (If the reconcile then
        // finds Unchanged — another process beat us under the flock — we pay
        // one unnecessary reopen. Rare and benign.)
        self.entries.lock().retain(|(root, _), _| root != canonical);

        match branches::detect_and_handle_branch_switch(canonical) {
            Ok(action) => {
                // Success clears any failure cooldown for the project.
                self.failed_reconciles.lock().remove(canonical);
                match &action {
                    branches::BranchSwitchAction::SnapshottedOutgoing {
                        from_branch_key,
                        to_branch_key,
                    } => {
                        // F1: `branch_switch_pending` stays true for this
                        // switch until a real build restamps the sidecar —
                        // record the tombstone so we don't re-enter the
                        // reconcile (flock + cache drop + reopen) on every
                        // request until then.
                        self.reconciled_outgoing.lock().insert(
                            canonical.to_path_buf(),
                            (from_branch_key.clone(), to_branch_key.clone()),
                        );
                    }
                    _ => {
                        // Restored / Unchanged / FirstBuild all leave (or put)
                        // the sidecar in agreement with HEAD — any previous
                        // tombstone for this project is obsolete.
                        self.reconciled_outgoing.lock().remove(canonical);
                    }
                }
                if action.switched() {
                    tracing::info!(
                        path = %canonical.display(),
                        ?action,
                        "Daemon: branch switch reconciled"
                    );
                }
            }
            Err(e) => {
                // W1: graceful degradation. Most likely cause on Windows: an
                // in-flight request's open searcher blocked the restore's
                // rename-over. The restore mutates `chunks.db` FIRST and
                // writes the root `branch.json` sidecar LAST
                // (layout::restore_branch_dir_into_root), and every searcher
                // holds `chunks.db` open — so an open-handle failure aborts
                // before anything changed, and any later-step failure leaves
                // individually-intact files with the OLD sidecar. Either
                // way `branch_switch_pending` stays true and a retry heals.
                // Record the failure so we serve the current (possibly
                // old-branch) content for the cooldown window instead of
                // paying flock + cache-drop + reopen on every request, then
                // retry once handles have had a chance to close. No
                // tombstone — this switch is NOT handled yet.
                self.failed_reconciles
                    .lock()
                    .insert(canonical.to_path_buf(), Instant::now());
                tracing::warn!(
                    path = %canonical.display(),
                    err = %e,
                    retry_in_s = self.reconcile_retry_cooldown.as_secs(),
                    "Daemon: branch-switch reconcile failed (open index handles?) — \
                     serving current index, will retry"
                );
            }
        }
    }

    /// F1 tombstone check: `true` iff this daemon already ran the
    /// `SnapshottedOutgoing` reconcile for EXACTLY the switch that is
    /// currently pending — the root sidecar still records the tombstone's
    /// `from_key` AND HEAD still points at its `to_key`. The moment either
    /// side moves (HEAD switched again; a real build restamped the sidecar)
    /// this returns `false` and the normal reconcile path runs.
    fn outgoing_switch_already_reconciled(&self, canonical: &Path) -> bool {
        let tombstones = self.reconciled_outgoing.lock();
        let Some((from_key, to_key)) = tombstones.get(canonical) else {
            return false;
        };
        let Some(root_meta) = layout::read_root_branch_meta(canonical) else {
            return false;
        };
        root_meta.branch_key == *from_key && layout::current_branch_key(canonical) == *to_key
    }

    /// W1 cooldown check: `true` while the last reconcile attempt for this
    /// project failed less than [`Self::reconcile_retry_cooldown`] ago.
    /// Expired entries are removed so the map stays tidy and the next request
    /// retries.
    fn reconcile_recently_failed(&self, canonical: &Path) -> bool {
        let mut failures = self.failed_reconciles.lock();
        let Some(failed_at) = failures.get(canonical) else {
            return false;
        };
        if failed_at.elapsed() < self.reconcile_retry_cooldown {
            true
        } else {
            failures.remove(canonical);
            false
        }
    }

    /// Drop every cached entry for `project_root`, regardless of branch key.
    /// Exposed for `semantex watch`'s own re-index loop, which already knows
    /// exactly when a rebuild just completed and can invalidate immediately
    /// rather than waiting for the next `get()`'s marker check.
    pub fn invalidate_project(&self, project_root: &Path) {
        let canonical = canonicalize(project_root);
        self.entries
            .lock()
            .retain(|(root, _), _| root != &canonical);
    }
}

fn canonicalize(path: &Path) -> PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

/// Cheap staleness marker: the index's `meta.json` mtime in NANOSECONDS since
/// epoch, plus the file length (one `stat`, no read/parse).
///
/// F4: deliberately NOT the parsed `meta.json::updated_at` field — the
/// builder stamps that in whole epoch SECONDS, so two rebuilds landing within
/// the same second (routine under `semantex watch` save-bursts) would produce
/// an identical marker and an interleaved reopen would serve the first
/// rebuild's content indefinitely. The file's mtime moves at nanosecond
/// resolution on every rewrite; length is appended as a cheap extra
/// discriminator for filesystems with coarse timestamps.
///
/// Best-effort — a missing/unstattable `meta.json` yields an empty string,
/// which simply means the FIRST open always "misses" (there is nothing to
/// compare against yet); it never panics or blocks the daemon.
fn index_marker(project_root: &Path) -> String {
    let meta_path = SemantexConfig::project_index_dir(project_root).join("meta.json");
    match std::fs::metadata(&meta_path) {
        Ok(md) => {
            let mtime_ns = md
                .modified()
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map_or(0, |d| d.as_nanos());
            format!("{mtime_ns}:{}", md.len())
        }
        Err(_) => String::new(),
    }
}

fn max_searchers_from_env() -> usize {
    parse_max_searchers(
        std::env::var("SEMANTEX_DAEMON_MAX_SEARCHERS")
            .ok()
            .as_deref(),
    )
}

/// Pure parsing core of [`max_searchers_from_env`] — split out (mirroring
/// `index/branches.rs`'s `_with_cap` idiom) so tests can exercise the parsing
/// rules without mutating process-global environment variables.
fn parse_max_searchers(raw: Option<&str>) -> usize {
    raw.and_then(|v| v.trim().parse::<usize>().ok())
        .filter(|&n| n > 0)
        .unwrap_or(DEFAULT_MAX_SEARCHERS)
}

/// Evict the least-recently-used entry if the map is at (or over) capacity.
/// Called with the map lock already held; never touches disk.
fn evict_lru_locked(entries: &mut HashMap<CacheKey, Arc<CachedSearcher>>, max_cached: usize) {
    while entries.len() >= max_cached {
        let Some(lru_key) = entries
            .iter()
            .min_by_key(|(_, v)| *v.last_used.lock())
            .map(|(k, _)| k.clone())
        else {
            break;
        };
        tracing::info!(
            project = %lru_key.0.display(),
            branch = %lru_key.1,
            "Daemon: evicting cached searcher (cache full)"
        );
        entries.remove(&lru_key);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::index::storage::ChunkStore;
    use crate::types::{Chunk, ChunkType, FileEntry, IndexMeta};
    use tempfile::TempDir;

    /// Test config that pins the dense backend alias to `coderank-hnsw` so
    /// `HybridSearcher::open` resolves it directly (`DenseBackendKind::parse`)
    /// without needing to walk the embedder/model registry — deterministic
    /// and independent of the shipped default embedder, matching
    /// `sample_meta()`'s `dense_backend` stamp below.
    fn test_config() -> SemantexConfig {
        SemantexConfig {
            dense_backend: "coderank-hnsw".to_string(),
            ..SemantexConfig::default()
        }
    }

    fn sample_meta() -> IndexMeta {
        IndexMeta {
            schema_version: IndexMeta::CURRENT_SCHEMA_VERSION,
            project_path: PathBuf::from("/x"),
            created_at: "0".to_string(),
            updated_at: "0".to_string(),
            file_count: 1,
            chunk_count: 1,
            embedding_model: "test".to_string(),
            embedding_dim: 48,
            use_bm25_stemmer: true,
            dense_backend: "coderank-hnsw".to_string(),
            embedder_fingerprint: "fp".to_string(),
        }
    }

    /// Build a minimal, directly-searchable index at `<project>/.semantex/`
    /// (no real embedder/tantivy build — mirrors `index/branches.rs`'s own
    /// synthetic test harness so these tests stay fast and model-free).
    fn build_index(project: &Path, content: &str, updated_at: &str) {
        let container = layout::container_dir(project);
        std::fs::create_dir_all(&container).unwrap();
        let store = ChunkStore::open(&container.join("chunks.db")).unwrap();
        store
            .insert_chunk(
                &Chunk {
                    id: 0,
                    file_path: PathBuf::from("src/a.rs"),
                    start_line: 1,
                    end_line: 1,
                    content: content.to_string(),
                    chunk_type: ChunkType::TextWindow { window_index: 0 },
                },
                1,
                0,
            )
            .unwrap();
        store
            .set_file_entry(&FileEntry {
                path: PathBuf::from("src/a.rs"),
                hash: 1,
                size: content.len() as u64,
                mtime: 0,
            })
            .unwrap();
        let mut meta = sample_meta();
        meta.updated_at = updated_at.to_string();
        std::fs::write(
            container.join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();
    }

    fn write_fake_git_head(project: &Path, branch: &str) {
        let git = project.join(".git");
        std::fs::create_dir_all(git.join("refs").join("heads")).unwrap();
        std::fs::write(git.join("HEAD"), format!("ref: refs/heads/{branch}\n")).unwrap();
        std::fs::write(git.join("refs").join("heads").join(branch), "deadbeef\n").unwrap();
    }

    /// Read back the single chunk's content via `with_store`, without
    /// assuming a specific autoincrement id (each test opens a fresh
    /// `chunks.db`, but relying on the DB's actual id assignment is safer
    /// than hardcoding one).
    fn read_chunk_content(searcher: &HybridSearcher) -> String {
        searcher.with_store(|store| {
            let ids = store.get_all_chunk_ids().unwrap();
            let id = *ids.first().expect("test index must have exactly one chunk");
            store.get_chunk(id).unwrap().content
        })
    }

    /// `SEMANTEX_DAEMON_MAX_SEARCHERS` parsing rules, exercised through the
    /// pure `parse_max_searchers` core — no process-global env mutation
    /// (the `_with_cap`/`_from` split idiom from `index/branches.rs` /
    /// `index/registry.rs`).
    #[test]
    fn max_searchers_parsing_accepts_override_and_rejects_garbage() {
        assert_eq!(parse_max_searchers(None), DEFAULT_MAX_SEARCHERS);
        assert_eq!(parse_max_searchers(Some("5")), 5);
        assert_eq!(parse_max_searchers(Some(" 3 ")), 3);
        assert_eq!(
            parse_max_searchers(Some("not-a-number")),
            DEFAULT_MAX_SEARCHERS
        );
        assert_eq!(parse_max_searchers(Some("0")), DEFAULT_MAX_SEARCHERS);
        assert_eq!(parse_max_searchers(Some("")), DEFAULT_MAX_SEARCHERS);
    }

    /// Three distinct projects with a cap of 2: the least-recently-touched
    /// project must be evicted when a third is opened.
    #[test]
    fn lru_evicts_oldest_entry_beyond_cap() {
        let tmp_a = TempDir::new().unwrap();
        let tmp_b = TempDir::new().unwrap();
        let tmp_c = TempDir::new().unwrap();
        for (tmp, content) in [(&tmp_a, "a"), (&tmp_b, "b"), (&tmp_c, "c")] {
            write_fake_git_head(tmp.path(), "main");
            build_index(tmp.path(), content, "0");
        }

        let cache = SearcherCache::new(test_config());
        // cap comes from env; force a small one for this test.
        let cache = SearcherCache {
            max_cached: 2,
            ..cache
        };

        let a = cache.get(tmp_a.path()).unwrap();
        assert_eq!(cache.len(), 1);
        let _b = cache.get(tmp_b.path()).unwrap();
        assert_eq!(cache.len(), 2);
        // Touch `a` again so `b` becomes the least-recently-used entry.
        std::thread::sleep(std::time::Duration::from_millis(5));
        let _a_again = cache.get(tmp_a.path()).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(5));

        // Opening `c` must evict `b` (LRU), not `a` (most recently touched).
        let _c = cache.get(tmp_c.path()).unwrap();
        assert_eq!(cache.len(), 2);

        // `a`'s original Arc handle must still be valid/usable even though
        // the map may have long since dropped/replaced its map entry —
        // concurrent-safety guarantee: an in-flight holder survives eviction.
        assert_eq!(read_chunk_content(&a.searcher), "a");
    }

    /// The concurrent-client safety guarantee this module exists to provide:
    /// a thread holding an `Arc<CachedSearcher>` keeps working correctly even
    /// while OTHER threads are concurrently opening new entries and evicting
    /// old ones out from under the map.
    #[test]
    fn concurrent_clients_survive_eviction_via_arc() {
        let tmp_a = TempDir::new().unwrap();
        let tmp_b = TempDir::new().unwrap();
        write_fake_git_head(tmp_a.path(), "main");
        build_index(tmp_a.path(), "alpha", "0");
        write_fake_git_head(tmp_b.path(), "main");
        build_index(tmp_b.path(), "beta", "0");

        let cache = Arc::new(SearcherCache {
            max_cached: 1,
            ..SearcherCache::new(test_config())
        });

        // Thread 1: grab and hold project A's searcher (simulating an
        // in-flight query) across a barrage of evictions from project B.
        let cache_1 = Arc::clone(&cache);
        let path_a = tmp_a.path().to_path_buf();
        let holder = std::thread::spawn(move || {
            let cached = cache_1.get(&path_a).unwrap();
            // Hold it while other threads hammer the cache with a different
            // project key (cap=1 guarantees every B lookup evicts A's entry
            // from the map).
            std::thread::sleep(std::time::Duration::from_millis(50));
            read_chunk_content(&cached.searcher)
        });

        let cache_2 = Arc::clone(&cache);
        let path_b = tmp_b.path().to_path_buf();
        let evictors: Vec<_> = (0..8)
            .map(|_| {
                let cache_2 = Arc::clone(&cache_2);
                let path_b = path_b.clone();
                std::thread::spawn(move || {
                    for _ in 0..5 {
                        let _ = cache_2.get(&path_b);
                    }
                })
            })
            .collect();

        for e in evictors {
            e.join().unwrap();
        }
        let content = holder.join().unwrap();
        assert_eq!(
            content, "alpha",
            "in-flight holder must keep serving project A's content even after its \
             map entry was evicted by concurrent project B lookups"
        );
    }

    /// Branch-switch reconcile round trip: index branch "a", snapshot,
    /// switch to "b" (also snapshotted), switch back to "a" — `get()` must
    /// serve each branch's OWN content after the switch, proving the
    /// per-request probe actually reconciles rather than serving a stale
    /// cached (project_root, old_branch_key) entry.
    #[test]
    fn branch_switch_reconcile_round_trip_serves_new_branch_content() {
        let tmp = TempDir::new().unwrap();
        let project = tmp.path();

        write_fake_git_head(project, "a");
        build_index(project, "fn on_a() {}", "100");
        layout::sync_v13_layout(project, "proj").unwrap();

        write_fake_git_head(project, "b");
        // Fresh chunks.db for "b" — mirroring `index/branches.rs`'s own
        // round-trip test, which removes the file before writing the next
        // branch's synthetic content so the store doesn't accumulate BOTH
        // branches' chunks (an artifact of this lightweight ChunkStore-only
        // harness, not something a real incremental build would do).
        std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
        build_index(project, "fn on_b() {}", "200");
        layout::sync_v13_layout(project, "proj").unwrap();
        // Ensure a snapshot for "b" exists at this content before we ever
        // switch back to "a" below.
        assert!(
            layout::branch_index_dir(project, &layout::branch_key_for_branch("b"))
                .join("chunks.db")
                .exists()
        );

        // Switch HEAD back to "a" — "a" has a snapshot (from the very first
        // sync above via `mirror_root_as`/`mirror_into_branch_dir`), so this
        // must resolve as a Restore, not a fresh in-place build.
        write_fake_git_head(project, "a");
        assert!(branches::branch_switch_pending(project));

        let cache = SearcherCache::new(test_config());
        let cached = cache.get(project).unwrap();
        assert_eq!(
            read_chunk_content(&cached.searcher),
            "fn on_a() {}",
            "cache.get() must reconcile the pending switch and serve branch a's restored content"
        );
        assert!(
            !branches::branch_switch_pending(project),
            "reconcile must have cleared the pending switch"
        );

        // Windows: release our handle on branch a's searcher BEFORE the next
        // switch. The restore below renames over `chunks.db`; POSIX keeps the
        // old inode alive for open handles, but on Windows an open handle
        // (SQLite opens without FILE_SHARE_DELETE) makes the rename fail with
        // error 32 — which is exactly the W1 graceful-degradation path, not
        // the round-trip semantics this test is about. The cache's own map
        // entry is dropped by the reconcile itself (before the restore runs);
        // this drop releases the only OTHER holder.
        drop(cached);

        // Switch forward to "b" again — must serve b's content, proving the
        // reconcile isn't a one-shot fluke and the cache key correctly
        // differentiates branches.
        write_fake_git_head(project, "b");
        let cached_b = cache.get(project).unwrap();
        assert_eq!(read_chunk_content(&cached_b.searcher), "fn on_b() {}");
    }

    /// F1 (blocker regression): a switch to a branch with NO snapshot
    /// (`SnapshottedOutgoing` — the `git switch -c` case) leaves
    /// `branch_switch_pending` TRUE (only a real build clears it, and the
    /// daemon never builds). The reconcile must run exactly ONCE: the second
    /// `get()` must reuse the SAME cached searcher (`Arc::ptr_eq`), must not
    /// reopen (open_count stays 1), and must NOT re-enter
    /// `detect_and_handle_branch_switch` — proven by holding the exclusive
    /// index flock during the second call: were the reconcile re-entered, it
    /// would block on that lock and the call would time out.
    #[test]
    fn snapshotted_outgoing_reconciles_once_then_reuses_cached_searcher() {
        let tmp = TempDir::new().unwrap();
        let project = tmp.path().to_path_buf();

        write_fake_git_head(&project, "a");
        build_index(&project, "fn on_a() {}", "100");
        layout::sync_v13_layout(&project, "proj").unwrap();

        // Switch HEAD to a brand-new branch with no snapshot.
        write_fake_git_head(&project, "b");
        assert!(branches::branch_switch_pending(&project));

        let cache = Arc::new(SearcherCache::new(test_config()));
        let first = cache.get(&project).unwrap();
        assert_eq!(cache.open_count(), 1);
        // The pending flag is INTENTIONALLY still set — the root sidecar
        // keeps the old identity so `semantex index`/`watch`/MCP force the
        // catch-up build. That persistence is exactly what made the pre-F1
        // code thrash.
        assert!(
            branches::branch_switch_pending(&project),
            "test precondition: SnapshottedOutgoing must leave the switch pending"
        );

        // Hold the exclusive index lock, as a concurrent `semantex index`
        // build would. A buggy second get() that re-enters the reconcile
        // blocks here forever; the fixed one never touches the lock.
        let lock_path = layout::container_dir(&project).join(".semantex.lock");
        let lock_file = std::fs::File::create(&lock_path).unwrap();
        lock_file.lock().unwrap();

        let (tx, rx) = std::sync::mpsc::channel();
        let cache_2 = Arc::clone(&cache);
        let project_2 = project.clone();
        std::thread::spawn(move || {
            let _ = tx.send(cache_2.get(&project_2));
        });
        let second = rx
            .recv_timeout(std::time::Duration::from_secs(10))
            .expect(
                "second get() after a SnapshottedOutgoing reconcile must not re-enter \
                 detect_and_handle_branch_switch (it would block on the index flock) — \
                 the F1 tombstone should have skipped the reconcile entirely",
            )
            .unwrap();
        drop(lock_file);

        assert!(
            Arc::ptr_eq(&first, &second),
            "second get() must reuse the same cached searcher, not drop + reopen"
        );
        assert_eq!(
            cache.open_count(),
            1,
            "no additional searcher open may happen while the tombstone is active"
        );

        // Tombstone self-invalidation: switching back to "a" agrees with the
        // (never-restamped) root sidecar, so pending clears without any
        // reconcile; switching to a THIRD branch is a different (from, to)
        // pair and must reconcile again rather than being masked.
        write_fake_git_head(&project, "a");
        assert!(!branches::branch_switch_pending(&project));
        write_fake_git_head(&project, "c");
        assert!(branches::branch_switch_pending(&project));
        let third = cache.get(&project).unwrap();
        assert!(
            !Arc::ptr_eq(&first, &third),
            "a switch to a different branch must not be masked by the old tombstone"
        );
    }

    /// F3: N threads racing a cache miss for the SAME key must collapse to
    /// exactly ONE underlying `HybridSearcher::open` (single-flight), all
    /// receiving the same entry. Without single-flight each racer pays its
    /// own slow open and transiently holds its own full searcher — N× RAM,
    /// enough to trip the daemon's RSS guard.
    #[test]
    fn racing_same_key_misses_open_exactly_once() {
        let tmp = TempDir::new().unwrap();
        write_fake_git_head(tmp.path(), "main");
        build_index(tmp.path(), "raced", "0");

        let cache = Arc::new(SearcherCache::new(test_config()));
        let barrier = Arc::new(std::sync::Barrier::new(8));
        let handles: Vec<_> = (0..8)
            .map(|_| {
                let cache = Arc::clone(&cache);
                let barrier = Arc::clone(&barrier);
                let path = tmp.path().to_path_buf();
                std::thread::spawn(move || {
                    barrier.wait();
                    cache.get(&path).unwrap()
                })
            })
            .collect();

        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
        assert_eq!(
            cache.open_count(),
            1,
            "8 same-key racers must be collapsed into exactly one searcher open"
        );
        for r in &results[1..] {
            assert!(
                Arc::ptr_eq(&results[0], r),
                "all racers must receive the single-flight winner's entry"
            );
        }
    }

    /// Staleness reload: simulate "someone ran `semantex index` again in
    /// another terminal" the way a REAL incremental build mutates a live
    /// index — SQLite rewrites `chunks.db` IN PLACE via a second connection
    /// and `meta.json` is rewritten. (Deleting/renaming-over the files, as an
    /// earlier version of this test did, is NOT what a rebuild does and fails
    /// with error 32 on Windows while the cached searcher holds the db open.)
    /// The next `get()` must notice the marker (meta.json mtime-ns + length,
    /// F4) changed and reopen rather than keep serving the old cached
    /// searcher's content.
    #[test]
    fn staleness_marker_reload_serves_fresh_content_after_rebuild() {
        let tmp = TempDir::new().unwrap();
        let project = tmp.path();
        write_fake_git_head(project, "main");
        build_index(project, "fn before() {}", "1");

        let cache = SearcherCache::new(test_config());
        let first = cache.get(project).unwrap();
        assert_eq!(read_chunk_content(&first.searcher), "fn before() {}");
        drop(first);

        // In-place content rewrite through a second SQLite connection — the
        // cache's own entry (and its open connection) stays resident, exactly
        // like a daemon serving while `semantex index` runs next door.
        let container = layout::container_dir(project);
        {
            let store = ChunkStore::open(&container.join("chunks.db")).unwrap();
            store.delete_chunks_for_file(Path::new("src/a.rs")).unwrap();
            store
                .insert_chunk(
                    &Chunk {
                        id: 0,
                        file_path: PathBuf::from("src/a.rs"),
                        start_line: 1,
                        end_line: 1,
                        content: "fn after() {}".to_string(),
                        chunk_type: ChunkType::TextWindow { window_index: 0 },
                    },
                    2,
                    0,
                )
                .unwrap();
        }
        // Windows file-time granularity can be as coarse as ~15 ms — make
        // sure the meta.json rewrite lands in a later tick so its mtime
        // actually moves (real rebuilds are seconds apart; this test isn't).
        std::thread::sleep(std::time::Duration::from_millis(30));
        // Rewrite meta.json with the SAME updated_at value ("1") — the F4
        // marker must catch the rebuild from the file's mtime alone, exactly
        // the two-rebuilds-in-one-second case the parsed updated_at (whole
        // seconds) could not distinguish.
        let mut meta = sample_meta();
        meta.updated_at = "1".to_string();
        std::fs::write(
            container.join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();

        let second = cache.get(project).unwrap();
        assert_eq!(
            read_chunk_content(&second.searcher),
            "fn after() {}",
            "get() must detect the meta.json mtime marker change and reopen"
        );
    }

    /// W1 (Windows graceful degradation): a FAILED
    /// `detect_and_handle_branch_switch` (on Windows: the restore's
    /// rename-over blocked by an in-flight request's open handles) must not
    /// poison the daemon — `get()` still serves the current (old-branch)
    /// content, no retry thrash during the cooldown window, and the reconcile
    /// is retried and succeeds once the blocker is gone. The failure is
    /// injected cross-platform by planting a DIRECTORY at the
    /// `.semantex.lock` path (`File::create` fails with EISDIR/access-denied
    /// on every OS, even running as root — unlike a chmod-based injection).
    #[test]
    fn failed_reconcile_degrades_gracefully_and_retries_after_cooldown() {
        let tmp = TempDir::new().unwrap();
        let project = tmp.path();

        // Branch "a" indexed + snapshotted, then "b" indexed + snapshotted.
        write_fake_git_head(project, "a");
        build_index(project, "fn on_a() {}", "100");
        layout::sync_v13_layout(project, "proj").unwrap();
        write_fake_git_head(project, "b");
        std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
        build_index(project, "fn on_b() {}", "200");
        layout::sync_v13_layout(project, "proj").unwrap();

        // Switch back to "a": a restore is now pending. Inject the failure.
        write_fake_git_head(project, "a");
        assert!(branches::branch_switch_pending(project));
        let lock_path = layout::container_dir(project).join(".semantex.lock");
        let _ = std::fs::remove_file(&lock_path);
        std::fs::create_dir_all(&lock_path).unwrap();

        let cache = SearcherCache {
            reconcile_retry_cooldown: Duration::from_millis(100),
            ..SearcherCache::new(test_config())
        };

        // Degraded, not poisoned: get() succeeds and serves the CURRENT
        // (unreconciled, old-branch) root content under the new branch key.
        let degraded = cache.get(project).unwrap();
        assert_eq!(
            read_chunk_content(&degraded.searcher),
            "fn on_b() {}",
            "a failed reconcile must degrade to serving the current index, not error out"
        );
        assert!(
            branches::branch_switch_pending(project),
            "a failed reconcile must leave the switch pending (no tombstone) so it is retried"
        );
        let opens_after_failure = cache.open_count();

        // Within the cooldown: no retry (no flock attempt, no cache drop) —
        // the same cached searcher is served, proving no per-request thrash.
        let during_cooldown = cache.get(project).unwrap();
        assert!(
            Arc::ptr_eq(&degraded, &during_cooldown),
            "requests during the failure cooldown must reuse the cached searcher"
        );
        assert_eq!(
            cache.open_count(),
            opens_after_failure,
            "no reopen may happen during the failure cooldown"
        );

        // Heal the injection, release OUR handles (on Windows an open
        // searcher would legitimately block the restore's rename-over), and
        // let the cooldown lapse: the next get() retries and succeeds.
        std::fs::remove_dir(&lock_path).unwrap();
        drop(degraded);
        drop(during_cooldown);
        std::thread::sleep(Duration::from_millis(150));

        let healed = cache.get(project).unwrap();
        assert_eq!(
            read_chunk_content(&healed.searcher),
            "fn on_a() {}",
            "after the cooldown the reconcile must be retried and serve the restored branch"
        );
        assert!(!branches::branch_switch_pending(project));
    }

    /// `SEMANTEX_DAEMON_MAX_SEARCHERS` cap override actually bounds the
    /// resident entry count end-to-end (not just the parsing unit test
    /// above).
    #[test]
    fn cap_env_override_bounds_resident_entries() {
        let tmp_a = TempDir::new().unwrap();
        let tmp_b = TempDir::new().unwrap();
        let tmp_c = TempDir::new().unwrap();
        for tmp in [&tmp_a, &tmp_b, &tmp_c] {
            write_fake_git_head(tmp.path(), "main");
            build_index(tmp.path(), "x", "0");
        }

        let cache = SearcherCache {
            max_cached: 1,
            ..SearcherCache::new(test_config())
        };
        let _a = cache.get(tmp_a.path()).unwrap();
        assert_eq!(cache.len(), 1);
        let _b = cache.get(tmp_b.path()).unwrap();
        assert_eq!(
            cache.len(),
            1,
            "cap=1 must evict before inserting the second entry"
        );
        let _c = cache.get(tmp_c.path()).unwrap();
        assert_eq!(cache.len(), 1);
    }

    /// `seeded` must reuse the given searcher (not open a second one) and
    /// make it immediately available under the project's current branch key.
    #[test]
    fn seeded_reuses_the_given_searcher() {
        let tmp = TempDir::new().unwrap();
        write_fake_git_head(tmp.path(), "main");
        build_index(tmp.path(), "seeded content", "0");

        let index_dir = layout::container_dir(tmp.path());
        let searcher = HybridSearcher::open(&index_dir, &test_config()).unwrap();
        let cache = SearcherCache::seeded(test_config(), tmp.path(), searcher);
        assert_eq!(cache.len(), 1);

        let cached = cache.get(tmp.path()).unwrap();
        assert_eq!(read_chunk_content(&cached.searcher), "seeded content");
    }

    /// `invalidate_project` drops every entry for a project regardless of
    /// branch key.
    #[test]
    fn invalidate_project_drops_all_branch_entries() {
        let tmp = TempDir::new().unwrap();
        write_fake_git_head(tmp.path(), "main");
        build_index(tmp.path(), "x", "0");

        let cache = SearcherCache::new(test_config());
        let _ = cache.get(tmp.path()).unwrap();
        assert_eq!(cache.len(), 1);
        cache.invalidate_project(tmp.path());
        assert_eq!(cache.len(), 0);
    }
}