From fc53015811b3e0169f3627d71086db26ef0f3c2a Mon Sep 17 00:00:00 2001 From: sven-n Date: Thu, 21 May 2026 08:19:25 +0200 Subject: [PATCH] Fix WAL file-handle leak in disk store recovery RecoverFromWalOrIndex in both BasicDiskVocabularyStore and BasicDiskVectorStore opened the WAL file with `using var fs`, which keeps the handle alive until the end of the method. The subsequent File.WriteAllBytes(_walPath, ...) call then raced against that handle on Windows and threw IOException("The process cannot access the file ... because it is being used by another process."), causing AddAndGetText_PersistsToDisk and Delete_RemovesFromIndexButKeepsFile to fail intermittently. Scope the FileStream/BinaryReader in an explicit `using (...)` block so they are disposed before the truncating write. Co-Authored-By: Claude Opus 4.7 --- .../VectorStore/BasicDiskVectorStore.cs | 53 +++++++++++-------- .../Vocabulary/BasicDiskVocabularyStore.cs | 29 ++++++---- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/Build5Nines.SharpVector/VectorStore/BasicDiskVectorStore.cs b/src/Build5Nines.SharpVector/VectorStore/BasicDiskVectorStore.cs index 137d725..5cabe01 100644 --- a/src/Build5Nines.SharpVector/VectorStore/BasicDiskVectorStore.cs +++ b/src/Build5Nines.SharpVector/VectorStore/BasicDiskVectorStore.cs @@ -191,33 +191,40 @@ private void RecoverFromWalOrIndex() // Replay WAL to recover any operations after the last checkpoint if (!File.Exists(_walPath)) return; - using var fs = new FileStream(_walPath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var br = new BinaryReader(fs); - while (fs.Position < fs.Length) + + // Scope the read handles so the file is closed before we overwrite it + // below; `using var` would otherwise hold the handle until the end of + // the method and File.WriteAllBytes would race against it on Windows. + using (var fs = new FileStream(_walPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var br = new BinaryReader(fs)) { - bool isDelete = br.ReadBoolean(); - var idJson = br.ReadString(); - var id = JsonSerializer.Deserialize(idJson)!; - if (isDelete) - { - _index.TryRemove(id, out _); - _cache.TryRemove(id, out _); - } - else + while (fs.Position < fs.Length) { - var itemJson = br.ReadString(); - var item = JsonSerializer.Deserialize>(itemJson)!; - - // Append item to items file to bring storage up-to-date - using var ofs = new FileStream(_itemsPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); - ofs.Seek(0, SeekOrigin.End); - var offset = ofs.Position; - WriteItem(ofs, item); - ofs.Flush(true); - _index[id] = offset; - _cache[id] = item; + bool isDelete = br.ReadBoolean(); + var idJson = br.ReadString(); + var id = JsonSerializer.Deserialize(idJson)!; + if (isDelete) + { + _index.TryRemove(id, out _); + _cache.TryRemove(id, out _); + } + else + { + var itemJson = br.ReadString(); + var item = JsonSerializer.Deserialize>(itemJson)!; + + // Append item to items file to bring storage up-to-date + using var ofs = new FileStream(_itemsPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); + ofs.Seek(0, SeekOrigin.End); + var offset = ofs.Position; + WriteItem(ofs, item); + ofs.Flush(true); + _index[id] = offset; + _cache[id] = item; + } } } + // After successful replay, truncate WAL (commit) File.WriteAllBytes(_walPath, Array.Empty()); PersistIndex(); diff --git a/src/Build5Nines.SharpVector/Vocabulary/BasicDiskVocabularyStore.cs b/src/Build5Nines.SharpVector/Vocabulary/BasicDiskVocabularyStore.cs index 2b1c6cd..3552c18 100644 --- a/src/Build5Nines.SharpVector/Vocabulary/BasicDiskVocabularyStore.cs +++ b/src/Build5Nines.SharpVector/Vocabulary/BasicDiskVocabularyStore.cs @@ -118,23 +118,30 @@ private void RecoverFromWalOrIndex() { LoadIfExists(); if (!File.Exists(_walPath)) return; - using var fs = new FileStream(_walPath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var br = new BinaryReader(fs); - while (fs.Position < fs.Length) + + // Scope the read handles so the file is closed before we overwrite it + // below; `using var` would otherwise hold the handle until the end of + // the method and File.WriteAllBytes would race against it on Windows. + using (var fs = new FileStream(_walPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var br = new BinaryReader(fs)) { - int count = br.ReadInt32(); - for (int i = 0; i < count; i++) + while (fs.Position < fs.Length) { - var tokenJson = br.ReadString(); - var token = JsonSerializer.Deserialize(tokenJson)!; - if (!_vocab.ContainsKey(token)) + int count = br.ReadInt32(); + for (int i = 0; i < count; i++) { - var idx = _vocab.Count; - _vocab[token] = idx; - _cache[token] = idx; + var tokenJson = br.ReadString(); + var token = JsonSerializer.Deserialize(tokenJson)!; + if (!_vocab.ContainsKey(token)) + { + var idx = _vocab.Count; + _vocab[token] = idx; + _cache[token] = idx; + } } } } + File.WriteAllBytes(_walPath, Array.Empty()); Persist(); }