From c1e4bb0da32a260c4cdec4b09855cc52c679b2ff Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:16:38 +0900 Subject: [PATCH 1/7] Fix MCP writable probe cleanup for #3023 --- changelog.d/unreleased/3023.fixed.md | 16 ++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 28 ++++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 52 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3023.fixed.md diff --git a/changelog.d/unreleased/3023.fixed.md b/changelog.d/unreleased/3023.fixed.md new file mode 100644 index 0000000000..eca203269a --- /dev/null +++ b/changelog.d/unreleased/3023.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3023 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP writable-directory probes now clean up best-effort (#3023)** — `suggest_improvement` keeps writable `.cdidx` probes from turning cleanup failures into false unwritable-directory errors and reports cleanup failures as warnings instead. + +## 日本語 + +- **MCP の writable-directory probe を best-effort cleanup にしました (#3023)** — `suggest_improvement` は `.cdidx` の書き込み probe で cleanup 失敗を誤った書き込み不可エラーにせず、warning として報告するようになりました。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 33878b3411..d6dba025ad 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -5040,12 +5040,13 @@ private static bool IsSamplingEnabled() private static bool TryProbeCdidxDirectoryWritable(string cdidxDir, out string? error) { var probePath = Path.Combine(cdidxDir, $".write_probe.{Guid.NewGuid():N}.tmp"); + var createdProbe = false; try { using (new FileStream(probePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { } - File.Delete(probePath); + createdProbe = true; error = null; return true; } @@ -5054,8 +5055,33 @@ private static bool TryProbeCdidxDirectoryWritable(string cdidxDir, out string? error = $"Cannot write to .cdidx directory {cdidxDir}; check directory ownership, permissions, and read-only mounts. {ex.Message}"; return false; } + finally + { + if (createdProbe) + TryDeleteCdidxDirectoryWritableProbe(probePath); + } } + private static void TryDeleteCdidxDirectoryWritableProbe(string probePath) + { + try + { + if (!File.Exists(probePath)) + return; + + if (DeleteCdidxDirectoryWritableProbeForTesting != null) + DeleteCdidxDirectoryWritableProbeForTesting(probePath); + else + File.Delete(probePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Warning: failed to delete .cdidx writable probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + + internal static Action? DeleteCdidxDirectoryWritableProbeForTesting { get; set; } + private static CSharpStaticInterfaceWorkspaceSymbols BuildMcpCSharpStaticInterfaceWorkspaceSymbols( DbWriter writer, FileIndexer indexer, diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 91bc1c3758..52ce3cae6b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -12390,6 +12390,58 @@ public void SuggestImprovement_WriteProbePreservesExistingProbeFile() Assert.Equal("keep me", File.ReadAllText(existingProbe)); } + [Fact] + public void SuggestImprovement_WriteProbeCleanupFailureWarnsWithoutFailing_Issue3023() + { + var cdidxDir = Path.GetDirectoryName(_dbPath)!; + var uniqueDesc = $"Probe cleanup regression {Guid.NewGuid():N}"; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "suggest_improvement", + ["arguments"] = new JsonObject + { + ["category"] = "other", + ["description"] = uniqueDesc, + }, + }, + }; + + JsonNode response; + using var stderr = new StringWriter(); + lock (TestConsoleLock.Gate) + { + var previousError = Console.Error; + Console.SetError(stderr); + try + { + McpServer.DeleteCdidxDirectoryWritableProbeForTesting = _ => throw new IOException("simulated probe cleanup failure"); + response = _server.HandleMessage(request)!; + } + finally + { + McpServer.DeleteCdidxDirectoryWritableProbeForTesting = null; + Console.SetError(previousError); + } + } + + try + { + Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + Assert.Contains("Warning: failed to delete .cdidx writable probe", stderr.ToString()); + Assert.Contains("IOException", stderr.ToString()); + } + finally + { + foreach (var leftover in Directory.GetFiles(cdidxDir, ".write_probe.*.tmp")) + DeleteFileRobust(leftover); + } + } + [Fact] public void SuggestImprovement_InvalidCategory_ReturnsError() { From 064bf2e6afdd04459fe9adf717c3081fceafe766 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:17:07 +0900 Subject: [PATCH 2/7] Fix install probe cleanup for #3024 --- changelog.d/unreleased/3024.fixed.md | 13 +++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 32 +++++++++++++++++++-- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 30 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/3024.fixed.md diff --git a/changelog.d/unreleased/3024.fixed.md b/changelog.d/unreleased/3024.fixed.md new file mode 100644 index 0000000000..b7e6c19d8a --- /dev/null +++ b/changelog.d/unreleased/3024.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 3024 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- +## English +- **Install-dir write probes now clean up best-effort (#3024)** — Upgrade preflight write checks now delete their temporary probe in a `finally` block and warn if cleanup fails without turning a writable directory into a false negative. + +## 日本語 +- **install-dir の書き込み probe を best-effort cleanup にしました (#3024)** — upgrade の事前書き込み確認で一時 probe を `finally` で削除し、cleanup 失敗は警告にして書き込み可能な directory を失敗扱いにしないようにしました。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 3d25dd91bf..37beb1a573 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -40,6 +40,7 @@ internal static class ProgramRunner CliFlagSchema.GetTopLevelValueOptionNames(); internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; internal static Func UpgradeHttpClientFactory { get; set; } = CreateUpgradeHttpClient; + internal static Action? DeleteInstallDirectoryWriteProbeForTesting { get; set; } private sealed record CommandRunContext( JsonSerializerOptions JsonOptions, @@ -3348,20 +3349,45 @@ await BoundedHttpContentReader.WriteToPrivateFileAsync( downloadCts.Token).ConfigureAwait(false); } - private static bool CanWriteDirectory(string directory) + internal static bool CanWriteDirectory(string directory) { + string? probe = null; + var createdProbe = false; try { Directory.CreateDirectory(directory); - var probe = Path.Combine(directory, $".cdidx-write-test-{Guid.NewGuid():N}"); + probe = Path.Combine(directory, $".cdidx-write-test-{Guid.NewGuid():N}"); File.WriteAllText(probe, ""); - File.Delete(probe); + createdProbe = true; return true; } catch { return false; } + finally + { + if (createdProbe && probe != null) + TryDeleteInstallDirectoryWriteProbe(probe); + } + } + + private static void TryDeleteInstallDirectoryWriteProbe(string probePath) + { + try + { + if (!File.Exists(probePath)) + return; + + if (DeleteInstallDirectoryWriteProbeForTesting != null) + DeleteInstallDirectoryWriteProbeForTesting(probePath); + else + File.Delete(probePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Warning: failed to delete install directory write probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } } private static int ToWaitMilliseconds(TimeSpan timeout) diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index cc90f83b16..76d822827e 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -103,6 +103,36 @@ public void RunLanguages_PrettyJson_IndentsOutput_Issue2996() Assert.True(document.RootElement.TryGetProperty("languages", out _)); } + [Fact] + public void CanWriteDirectory_ProbeCleanupFailureWarnsWithoutFailing_Issue3024() + { + lock (TestConsoleLock.Gate) + { + var directory = Path.Combine(Path.GetTempPath(), $"cdidx_install_probe_cleanup_{Guid.NewGuid():N}"); + var originalError = Console.Error; + using var stderr = new StringWriter(CultureInfo.InvariantCulture); + try + { + Directory.CreateDirectory(directory); + ProgramRunner.DeleteInstallDirectoryWriteProbeForTesting = _ => throw new IOException("simulated probe cleanup failure"); + Console.SetError(stderr); + + Assert.True(ProgramRunner.CanWriteDirectory(directory)); + + var warning = stderr.ToString(); + Assert.Contains("Warning: failed to delete install directory write probe", warning); + Assert.Contains("IOException", warning); + Assert.Single(Directory.GetFiles(directory, ".cdidx-write-test-*", SearchOption.TopDirectoryOnly)); + } + finally + { + ProgramRunner.DeleteInstallDirectoryWriteProbeForTesting = null; + Console.SetError(originalError); + TestProjectHelper.DeleteDirectory(directory); + } + } + } + [Fact] public void RunSearch_FirstQueryLiteralMatchingPrettyFlag_IsNotConsumed_Issue2996() { From ef996c02b39a7302f5c8cfc0aeb871f169a8a315 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:18:20 +0900 Subject: [PATCH 3/7] Move case probes under cdidx data dir for #3174 --- changelog.d/unreleased/3174.fixed.md | 16 +++ src/CodeIndex/Cli/GitHelper.cs | 5 +- .../Scanning/CaseSensitivityProbeDirectory.cs | 111 ++++++++++++++++++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 3 +- tests/CodeIndex.Tests/FileIndexerTests.cs | 39 ++++++ tests/CodeIndex.Tests/GitHelperTests.cs | 29 ++++- 6 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/3174.fixed.md create mode 100644 src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs diff --git a/changelog.d/unreleased/3174.fixed.md b/changelog.d/unreleased/3174.fixed.md new file mode 100644 index 0000000000..eb9f35879a --- /dev/null +++ b/changelog.d/unreleased/3174.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3174 +affected: + - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs + - tests/CodeIndex.Tests/GitHelperTests.cs +--- +## English +- **Case-sensitivity probes no longer write hidden files at workspace roots (#3174)** — Git and file-indexer filesystem probes now prefer a read-only root path-variant check, fall back to `.cdidx/probes` only when a write probe is needed, and remove probe-only files/directories after detection. + +## 日本語 +- **case-sensitivity probe が workspace root 直下に隠しファイルを書かないようにしました (#3174)** — Git と file-indexer の filesystem probe は read-only の root path-variant check を優先し、write probe が必要な場合だけ `.cdidx/probes` に fallback して、判定後に probe 専用の file/directory を削除します。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 8c13900765..ebc51e46ef 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -916,7 +916,8 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) if (TryProbeExistingDirectoryPath(normalizedRoot, out var ignoreCase)) return ignoreCase; - var probePath = Path.Combine(normalizedRoot, $".cdidx_case_probe_{Guid.NewGuid():N}"); + using var probe = CaseSensitivityProbeDirectory.CreateProbePathScope(normalizedRoot, "case-probe-"); + var probePath = probe.Path; var ioProbePath = LongPath.EnsureWindowsPrefix(probePath); File.WriteAllText(ioProbePath, string.Empty); try @@ -944,7 +945,7 @@ private static bool TryProbeExistingDirectoryPath(string path, out bool ignoreCa if (!TryCreateCaseVariant(path, out var variant)) return false; - ignoreCase = Directory.Exists(variant); + ignoreCase = Directory.Exists(LongPath.EnsureWindowsPrefix(variant)); return true; } diff --git a/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs b/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs new file mode 100644 index 0000000000..c6222bf73c --- /dev/null +++ b/src/CodeIndex/Indexer/Scanning/CaseSensitivityProbeDirectory.cs @@ -0,0 +1,111 @@ +using System.Runtime.InteropServices; + +namespace CodeIndex.Indexer; + +internal static class CaseSensitivityProbeDirectory +{ + internal const string DataDirectoryName = ".cdidx"; + internal const string ProbeDirectoryName = "probes"; + + private const UnixFileMode PrivateDirectoryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + internal static ProbePathScope CreateProbePathScope(string projectRoot, string prefix) + { + var directory = CreateProbeDirectory(projectRoot); + return new ProbePathScope( + Path.Combine(directory.ProbeDirectory, $"{prefix}{Guid.NewGuid():N}"), + directory.ProbeDirectory, + directory.DataDirectory, + directory.CreatedProbeDirectory, + directory.CreatedDataDirectory); + } + + internal static ProbeDirectoryScope CreateProbeDirectory(string projectRoot) + { + var normalizedRoot = Path.GetFullPath(projectRoot); + var cdidxDirectory = Path.Combine(normalizedRoot, DataDirectoryName); + var createdDataDirectory = !Directory.Exists(LongPath.EnsureWindowsPrefix(cdidxDirectory)); + CreatePrivateDirectory(cdidxDirectory); + + var probeDirectory = Path.Combine(cdidxDirectory, ProbeDirectoryName); + var createdProbeDirectory = !Directory.Exists(LongPath.EnsureWindowsPrefix(probeDirectory)); + CreatePrivateDirectory(probeDirectory); + return new ProbeDirectoryScope(cdidxDirectory, probeDirectory, createdDataDirectory, createdProbeDirectory); + } + + internal sealed class ProbePathScope : IDisposable + { + private readonly string _probeDirectory; + private readonly string _dataDirectory; + private readonly bool _createdProbeDirectory; + private readonly bool _createdDataDirectory; + private bool _disposed; + + internal ProbePathScope( + string path, + string probeDirectory, + string dataDirectory, + bool createdProbeDirectory, + bool createdDataDirectory) + { + Path = path; + _probeDirectory = probeDirectory; + _dataDirectory = dataDirectory; + _createdProbeDirectory = createdProbeDirectory; + _createdDataDirectory = createdDataDirectory; + } + + internal string Path { get; } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + TryDeleteCreatedEmptyDirectory(_probeDirectory, _createdProbeDirectory); + TryDeleteCreatedEmptyDirectory(_dataDirectory, _createdDataDirectory); + } + } + + internal readonly record struct ProbeDirectoryScope( + string DataDirectory, + string ProbeDirectory, + bool CreatedDataDirectory, + bool CreatedProbeDirectory); + + private static void CreatePrivateDirectory(string path) + { + Directory.CreateDirectory(LongPath.EnsureWindowsPrefix(path)); + ApplyPrivateDirectoryMode(path); + } + + private static void ApplyPrivateDirectoryMode(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + File.SetUnixFileMode(LongPath.EnsureWindowsPrefix(path), PrivateDirectoryMode); + } + + private static void TryDeleteCreatedEmptyDirectory(string path, bool createdForProbe) + { + if (!createdForProbe) + return; + + try + { + Directory.Delete(LongPath.EnsureWindowsPrefix(path)); + } + catch (DirectoryNotFoundException) + { + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 004b67d339..5dcd9460e6 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -1055,7 +1055,8 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) if (TryCreateCaseVariant(normalizedRoot, out var rootVariant)) return Directory.Exists(LongPath.EnsureWindowsPrefix(rootVariant)); - var probePath = Path.Combine(normalizedRoot, $".cdidx_case_probe_{Guid.NewGuid():N}"); + using var probe = CaseSensitivityProbeDirectory.CreateProbePathScope(normalizedRoot, "case-probe-"); + var probePath = probe.Path; var prefixedProbePath = LongPath.EnsureWindowsPrefix(probePath); File.WriteAllText(prefixedProbePath, string.Empty); try diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index b8c76abe62..397aa41a24 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -48,6 +48,45 @@ public void ScanFilesDetailed_CancelledToken_ThrowsBeforeEnumeration() } } + [Fact] + public void Constructor_CaseProbeAvoidsRootProbeArtifacts_Issue3174() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx-case-probe-indexer-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + _ = new FileIndexer(tempDir); + + Assert.Empty(Directory.GetFiles(tempDir, ".cdidx_case_probe_*", SearchOption.TopDirectoryOnly)); + Assert.False(Directory.Exists(Path.Combine(tempDir, CaseSensitivityProbeDirectory.DataDirectoryName))); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + + [Fact] + public void Constructor_CaseProbePreservesExistingCdidxDirectory_Issue3174() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx-case-probe-existing-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + var dataDirectory = Path.Combine(tempDir, CaseSensitivityProbeDirectory.DataDirectoryName); + Directory.CreateDirectory(dataDirectory); + + _ = new FileIndexer(tempDir); + + Assert.True(Directory.Exists(dataDirectory)); + Assert.False(Directory.Exists(Path.Combine(dataDirectory, CaseSensitivityProbeDirectory.ProbeDirectoryName))); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + [Fact] public void ScanFilesDetailed_CaseInsensitiveChildDirectory_SkipsCaseOnlyDuplicatePathWithWarning() { diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 2b59b03086..0a3f0d623c 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using CodeIndex.Cli; +using CodeIndex.Indexer; namespace CodeIndex.Tests; @@ -840,6 +841,28 @@ public void ResolveIgnoreCase_NonRepoIgnoresGlobalGitConfigAndFallsBackToFileSys Assert.Equal(expected, resolved); } + [Fact] + public void ResolveIgnoreCase_NonRepoProbeAvoidsRootProbeArtifacts_Issue3174() + { + var nonRepoDir = Path.Combine(_tempDir, $"non_repo_probe_{Guid.NewGuid():N}"); + var fakeHome = Path.Combine(_tempDir, $"fake_home_{Guid.NewGuid():N}"); + Directory.CreateDirectory(nonRepoDir); + Directory.CreateDirectory(fakeHome); + var environment = new Dictionary + { + ["HOME"] = fakeHome, + ["XDG_CONFIG_HOME"] = Path.Combine(fakeHome, ".config"), + ["GIT_CONFIG_NOSYSTEM"] = "1", + }; + + var resolved = GitHelper.ResolveIgnoreCase(nonRepoDir, environment); + var expected = ProbeDirectoryIgnoreCaseLikeProduction(nonRepoDir); + + Assert.Equal(expected, resolved); + Assert.Empty(Directory.GetFiles(nonRepoDir, ".cdidx_case_probe_*", SearchOption.TopDirectoryOnly)); + Assert.False(Directory.Exists(Path.Combine(nonRepoDir, CaseSensitivityProbeDirectory.DataDirectoryName))); + } + private string CreateGitRepo() { var repoDir = Path.Combine(_tempDir, $"repo_{Guid.NewGuid():N}"); @@ -985,10 +1008,8 @@ exit 1 private static bool ProbeDirectoryIgnoreCaseLikeProduction(string path) { - if (TryCreateCaseVariant(path, out var variant)) - return Directory.Exists(variant); - - var probePath = Path.Combine(path, $".cdidx_case_probe_test_{Guid.NewGuid():N}"); + using var probe = CaseSensitivityProbeDirectory.CreateProbePathScope(path, "case-probe-test-"); + var probePath = probe.Path; File.WriteAllText(probePath, string.Empty); try { From d4e4a08a59973a2265491586f621084e804bae01 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:19:20 +0900 Subject: [PATCH 4/7] Warn on import export temp cleanup failures for #3032 --- changelog.d/unreleased/3032.fixed.md | 13 +++ .../Cli/ExportImportCommandRunner.cs | 25 +++-- .../ExportImportCommandRunnerTests.cs | 101 ++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/3032.fixed.md diff --git a/changelog.d/unreleased/3032.fixed.md b/changelog.d/unreleased/3032.fixed.md new file mode 100644 index 0000000000..6f361d5465 --- /dev/null +++ b/changelog.d/unreleased/3032.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 3032 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +--- +## English +- **Import/export temporary database cleanup failures are now visible (#3032)** — Archive import/export now emits stderr warnings when deleting temporary database files or their SQLite sidecars fails, while preserving the original command result. + +## 日本語 +- **import/export の一時 DB cleanup 失敗を可視化しました (#3032)** — archive import/export で一時 database file や SQLite sidecar の削除に失敗した場合、元の command 結果を保ったまま stderr に警告を出します。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index f2ca1b1883..de137fbf30 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -125,8 +125,8 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) } finally { - try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } - try { DeleteSqliteSidecars(tempPath); } catch { } + TryDeleteFile(tempPath, "import temporary database"); + DeleteSqliteSidecars(tempPath, "import temporary database sidecar"); } } @@ -206,8 +206,8 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt } finally { - try { if (File.Exists(snapshotPath)) File.Delete(snapshotPath); } catch { } - try { DeleteSqliteSidecars(snapshotPath); } catch { } + TryDeleteFile(snapshotPath, "export temporary database"); + DeleteSqliteSidecars(snapshotPath, "export temporary database sidecar"); } } @@ -581,29 +581,34 @@ internal static void ReplaceImportedDatabase(string tempPath, string fullDbPath) DeleteSqliteSidecars(fullDbPath); } - private static void DeleteSqliteSidecars(string dbPath) + private static void DeleteSqliteSidecars(string dbPath, string? cleanupDescription = null) { - TryDeleteFile(dbPath + "-wal"); - TryDeleteFile(dbPath + "-shm"); + TryDeleteFile(dbPath + "-wal", cleanupDescription, DeleteSqliteSidecarForTesting); + TryDeleteFile(dbPath + "-shm", cleanupDescription, DeleteSqliteSidecarForTesting); } - private static void TryDeleteFile(string path) + private static void TryDeleteFile(string path, string? cleanupDescription = null, Action? deleteOverride = null) { try { if (!File.Exists(path)) return; - if (DeleteSqliteSidecarForTesting != null) - DeleteSqliteSidecarForTesting(path); + if (deleteOverride != null) + deleteOverride(path); + else if (DeleteFileForTesting != null) + DeleteFileForTesting(path); else File.Delete(path); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) { + if (!string.IsNullOrWhiteSpace(cleanupDescription)) + Console.Error.WriteLine($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } + internal static Action? DeleteFileForTesting { get; set; } internal static Action? DeleteSqliteSidecarForTesting { get; set; } private static bool IsSamePath(string left, string right) diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index 54b0df29a4..2cf904d5cc 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -131,6 +131,51 @@ public void RunImport_FailureOmitsRawExceptionMessage() } } + [Fact] + public void RunImport_TemporaryDatabaseCleanupFailureWarnsAndPreservesImportError_Issue3032() + { + var workDir = TestProjectHelper.CreateTempProject("import_temp_cleanup_warning"); + string? cleanupPath = null; + try + { + var manifest = $$""" + {"format_version":"1","cdidx_version":"test","user_version":0,"database_sha256":"{{new string('0', 64)}}"} + """; + var archivePath = CreateArchiveWithManifestAndDatabase(workDir, manifest, [1, 2, 3, 4]); + var dbPath = Path.Combine(workDir, "codeindex.db"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + ExportImportCommandRunner.DeleteFileForTesting = path => + { + if (Path.GetFileName(path).StartsWith(".codeindex-import-", StringComparison.Ordinal) + && path.EndsWith(".db", StringComparison.Ordinal)) + { + cleanupPath = path; + throw new IOException("simulated import temp cleanup failure"); + } + + File.Delete(path); + }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport([archivePath, "--db", dbPath], jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("archive manifest mismatch: database_sha256 does not match codeindex.db", stderr); + Assert.Contains("Warning: failed to delete import temporary database", stderr); + Assert.Contains("IOException", stderr); + Assert.NotNull(cleanupPath); + Assert.True(File.Exists(cleanupPath)); + } + finally + { + ExportImportCommandRunner.DeleteFileForTesting = null; + if (cleanupPath != null && File.Exists(cleanupPath)) + File.Delete(cleanupPath); + TestProjectHelper.DeleteDirectory(workDir); + } + } + [Fact] public void RunExportArchive_FailureOmitsRawExceptionMessage() { @@ -156,6 +201,47 @@ public void RunExportArchive_FailureOmitsRawExceptionMessage() } } + [Fact] + public void RunExportArchive_TemporaryDatabaseCleanupFailureWarnsWithoutFailing_Issue3032() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_temp_cleanup_warning"); + string? cleanupPath = null; + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var outputPath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + ExportImportCommandRunner.DeleteFileForTesting = path => + { + if (Path.GetFileName(path).StartsWith("codeindex-export-", StringComparison.Ordinal) + && path.EndsWith(".db", StringComparison.Ordinal)) + { + cleanupPath = path; + throw new IOException("simulated export temp cleanup failure"); + } + + File.Delete(path); + }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport([outputPath, "--db", dbPath], new JsonSerializerOptions(), "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("Exported CodeIndex archive", stdout); + Assert.True(File.Exists(outputPath)); + Assert.Contains("Warning: failed to delete export temporary database", stderr); + Assert.Contains("IOException", stderr); + Assert.NotNull(cleanupPath); + Assert.True(File.Exists(cleanupPath)); + } + finally + { + ExportImportCommandRunner.DeleteFileForTesting = null; + if (cleanupPath != null && File.Exists(cleanupPath)) + File.Delete(cleanupPath); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunExportArchive_RelativeOutputReportsAndWritesFullPath_Issue3138() { @@ -489,4 +575,19 @@ private static string CreateArchiveWithManifest(string workDir, string manifest) writer.Write(manifest); return archivePath; } + + private static string CreateArchiveWithManifestAndDatabase(string workDir, string manifest, byte[] databaseBytes) + { + var archivePath = Path.Combine(workDir, "codeindex-with-db.cdidx.zip"); + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + var manifestEntry = archive.CreateEntry("manifest.json"); + using (var writer = new StreamWriter(manifestEntry.Open())) + writer.Write(manifest); + + var databaseEntry = archive.CreateEntry("codeindex.db"); + using (var stream = databaseEntry.Open()) + stream.Write(databaseBytes, 0, databaseBytes.Length); + + return archivePath; + } } From 3d197b5de37544c19673cfcef9d1090ee387d499 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:19:58 +0900 Subject: [PATCH 5/7] Preserve checkpoint failure during temp cleanup for #3029 --- changelog.d/unreleased/3029.fixed.md | 13 ++++++ src/CodeIndex/Cli/DbCommandRunner.cs | 22 +++++++++- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 40 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3029.fixed.md diff --git a/changelog.d/unreleased/3029.fixed.md b/changelog.d/unreleased/3029.fixed.md new file mode 100644 index 0000000000..9b4ae62da6 --- /dev/null +++ b/changelog.d/unreleased/3029.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 3029 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- +## English +- **Checkpoint temporary cleanup no longer masks the original failure (#3029)** — Failed checkpoint creation now reports cleanup deletion failures as warnings while preserving the checkpoint error that caused rollback. + +## 日本語 +- **checkpoint 一時 cleanup 失敗が元の失敗を隠さないようにしました (#3029)** — checkpoint 作成失敗時の一時 directory 削除失敗は警告として出し、rollback の原因になった checkpoint error を保ちます。 diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 02f75cc6d0..b23888600a 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -23,6 +23,7 @@ public static class DbCommandRunner internal const int SchemaSqlTextLimit = 8192; private static readonly char[] InvalidCheckpointNameChars = Path.GetInvalidFileNameChars(); internal static Action? RestoreFailureAfterBackupForTesting { get; set; } + internal static Action? DeleteTemporaryDirectoryForTesting { get; set; } internal static Func>? IntegrityCheckRowsForTesting { get; set; } public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions) @@ -690,8 +691,7 @@ private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, s } catch { - if (Directory.Exists(tempPath)) - Directory.Delete(tempPath, recursive: true); + TryDeleteTemporaryDirectory(tempPath, "checkpoint temporary directory"); throw; } @@ -885,6 +885,24 @@ private static void DeleteIfExists(string path) File.Delete(LongPath.EnsureWindowsPrefix(path)); } + private static void TryDeleteTemporaryDirectory(string path, string cleanupDescription) + { + try + { + if (!Directory.Exists(path)) + return; + + if (DeleteTemporaryDirectoryForTesting != null) + DeleteTemporaryDirectoryForTesting(path); + else + Directory.Delete(path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + Console.Error.WriteLine($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + internal static DbCommandOptions ParseArgs(string[] args) { var dbPath = Path.Combine(".cdidx", "codeindex.db"); diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 49f4f1995d..0ee6404e8e 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -436,6 +436,46 @@ public void Run_Checkpoint_OnPosix_WritesPrivateSnapshotPermissions() } } + [Fact] + public void Run_CheckpointTempCleanupFailurePreservesOriginalFailure_Issue3029() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_cleanup_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + string? cleanupPath = null; + try + { + Directory.CreateDirectory(root); + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + + var checkpointRoot = dbPath + ".checkpoints"; + Directory.CreateDirectory(checkpointRoot); + File.WriteAllText(Path.Combine(checkpointRoot, "saved"), "checkpoint path blocker"); + DbCommandRunner.DeleteTemporaryDirectoryForTesting = path => + { + cleanupPath = path; + throw new IOException("simulated checkpoint temp cleanup failure"); + }; + + var (exitCode, _, stderr) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Contains("failed to create database checkpoint", stderr); + Assert.Contains("Warning: failed to delete checkpoint temporary directory", stderr); + Assert.Contains("IOException", stderr); + Assert.NotNull(cleanupPath); + Assert.True(Directory.Exists(cleanupPath)); + } + finally + { + DbCommandRunner.DeleteTemporaryDirectoryForTesting = null; + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CheckpointsList_JsonIncludesCreatedCheckpoint() { From 1e36b195bcf6b4bbce29fc1a8d5b664992e687d4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:20:28 +0900 Subject: [PATCH 6/7] Add collision resistant restore suffixes for #3031 --- changelog.d/unreleased/3031.fixed.md | 13 +++++ src/CodeIndex/Cli/DbCommandRunner.cs | 10 +++- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 49 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3031.fixed.md diff --git a/changelog.d/unreleased/3031.fixed.md b/changelog.d/unreleased/3031.fixed.md new file mode 100644 index 0000000000..cacf95a1ee --- /dev/null +++ b/changelog.d/unreleased/3031.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 3031 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- +## English +- **Restore staging and backup directory names now include a collision-resistant suffix (#3031)** — Restore temp and backup paths keep their timestamp while adding a GUID suffix to avoid same-millisecond collisions. + +## 日本語 +- **restore の staging / backup directory 名に衝突耐性のある suffix を追加しました (#3031)** — restore の一時 path と backup path は timestamp を維持しつつ GUID suffix を加え、同一 millisecond の衝突を避けます。 diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index b23888600a..d4fb525678 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -782,8 +782,9 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c if (!File.Exists(LongPath.EnsureWindowsPrefix(checkpointDbPath))) throw new InvalidOperationException($"checkpoint is incomplete: {FormatCheckpointNameForDiagnostic(name)}"); - var restoreTempPath = fullDbPath + ".restore-tmp-" + DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture); - var backupPath = fullDbPath + ".restore-backup-" + DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture); + var restorePathSuffix = MakeRestorePathSuffix(); + var restoreTempPath = fullDbPath + ".restore-tmp-" + restorePathSuffix; + var backupPath = fullDbPath + ".restore-backup-" + restorePathSuffix; DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); try { @@ -837,6 +838,11 @@ private static string FormatCheckpointNameForDiagnostic(string name) private static string MakeTimestampCheckpointName() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture); + private static string MakeRestorePathSuffix() + => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture) + + "-" + + Guid.NewGuid().ToString("N"); + private static string GetCheckpointRoot(string fullDbPath) => fullDbPath + CheckpointsDirectorySuffix; diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 0ee6404e8e..2403e71485 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -615,6 +615,45 @@ public void Run_RestoreFailureAfterBackup_RestoresOriginalDatabase() } } + [Fact] + public void Run_RestoreTemporaryNamesIncludeCollisionResistantSuffix_Issue3031() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_restore_suffix_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + var inspected = false; + Directory.CreateDirectory(root); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + File.WriteAllText(dbPath, "changed"); + DbCommandRunner.RestoreFailureAfterBackupForTesting = () => + { + var restoreTempPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); + var backupPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); + AssertRestoreSuffix(Path.GetFileName(restoreTempPath), "codeindex.db.restore-tmp-"); + AssertRestoreSuffix(Path.GetFileName(backupPath), "codeindex.db.restore-backup-"); + inspected = true; + }; + + var (restoreExit, _, _) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.True(inspected); + } + finally + { + DbCommandRunner.RestoreFailureAfterBackupForTesting = null; + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_Restore_OnPosix_CreatesPrivateStagingAndBackupPermissions() { @@ -783,6 +822,16 @@ public void Run_Schema_JsonCapsEntriesAndSqlText_Issue2881() return (exitCode, document.RootElement.Clone()); } + private static void AssertRestoreSuffix(string directoryName, string prefix) + { + Assert.StartsWith(prefix, directoryName); + var suffix = directoryName[prefix.Length..]; + Assert.Equal(50, suffix.Length); + Assert.True(suffix[..17].All(char.IsDigit)); + Assert.Equal('-', suffix[17]); + Assert.True(suffix[18..].All(char.IsAsciiHexDigit)); + } + private static void AssertPrivateDirectory(string path) { #pragma warning disable CA1416 From 2397d124dea009d12816ffcbbcf1d8ed88d823c7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 23:21:01 +0900 Subject: [PATCH 7/7] Warn on restore temp cleanup failures for #3030 --- changelog.d/unreleased/3030.fixed.md | 13 +++++ src/CodeIndex/Cli/DbCommandRunner.cs | 3 +- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 47 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3030.fixed.md diff --git a/changelog.d/unreleased/3030.fixed.md b/changelog.d/unreleased/3030.fixed.md new file mode 100644 index 0000000000..25b8de8d12 --- /dev/null +++ b/changelog.d/unreleased/3030.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 3030 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- +## English +- **Restore temporary cleanup no longer masks restore outcomes (#3030)** — Restore temp directory deletion failures now emit stderr warnings instead of overriding a successful restore or the original restore error. + +## 日本語 +- **restore 一時 cleanup 失敗が restore 結果を隠さないようにしました (#3030)** — restore の一時 directory 削除失敗は、成功した restore や元の restore error を上書きせず stderr 警告として出します。 diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index d4fb525678..dc66f34066 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -812,8 +812,7 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c } finally { - if (Directory.Exists(restoreTempPath)) - Directory.Delete(restoreTempPath, recursive: true); + TryDeleteTemporaryDirectory(restoreTempPath, "restore temporary directory"); } return backupPath; diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 2403e71485..cbc33829f5 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -654,6 +654,53 @@ public void Run_RestoreTemporaryNamesIncludeCollisionResistantSuffix_Issue3031() } } + [Fact] + public void Run_RestoreTempCleanupFailureWarnsWithoutFailing_Issue3030() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_restore_cleanup_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + string? cleanupPath = null; + Directory.CreateDirectory(root); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + var originalBytes = File.ReadAllBytes(dbPath); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + File.WriteAllText(dbPath, "changed"); + DbCommandRunner.DeleteTemporaryDirectoryForTesting = path => + { + if (Path.GetFileName(path).StartsWith("codeindex.db.restore-tmp-", StringComparison.Ordinal)) + { + cleanupPath = path; + throw new IOException("simulated restore temp cleanup failure"); + } + + Directory.Delete(path, recursive: true); + }; + + var (restoreExit, restoreOut, stderr) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.Contains("Restored", restoreOut); + Assert.Equal(originalBytes, File.ReadAllBytes(dbPath)); + Assert.Contains("Warning: failed to delete restore temporary directory", stderr); + Assert.Contains("IOException", stderr); + Assert.NotNull(cleanupPath); + Assert.True(Directory.Exists(cleanupPath)); + } + finally + { + DbCommandRunner.DeleteTemporaryDirectoryForTesting = null; + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_Restore_OnPosix_CreatesPrivateStagingAndBackupPermissions() {