diff --git a/changelog.d/unreleased/1410.fixed.md b/changelog.d/unreleased/1410.fixed.md new file mode 100644 index 0000000000..7150f73d5a --- /dev/null +++ b/changelog.d/unreleased/1410.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1410 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Suggestion storage now flushes temp files before replacement (#1410)** — local suggestion writes serialize to a temp file, flush it to disk before renaming, and preserve zero-byte stores as `.bak` files instead of silently treating them as empty. + +## 日本語 + +- **提案ストアが置換前に一時ファイルをディスクへ flush するようになりました (#1410)** — ローカル提案の書き込みは一時ファイルへ serialize して rename 前にディスクへ flush し、zero-byte のストアは空として黙って扱わず `.bak` として退避します。 diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index ea57c5f50f..ceaf7739cb 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -379,6 +379,12 @@ private List ReadUnlocked() if (!File.Exists(ioPath)) return new List(); + if (new FileInfo(ioPath).Length == 0) + { + PreserveCorruptFile(); + return new List(); + } + var json = File.ReadAllText(ioPath); if (string.IsNullOrWhiteSpace(json)) return new List(); @@ -547,7 +553,10 @@ private List ReadFilteredUnlocked( var snapshot = File.ReadAllBytes(ioPath); if (snapshot.Length == 0) + { + PreserveCorruptFile(); return new List(); + } if (IsEmptyOrJsonWhitespace(snapshot)) return new List(); @@ -650,10 +659,18 @@ private void SaveUnlocked(List records) NormalizeRecordDefaults(records); var tempPath = _filePath + ".tmp"; - var json = JsonSerializer.Serialize(records, s_jsonOptions); try { - File.WriteAllText(tempPath, json); + using (var stream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None)) + { + JsonSerializer.Serialize(stream, records, s_jsonOptions); + stream.Flush(flushToDisk: true); + } + File.Move(tempPath, _filePath, overwrite: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 7a0dbdad4f..70a53ce764 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -645,6 +645,34 @@ public void CorruptFile_IsPreservedAsBackup() Assert.False(File.Exists(filePath), "Original corrupt file should be removed"); } + [Fact] + public void ZeroByteFile_IsPreservedAsBackup() + { + var filePath = Path.Combine(_tempDir, "suggestions-codeindex.json"); + var backupPath = filePath + ".bak"; + File.WriteAllBytes(filePath, Array.Empty()); + + var all = _store.LoadAll(); + + Assert.Empty(all); + Assert.True(File.Exists(backupPath), "Zero-byte file should be preserved as .bak"); + Assert.False(File.Exists(filePath), "Original zero-byte file should be removed"); + } + + [Fact] + public void FilteredZeroByteFile_IsPreservedAsBackup() + { + var filePath = Path.Combine(_tempDir, "suggestions-codeindex.json"); + var backupPath = filePath + ".bak"; + File.WriteAllBytes(filePath, Array.Empty()); + + var all = _store.LoadByCategory("other"); + + Assert.Empty(all); + Assert.True(File.Exists(backupPath), "Zero-byte file should be preserved as .bak"); + Assert.False(File.Exists(filePath), "Original zero-byte file should be removed"); + } + [Fact] public void AtomicWrite_SurvivesAddAfterCorruption() {