diff --git a/changelog.d/unreleased/1574.fixed.md b/changelog.d/unreleased/1574.fixed.md new file mode 100644 index 0000000000..991db98990 --- /dev/null +++ b/changelog.d/unreleased/1574.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1574 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **`SuggestionStore` no longer leaks `.tmp` files on save failure (#1574)** — `SuggestionStore.SaveUnlocked` previously wrote to `.tmp` and then called `File.Move`, but had no cleanup path if either step threw (e.g. transient filesystem error or anti-virus interference), so repeated failures accumulated orphan `.tmp` files in `.cdidx/`. The write/rename is now wrapped in a `try` / `catch` that best-effort deletes the temp file before rethrowing, keeping the directory free of stale in-progress artifacts. + +## 日本語 + +- **`SuggestionStore` が保存失敗時に `.tmp` を残さなくなりました (#1574)** — `SuggestionStore.SaveUnlocked` は `.tmp` への書き込み後に `File.Move` を呼んでいましたが、どちらかが失敗した場合(一時的なファイルシステムエラーやアンチウイルス干渉など)にクリーンアップ経路が無く、失敗を繰り返すと `.cdidx/` に孤児 `.tmp` が蓄積していました。書き込み / リネームを `try` / `catch` で包み、再 throw する前に一時ファイルをベストエフォートで削除するようにしたため、進行中アーティファクトの残骸でディレクトリが汚れなくなります。 diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 39a519dbfb..c67f8ccee3 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -229,9 +229,13 @@ private List ReadUnlocked() /// /// Write suggestions atomically without acquiring the lock (caller must hold it). - /// Uses write-to-temp-and-rename to prevent partial writes. + /// Uses write-to-temp-and-rename to prevent partial writes. If either the write + /// or the rename throws, the temp file is best-effort deleted so that repeated + /// failures do not accumulate orphaned .tmp files in .cdidx/. /// ロックを取得せずにアトミックに提案を書き込む(呼び出し元がロックを保持していること)。 /// 部分書き込みを防ぐため一時ファイル→リネームを使用。 + /// write または rename が失敗した場合、一時ファイルをベストエフォートで削除して + /// .cdidx/ に孤児 .tmp が蓄積するのを防ぐ。 /// private void SaveUnlocked(List records) { @@ -241,8 +245,16 @@ private void SaveUnlocked(List records) var tempPath = _filePath + ".tmp"; var json = JsonSerializer.Serialize(records, s_jsonOptions); - File.WriteAllText(tempPath, json); - File.Move(tempPath, _filePath, overwrite: true); + try + { + File.WriteAllText(tempPath, json); + File.Move(tempPath, _filePath, overwrite: true); + } + catch + { + try { File.Delete(tempPath); } catch { /* best-effort cleanup / ベストエフォートのクリーンアップ */ } + throw; + } } /// diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 6e49d123fc..7182426728 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -230,6 +230,26 @@ public void AtomicWrite_SurvivesAddAfterCorruption() Assert.Equal("Post-corruption suggestion", all[0].Description); } + [Fact] + public void TryAdd_MoveFailure_DoesNotLeaveOrphanTmpFile() + { + // Force File.Move to fail by pre-creating the destination as a directory. + // The write-to-temp succeeds, but the rename onto a directory throws and + // the temp file must be cleaned up so `.cdidx/` does not accumulate orphans (#1574). + // File.Move を失敗させるため、宛先パスをディレクトリとして事前作成する。 + // 一時ファイルへの書き込みは成功するが、ディレクトリに対する rename は失敗するため、 + // `.cdidx/` に孤児が蓄積しないよう一時ファイルがクリーンアップされる必要がある (#1574)。 + var filePath = Path.Combine(_tempDir, "suggestions-codeindex.json"); + var tmpPath = filePath + ".tmp"; + Directory.CreateDirectory(filePath); + + var record = MakeRecord("other", null, "Move failure cleanup"); + var ex = Record.Exception(() => _store.TryAdd(record)); + + Assert.NotNull(ex); + Assert.False(File.Exists(tmpPath), $"Orphan .tmp file should be cleaned up after Move failure: {tmpPath}"); + } + // --- Helpers / ヘルパー --- private static SuggestionRecord MakeRecord(string category, string? language, string description)