Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1574.fixed.md
Original file line number Diff line number Diff line change
@@ -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 `<file>.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` は `<file>.tmp` への書き込み後に `File.Move` を呼んでいましたが、どちらかが失敗した場合(一時的なファイルシステムエラーやアンチウイルス干渉など)にクリーンアップ経路が無く、失敗を繰り返すと `.cdidx/` に孤児 `.tmp` が蓄積していました。書き込み / リネームを `try` / `catch` で包み、再 throw する前に一時ファイルをベストエフォートで削除するようにしたため、進行中アーティファクトの残骸でディレクトリが汚れなくなります。
18 changes: 15 additions & 3 deletions src/CodeIndex/Cli/SuggestionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,13 @@ private List<SuggestionRecord> ReadUnlocked()

/// <summary>
/// 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 <c>.tmp</c> files in <c>.cdidx/</c>.
/// ロックを取得せずにアトミックに提案を書き込む(呼び出し元がロックを保持していること)。
/// 部分書き込みを防ぐため一時ファイル→リネームを使用。
/// write または rename が失敗した場合、一時ファイルをベストエフォートで削除して
/// <c>.cdidx/</c> に孤児 <c>.tmp</c> が蓄積するのを防ぐ。
/// </summary>
private void SaveUnlocked(List<SuggestionRecord> records)
{
Expand All @@ -241,8 +245,16 @@ private void SaveUnlocked(List<SuggestionRecord> 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;
}
}

/// <summary>
Expand Down
20 changes: 20 additions & 0 deletions tests/CodeIndex.Tests/SuggestionStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading