From a3d9174cd86d33f810835e07a0787b143db59007 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 20:14:52 +0900 Subject: [PATCH 1/3] Honor SQLite open cancellation (#3050) --- changelog.d/unreleased/3050.fixed.md | 19 ++++++ src/CodeIndex/Cli/IndexCommandRunner.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 11 +++- src/CodeIndex/Database/DbContext.cs | 64 +++++++++++++++---- src/CodeIndex/Mcp/McpServer.cs | 4 +- .../CodeIndexExceptionTests.cs | 26 ++++++++ 6 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 changelog.d/unreleased/3050.fixed.md diff --git a/changelog.d/unreleased/3050.fixed.md b/changelog.d/unreleased/3050.fixed.md new file mode 100644 index 0000000000..ff71d3e7f1 --- /dev/null +++ b/changelog.d/unreleased/3050.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3050 +affected: + - src/CodeIndex/Database/DbContext.cs + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/CodeIndexExceptionTests.cs +--- + +## English + +- **SQLite connection-open retries now honor caller cancellation (#3050)** — `DbContext` open retry backoff now observes cancellation tokens passed by `index`, `status`, and MCP DB-open paths, so cancelled operations no longer wait for retry sleeps to finish. + +## 日本語 + +- **SQLite connection open の retry が呼び出し元の cancellation を尊重するようになりました (#3050)** — `DbContext` の open retry backoff は `index`、`status`、MCP の DB open 経路から渡された cancellation token を監視し、キャンセル済みの操作が retry sleep の終了まで待たなくなりました。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index b458d70923..15c21ba7b3 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -184,7 +184,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C using (indexLock) { - using var db = new DbContext(dbPath); + using var db = new DbContext(dbPath, indexCancellation.Token); if (db.ReadOnlyFallback) { return WriteCommandError( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 167e3dcfa6..fa49fe9ccb 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3951,7 +3951,7 @@ public static int RunStatus( if (!options.CheckWorkspace) return CommandExitCodes.Success; return GetStatusCheckExitCode(checkFailures); - }); + }, cancellationToken: cancellationToken); } private static JsonObject BuildEffectiveConfigJson(QueryCommandOptions options, string[] cmdArgs, string? appVersion) @@ -7463,7 +7463,12 @@ private static bool IsEmptySymbolAnalysis(SymbolAnalysisResult analysis) && analysis.Callers.Count == 0 && analysis.Callees.Count == 0; - private static int WithDb(QueryCommandOptions options, JsonSerializerOptions jsonOptions, Func action, Action? afterProfile = null) + private static int WithDb( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + Func action, + Action? afterProfile = null, + CancellationToken cancellationToken = default) { var dbPath = options.DbPath; if (s_batchReader == null) @@ -7521,7 +7526,7 @@ private static int WithDb(QueryCommandOptions options, JsonSerializerOptions jso } else { - db = new DbContext(dbPath); + db = new DbContext(dbPath, cancellationToken); if (!db.TryValidateIsCodeIndexDb(out var validationReason)) return WriteInvalidCodeIndexDbError(dbPath, validationReason); db.TryMigrateForRead(); diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 46c5a8ca98..dd172cd992 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -235,8 +235,9 @@ internal static bool TryValidateExistingCodeIndexDb( } } - public DbContext(string dbPath) + public DbContext(string dbPath, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); _schemaCacheKey = TryCreateSchemaCacheKey(dbPath); // Explicit URI form (file:///abs/path?immutable=1 etc.) — the user has opted into @@ -262,6 +263,7 @@ public DbContext(string dbPath) try { _connection = new SqliteConnection(DbPathResolver.BuildSqliteConnectionString(dbPath, SqliteOpenMode.ReadOnly)); + cancellationToken.ThrowIfCancellationRequested(); _connection.Open(); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); @@ -297,8 +299,8 @@ public DbContext(string dbPath) _connection = OpenSqliteConnectionWithRetry( () => new SqliteConnection(builder.ConnectionString), static connection => connection.Open(), - static milliseconds => System.Threading.Thread.Sleep(milliseconds), - dbPath: dbPath); + dbPath: dbPath, + cancellationToken: cancellationToken); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); RegisterConnectionFunctionsWithRetry(_connection); @@ -329,7 +331,7 @@ public DbContext(string dbPath) // immutable=1 を付けないと SQLite は -shm/-wal を触ろうとして CANTOPEN で落ちることがある。 _connection?.Dispose(); _walCheckpointAttempted = true; - _walCheckpointSucceeded = TryCheckpointWalBeforeReadOnlyFallback(dbPath); + _walCheckpointSucceeded = TryCheckpointWalBeforeReadOnlyFallback(dbPath, cancellationToken); if (_walCheckpointSucceeded) { try @@ -337,8 +339,8 @@ public DbContext(string dbPath) _connection = OpenSqliteConnectionWithRetry( () => new SqliteConnection(builder.ConnectionString), static connection => connection.Open(), - static milliseconds => System.Threading.Thread.Sleep(milliseconds), - dbPath: dbPath); + dbPath: dbPath, + cancellationToken: cancellationToken); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); RegisterConnectionFunctionsWithRetry(_connection); @@ -359,7 +361,7 @@ public DbContext(string dbPath) { _connection?.Dispose(); _readOnlyFallback = true; - OpenReadOnlyFallback(dbPath); + OpenReadOnlyFallback(dbPath, cancellationToken); } if (!_isReadOnly) @@ -371,7 +373,7 @@ public DbContext(string dbPath) try { _readOnlyFallback = true; - OpenReadOnlyFallback(dbPath); + OpenReadOnlyFallback(dbPath, cancellationToken); } catch { @@ -393,8 +395,9 @@ public DbContext(string dbPath) _suppressWriteWorkTracking = false; } - private void OpenReadOnlyFallback(string dbPath) + private void OpenReadOnlyFallback(string dbPath, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); _connection = OpenReadOnly(dbPath); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); @@ -403,7 +406,7 @@ private void OpenReadOnlyFallback(string dbPath) WarnIfBatchInProgress(); } - private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath) + private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath, CancellationToken cancellationToken) { try { @@ -415,9 +418,9 @@ private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath) using var connection = OpenSqliteConnectionWithRetry( () => new SqliteConnection(builder.ConnectionString), static connection => connection.Open(), - static milliseconds => System.Threading.Thread.Sleep(milliseconds), maxOpenAttempts: 1, - dbPath: dbPath); + dbPath: dbPath, + cancellationToken: cancellationToken); using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; cmd.ExecuteNonQuery(); @@ -682,15 +685,18 @@ internal static SqliteConnection OpenSqliteConnectionWithRetry( Action openConnection, Action? sleep = null, int maxOpenAttempts = 5, - string? dbPath = null) + string? dbPath = null, + CancellationToken cancellationToken = default) { if (maxOpenAttempts <= 0) throw new ArgumentOutOfRangeException(nameof(maxOpenAttempts), maxOpenAttempts, "Must be at least 1."); + cancellationToken.ThrowIfCancellationRequested(); SqliteConnection? connection = null; SqliteException? lastBusyError = null; for (var attempt = 1; attempt <= maxOpenAttempts; attempt++) { + cancellationToken.ThrowIfCancellationRequested(); connection?.Dispose(); connection = createConnection(); try @@ -707,7 +713,17 @@ internal static SqliteConnection OpenSqliteConnectionWithRetry( // #1580: 末尾の throw を必ず通すために busy エラーを全試行で捕捉する。 lastBusyError = ex; if (attempt < maxOpenAttempts) - sleep?.Invoke(50 * attempt); + { + try + { + SleepBeforeRetry(50 * attempt, sleep, cancellationToken); + } + catch + { + connection.Dispose(); + throw; + } + } } catch (Exception) { @@ -730,6 +746,26 @@ internal static SqliteConnection OpenSqliteConnectionWithRetry( innerException: lastBusyError); } + private static void SleepBeforeRetry(int milliseconds, Action? sleep, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (sleep != null) + { + sleep(milliseconds); + cancellationToken.ThrowIfCancellationRequested(); + return; + } + + if (!cancellationToken.CanBeCanceled) + { + System.Threading.Thread.Sleep(milliseconds); + return; + } + + if (cancellationToken.WaitHandle.WaitOne(milliseconds)) + cancellationToken.ThrowIfCancellationRequested(); + } + // Best-effort: extract the filesystem path from a SQLite URI so -wal checks can run. // Returns null if parsing fails; the caller simply skips the gate in that case. // URI から filesystem path を取り出すベストエフォート。失敗したらゲートをスキップ。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index fd51f8ad28..2b436aafeb 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -3166,7 +3166,7 @@ private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func() ?? false; @@ -3208,7 +3208,7 @@ internal DbContext GetOrOpenSharedDb() if (_sharedDb != null) return _sharedDb; - _sharedDb = new DbContext(_dbPath); + _sharedDb = new DbContext(_dbPath, _currentRequestToken.Value); return _sharedDb; } diff --git a/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs b/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs index 69b166397a..492e990080 100644 --- a/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs +++ b/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reflection; using System.Text.Json; using CodeIndex; @@ -207,6 +208,31 @@ public void OpenSqliteConnectionWithRetry_ExhaustedRetries_RaisesCodeIndexExcept Assert.Equal(3, attempts); } + [Fact] + public void OpenSqliteConnectionWithRetry_CancelDuringRetrySleep_ThrowsOperationCanceled() + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(10)); + var attempts = 0; + var stopwatch = Stopwatch.StartNew(); + + var ex = Assert.Throws(() => + DbContext.OpenSqliteConnectionWithRetry( + () => new SqliteConnection("Data Source=:memory:"), + _ => + { + attempts++; + throw CreateTransientBusyException(); + }, + maxOpenAttempts: 5, + cancellationToken: cts.Token)); + + stopwatch.Stop(); + Assert.Equal(cts.Token, ex.CancellationToken); + Assert.Equal(1, attempts); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Cancellation took {stopwatch.Elapsed}."); + } + private static SqliteException CreateTransientBusyException() { var exception = Activator.CreateInstance( From 655e5c902ffae6a67988eb4d3d5111e1be2342c3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 20:18:21 +0900 Subject: [PATCH 2/3] Honor SQLite function retry cancellation (#3049) --- changelog.d/unreleased/3049.fixed.md | 16 ++++++++++++ src/CodeIndex/Database/DbContext.cs | 22 +++++++++------- .../CodeIndexExceptionTests.cs | 26 +++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/3049.fixed.md diff --git a/changelog.d/unreleased/3049.fixed.md b/changelog.d/unreleased/3049.fixed.md new file mode 100644 index 0000000000..764d593866 --- /dev/null +++ b/changelog.d/unreleased/3049.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3049 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/CodeIndexExceptionTests.cs +--- + +## English + +- **SQLite connection-function retry sleeps now honor cancellation (#3049)** — transient failures while registering SQLite connection functions now use token-aware retry backoff so cancelled CLI and MCP work can stop promptly. + +## 日本語 + +- **SQLite connection function 登録の retry sleep が cancellation を尊重するようになりました (#3049)** — SQLite connection function 登録中の transient failure は token-aware な retry backoff を使うようになり、キャンセルされた CLI / MCP 処理が速やかに停止できます。 diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index dd172cd992..7858a03bce 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -267,7 +267,7 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) _connection.Open(); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection); + RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); _isReadOnly = true; WarnIfBatchInProgress(); return; @@ -303,7 +303,7 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) cancellationToken: cancellationToken); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection); + RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); EnsureWritableUserVersionSupported(dbPath); ConfigureAutoVacuumForEmptyDatabase(); Execute($"PRAGMA application_id={ApplicationId}"); @@ -343,7 +343,7 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) cancellationToken: cancellationToken); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection); + RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); EnsureWritableUserVersionSupported(dbPath); ConfigureAutoVacuumForEmptyDatabase(); Execute($"PRAGMA application_id={ApplicationId}"); @@ -401,7 +401,7 @@ private void OpenReadOnlyFallback(string dbPath, CancellationToken cancellationT _connection = OpenReadOnly(dbPath); Execute("PRAGMA busy_timeout=5000"); ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection); + RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); _isReadOnly = true; WarnIfBatchInProgress(); } @@ -1258,25 +1258,29 @@ private static void MaskRangePreservingNewLines(char[] chars, int start, int end } } - private static void RegisterConnectionFunctionsWithRetry( + internal static void RegisterConnectionFunctionsWithRetry( SqliteConnection connection, Action? sleep = null, - int maxAttempts = 5) + int maxAttempts = 5, + CancellationToken cancellationToken = default, + Action? registerConnectionFunctions = null) { if (maxAttempts <= 0) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "Must be at least 1."); - sleep ??= static milliseconds => System.Threading.Thread.Sleep(milliseconds); + cancellationToken.ThrowIfCancellationRequested(); + registerConnectionFunctions ??= RegisterConnectionFunctions; for (var attempt = 1; attempt <= maxAttempts; attempt++) { + cancellationToken.ThrowIfCancellationRequested(); try { - RegisterConnectionFunctions(connection); + registerConnectionFunctions(connection); return; } catch (SqliteException ex) when (IsTransientBusyError(ex) && attempt < maxAttempts) { - sleep(50 * attempt); + SleepBeforeRetry(50 * attempt, sleep, cancellationToken); } } } diff --git a/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs b/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs index 492e990080..1d924cf0d1 100644 --- a/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs +++ b/tests/CodeIndex.Tests/CodeIndexExceptionTests.cs @@ -233,6 +233,32 @@ public void OpenSqliteConnectionWithRetry_CancelDuringRetrySleep_ThrowsOperation Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Cancellation took {stopwatch.Elapsed}."); } + [Fact] + public void RegisterConnectionFunctionsWithRetry_CancelDuringRetrySleep_ThrowsOperationCanceled() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(10)); + var attempts = 0; + var stopwatch = Stopwatch.StartNew(); + + var ex = Assert.Throws(() => + DbContext.RegisterConnectionFunctionsWithRetry( + connection, + maxAttempts: 5, + cancellationToken: cts.Token, + registerConnectionFunctions: _ => + { + attempts++; + throw CreateTransientBusyException(); + })); + + stopwatch.Stop(); + Assert.Equal(cts.Token, ex.CancellationToken); + Assert.Equal(1, attempts); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), $"Cancellation took {stopwatch.Elapsed}."); + } + private static SqliteException CreateTransientBusyException() { var exception = Activator.CreateInstance( From 680c7eaf7a6eeb1bd4ffe522d7f42f04d8f1791f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 21:13:10 +0900 Subject: [PATCH 3/3] Honor GitHelper cancellation (#3059) --- changelog.d/unreleased/3059.fixed.md | 19 +++ src/CodeIndex/Cli/GitHelper.cs | 145 ++++++++++++++---- .../Cli/IndexCommandRunner.DryRun.cs | 16 +- .../Cli/IndexCommandRunner.FullScan.cs | 17 +- .../Cli/IndexCommandRunner.Update.cs | 3 +- .../Cli/IndexCommandRunner.UpdateTargets.cs | 17 +- src/CodeIndex/Cli/IndexCommandRunner.cs | 35 +++-- src/CodeIndex/Cli/IndexWatchRunner.cs | 8 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 26 +++- tests/CodeIndex.Tests/GitHelperTests.cs | 81 ++++++++++ 10 files changed, 305 insertions(+), 62 deletions(-) create mode 100644 changelog.d/unreleased/3059.fixed.md diff --git a/changelog.d/unreleased/3059.fixed.md b/changelog.d/unreleased/3059.fixed.md new file mode 100644 index 0000000000..3d2d7e97c1 --- /dev/null +++ b/changelog.d/unreleased/3059.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3059 +affected: + - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/IndexCommandRunner.Update.cs + - src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs + - tests/CodeIndex.Tests/GitHelperTests.cs +--- + +## English + +- **Git helper process execution now honors caller cancellation (#3059)** — git-backed update target resolution now passes cancellation into `GitHelper`, which kills the child process tree and returns promptly when the caller cancels. + +## 日本語 + +- **Git helper の process 実行が呼び出し元の cancellation を尊重するようになりました (#3059)** — git ベースの update target 解決は cancellation を `GitHelper` に渡し、呼び出し元がキャンセルした場合は子プロセスツリーを終了して速やかに戻ります。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 87da3daf52..8c13900765 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -139,9 +139,12 @@ public static GitRepositoryType TryGetRepositoryType(string projectRoot) /// Get changed files from a git commit. /// gitコミットから変更ファイルを取得する。 /// - public static List GetChangedFilesFromCommit(string projectRoot, string commitId) + public static List GetChangedFilesFromCommit( + string projectRoot, + string commitId, + CancellationToken cancellationToken = default) { - ValidateSingleCommitRef(projectRoot, commitId); + ValidateSingleCommitRef(projectRoot, commitId, cancellationToken); var psi = new ProcessStartInfo { @@ -161,7 +164,7 @@ public static List GetChangedFilesFromCommit(string projectRoot, string psi.ArgumentList.Add("--name-status"); psi.ArgumentList.Add(commitId); - var (exitCode, output, error) = RunProcessCapturingOutput(psi) + var (exitCode, output, error) = RunProcessCapturingOutput(psi, cancellationToken) ?? throw new InvalidOperationException("Failed to start git process / gitプロセスの起動に失敗"); if (exitCode != 0) @@ -196,7 +199,10 @@ public static bool IsCommitObjectId(string value) public static void ValidateCommitRef(string projectRoot, string commitRef) => ValidateSingleCommitRef(projectRoot, commitRef); - private static void ValidateSingleCommitRef(string projectRoot, string commitId) + private static void ValidateSingleCommitRef( + string projectRoot, + string commitId, + CancellationToken cancellationToken = default) { // Reject range/pathspec syntax before invoking git so --commits remains a list // of single commit-ish values, not revision-set expressions. @@ -211,14 +217,14 @@ private static void ValidateSingleCommitRef(string projectRoot, string commitId) $"Invalid commit ID '{commitId}'. Provide a single commit-ish; ranges and tag refs are not accepted. Use `git rev-parse --verify ^{{commit}}` to validate it."); } - var symbolicName = TryRunGit(projectRoot, "rev-parse", "--symbolic-full-name", commitId)?.Trim(); + var symbolicName = TryRunGit(projectRoot, cancellationToken, "rev-parse", "--symbolic-full-name", commitId)?.Trim(); if (symbolicName != null && symbolicName.StartsWith("refs/tags/", StringComparison.Ordinal)) { throw new ArgumentException( $"Invalid commit ID '{commitId}'. Tag refs are not accepted for --commits; pass the peeled commit SHA from `git rev-parse --verify {commitId}^{{commit}}`."); } - var resolved = TryRunGit(projectRoot, "rev-parse", "--verify", $"{commitId}^{{commit}}")?.Trim(); + var resolved = TryRunGit(projectRoot, cancellationToken, "rev-parse", "--verify", $"{commitId}^{{commit}}")?.Trim(); if (string.IsNullOrWhiteSpace(resolved)) { throw new ArgumentException( @@ -230,7 +236,11 @@ private static void ValidateSingleCommitRef(string projectRoot, string commitId) /// Get changed files between two git refs, including both sides of renames. /// 2つのgit ref間の変更ファイルを取得する。rename は旧パスと新パスの両方を含める。 /// - public static List GetChangedFilesBetweenRefs(string projectRoot, string oldRef, string newRef) + public static List GetChangedFilesBetweenRefs( + string projectRoot, + string oldRef, + string newRef, + CancellationToken cancellationToken = default) { ValidateGitRef(oldRef, nameof(oldRef)); ValidateGitRef(newRef, nameof(newRef)); @@ -251,7 +261,7 @@ public static List GetChangedFilesBetweenRefs(string projectRoot, string psi.ArgumentList.Add(newRef); psi.ArgumentList.Add("--"); - var (exitCode, output, error) = RunProcessCapturingOutput(psi) + var (exitCode, output, error) = RunProcessCapturingOutput(psi, cancellationToken) ?? throw new InvalidOperationException("Failed to start git process / gitプロセスの起動に失敗"); if (exitCode != 0) @@ -292,9 +302,9 @@ private static void ValidateGitRef(string value, string parameterName) /// Try to resolve the current HEAD commit for the repository that contains the project root. /// projectRoot を含むリポジトリの現在の HEAD コミットを安全に取得する。 /// - public static string? TryGetHeadCommit(string projectRoot) + public static string? TryGetHeadCommit(string projectRoot, CancellationToken cancellationToken = default) { - var result = TryGetHeadCommitResult(projectRoot); + var result = TryGetHeadCommitResult(projectRoot, cancellationToken); return result.State is GitHeadCommitState.Resolved or GitHeadCommitState.DetachedHead ? result.Sha : null; @@ -313,14 +323,15 @@ private static void ValidateGitRef(string value, string parameterName) } } - public static GitHeadCommitResult TryGetHeadCommitResult(string projectRoot) - => TryGetHeadCommitResult(projectRoot, gitEnvironmentOverrides: null); + public static GitHeadCommitResult TryGetHeadCommitResult(string projectRoot, CancellationToken cancellationToken = default) + => TryGetHeadCommitResult(projectRoot, gitEnvironmentOverrides: null, cancellationToken); internal static GitHeadCommitResult TryGetHeadCommitResult( string projectRoot, - IReadOnlyDictionary? gitEnvironmentOverrides) + IReadOnlyDictionary? gitEnvironmentOverrides, + CancellationToken cancellationToken = default) { - var repositoryRoot = TryGetRepositoryRoot(projectRoot, gitEnvironmentOverrides); + var repositoryRoot = TryGetRepositoryRoot(projectRoot, gitEnvironmentOverrides, cancellationToken); if (repositoryRoot == null) { return HasGitMetadataEntry(projectRoot) @@ -328,7 +339,7 @@ internal static GitHeadCommitResult TryGetHeadCommitResult( : GitHeadCommitResult.NotARepo; } - var headResult = RunGitCapturingResult(projectRoot, gitEnvironmentOverrides, "rev-parse", "--verify", "HEAD^{commit}"); + var headResult = RunGitCapturingResult(projectRoot, gitEnvironmentOverrides, cancellationToken, "rev-parse", "--verify", "HEAD^{commit}"); if (headResult.StartError != null) return GitHeadCommitResult.Error(headResult.StartError); @@ -344,7 +355,7 @@ internal static GitHeadCommitResult TryGetHeadCommitResult( if (string.IsNullOrWhiteSpace(sha)) return GitHeadCommitResult.None; - var branchResult = RunGitCapturingResult(projectRoot, gitEnvironmentOverrides, "rev-parse", "--abbrev-ref", "HEAD"); + var branchResult = RunGitCapturingResult(projectRoot, gitEnvironmentOverrides, cancellationToken, "rev-parse", "--abbrev-ref", "HEAD"); if (branchResult.StartError != null) return GitHeadCommitResult.Error(branchResult.StartError); if (branchResult.ExitCode != 0) @@ -364,9 +375,9 @@ internal static GitHeadCommitResult TryGetHeadCommitResult( /// 現在のブランチ短縮名を安全に取得する。detached HEAD は null 扱いにして、 /// 文字列 "HEAD" を誤ってブランチ名として永続化しないようにする。 /// - public static string? TryGetHeadBranch(string projectRoot) + public static string? TryGetHeadBranch(string projectRoot, CancellationToken cancellationToken = default) { - var output = TryRunGit(projectRoot, "rev-parse", "--abbrev-ref", "HEAD"); + var output = TryRunGit(projectRoot, cancellationToken, "rev-parse", "--abbrev-ref", "HEAD"); var value = output?.Trim(); if (string.IsNullOrWhiteSpace(value)) return null; @@ -448,8 +459,8 @@ private static bool TryRunGitForExitCode(string projectRoot, params string[] arg /// Try to resolve the repository root that contains the project path. /// projectPath を含むリポジトリのルートを安全に取得する。 /// - public static string? TryGetRepositoryRoot(string projectPath) - => TryGetRepositoryRoot(projectPath, gitEnvironmentOverrides: null); + public static string? TryGetRepositoryRoot(string projectPath, CancellationToken cancellationToken = default) + => TryGetRepositoryRoot(projectPath, gitEnvironmentOverrides: null, cancellationToken); internal static GitRepositoryType TryGetRepositoryType( string projectRoot, @@ -472,16 +483,19 @@ internal static GitRepositoryType TryGetRepositoryType( /// Resolve whether ignore matching should be case-insensitive for this workspace. /// git 管理下なら core.ignorecase を優先し、そうでなければファイルシステム特性を推定する。 /// - public static bool ResolveIgnoreCase(string projectRoot) - => ResolveIgnoreCase(projectRoot, gitEnvironmentOverrides: null); + public static bool ResolveIgnoreCase(string projectRoot, CancellationToken cancellationToken = default) + => ResolveIgnoreCase(projectRoot, gitEnvironmentOverrides: null, cancellationToken); - internal static bool ResolveIgnoreCase(string projectRoot, IReadOnlyDictionary? gitEnvironmentOverrides) + internal static bool ResolveIgnoreCase( + string projectRoot, + IReadOnlyDictionary? gitEnvironmentOverrides, + CancellationToken cancellationToken = default) { - var repoRoot = TryGetRepositoryRoot(projectRoot, gitEnvironmentOverrides); + var repoRoot = TryGetRepositoryRoot(projectRoot, gitEnvironmentOverrides, cancellationToken); if (repoRoot == null) return ProbeFileSystemIgnoreCase(projectRoot); - var configured = TryRunGit(repoRoot, gitEnvironmentOverrides, "config", "--bool", "--get", "core.ignorecase")?.Trim(); + var configured = TryRunGit(repoRoot, gitEnvironmentOverrides, cancellationToken, "config", "--bool", "--get", "core.ignorecase")?.Trim(); if (bool.TryParse(configured, out var ignoreCase)) return ignoreCase; @@ -575,9 +589,12 @@ private static string ParsePorcelainPath(string path) return paths; } - internal static string? TryGetRepositoryRoot(string projectPath, IReadOnlyDictionary? gitEnvironmentOverrides) + internal static string? TryGetRepositoryRoot( + string projectPath, + IReadOnlyDictionary? gitEnvironmentOverrides, + CancellationToken cancellationToken = default) { - var cdup = TryRunGit(projectPath, gitEnvironmentOverrides, "rev-parse", "--show-cdup"); + var cdup = TryRunGit(projectPath, gitEnvironmentOverrides, cancellationToken, "rev-parse", "--show-cdup"); if (cdup != null) { var value = cdup.Trim(); @@ -586,7 +603,7 @@ private static string ParsePorcelainPath(string path) : Path.GetFullPath(Path.Combine(projectPath, value)); } - var isBare = TryRunGit(projectPath, gitEnvironmentOverrides, "rev-parse", "--is-bare-repository")?.Trim(); + var isBare = TryRunGit(projectPath, gitEnvironmentOverrides, cancellationToken, "rev-parse", "--is-bare-repository")?.Trim(); return string.Equals(isBare, "true", StringComparison.OrdinalIgnoreCase) ? Path.GetFullPath(projectPath) : null; @@ -602,9 +619,19 @@ private static bool HasGitMetadataEntry(string projectRoot) private static string? TryRunGit(string projectRoot, params string[] args) => TryRunGit(projectRoot, gitEnvironmentOverrides: null, args); + private static string? TryRunGit(string projectRoot, CancellationToken cancellationToken, params string[] args) + => TryRunGit(projectRoot, gitEnvironmentOverrides: null, cancellationToken, args); + private readonly record struct GitCommandResult(int? ExitCode, string? Output, string? Error, string? StartError); private static string? TryRunGit(string projectRoot, IReadOnlyDictionary? gitEnvironmentOverrides, params string[] args) + => TryRunGit(projectRoot, gitEnvironmentOverrides, CancellationToken.None, args); + + private static string? TryRunGit( + string projectRoot, + IReadOnlyDictionary? gitEnvironmentOverrides, + CancellationToken cancellationToken, + params string[] args) { try { @@ -632,13 +659,17 @@ private static bool HasGitMetadataEntry(string projectRoot) } } - var result = RunProcessCapturingOutput(psi); + var result = RunProcessCapturingOutput(psi, cancellationToken); if (result == null) return null; var (exitCode, output, _) = result.Value; return exitCode == 0 ? output : null; } + catch (OperationCanceledException) + { + throw; + } catch { return null; @@ -648,6 +679,7 @@ private static bool HasGitMetadataEntry(string projectRoot) private static GitCommandResult RunGitCapturingResult( string projectRoot, IReadOnlyDictionary? gitEnvironmentOverrides, + CancellationToken cancellationToken = default, params string[] args) { try @@ -676,11 +708,15 @@ private static GitCommandResult RunGitCapturingResult( } } - var result = RunProcessCapturingOutput(psi); + var result = RunProcessCapturingOutput(psi, cancellationToken); return result == null ? new GitCommandResult(null, null, null, "Failed to start git process / gitプロセスの起動に失敗") : new GitCommandResult(result.Value.ExitCode, result.Value.Output, result.Value.Error, null); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { return new GitCommandResult(null, null, null, ex.Message); @@ -704,8 +740,11 @@ private static bool IsMissingHeadError(string reason) // stdoutとstderrを同時に汲み出す。Process自前のイベントスレッドを使うことで // stderrパイプ満杯による stdout 読み取りデッドロックを防ぎ、 // 非同期APIを GetAwaiter().GetResult() で待つ sync-over-async も避ける。 - private static (int ExitCode, string Output, string Error)? RunProcessCapturingOutput(ProcessStartInfo psi) + private static (int ExitCode, string Output, string Error)? RunProcessCapturingOutput( + ProcessStartInfo psi, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); using var process = new Process { StartInfo = psi }; var stdout = new StringBuilder(); var stderr = new StringBuilder(); @@ -742,11 +781,18 @@ void MarkFailure(string reason) process.BeginOutputReadLine(); process.BeginErrorReadLine(); - if (!process.WaitForExit(ToWaitMilliseconds(GitCommandTimeout))) + var exited = WaitForGitExit(process, GitCommandTimeout, cancellationToken, out var cancelled); + if (!exited) { - MarkFailure($"git command timed out after {FormatDuration(GitCommandTimeout)}."); + MarkFailure(cancelled + ? "git command cancelled." + : $"git command timed out after {FormatDuration(GitCommandTimeout)}."); if (!process.WaitForExit(ToWaitMilliseconds(GitKillWaitTimeout))) + { + if (cancelled) + cancellationToken.ThrowIfCancellationRequested(); return (GitProcessFailureExitCode, ReadCaptured(stdout), CombineCapturedError(ReadCaptured(stderr), failureReason!)); + } process.WaitForExit(); } else @@ -756,12 +802,45 @@ void MarkFailure(string reason) var output = ReadCaptured(stdout); var error = ReadCaptured(stderr); + if (cancelled) + cancellationToken.ThrowIfCancellationRequested(); if (failureReason != null) return (GitProcessFailureExitCode, output, CombineCapturedError(error, failureReason)); return (process.ExitCode, output, error); } + private static bool WaitForGitExit( + Process process, + TimeSpan timeout, + CancellationToken cancellationToken, + out bool cancelled) + { + var timeoutMilliseconds = ToWaitMilliseconds(timeout); + var waitSliceMilliseconds = Math.Min(50, timeoutMilliseconds); + var stopwatch = Stopwatch.StartNew(); + while (true) + { + if (process.WaitForExit(waitSliceMilliseconds)) + { + cancelled = false; + return true; + } + + if (cancellationToken.IsCancellationRequested) + { + cancelled = true; + return false; + } + + if (stopwatch.ElapsedMilliseconds >= timeoutMilliseconds) + { + cancelled = false; + return false; + } + } + } + private static string ReadCaptured(StringBuilder builder) { lock (builder) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 20256f9db7..162c4c62dd 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -137,18 +137,23 @@ private static bool TryResolveDryRunCandidates( // Git更新モード: コミットまたはref間の変更ファイル。 var changedFiles = new HashSet(StringComparer.Ordinal); var relevantIgnoreFileChanged = false; - var repoRoot = GitHelper.TryGetRepositoryRoot(projectPath) ?? Path.GetFullPath(projectPath); + var repoRoot = GitHelper.TryGetRepositoryRoot(projectPath, cancellationToken) ?? Path.GetFullPath(projectPath); try { foreach (var commit in options.Commits) { - var changed = GitHelper.GetChangedFilesFromCommit(projectPath, commit); + var changed = GitHelper.GetChangedFilesFromCommit(projectPath, commit, cancellationToken); var normalized = NormalizeCommitFileTargets(projectPath, repoRoot, changed, out var commitTouchedRelevantIgnoreFile); relevantIgnoreFileChanged |= commitTouchedRelevantIgnoreFile; foreach (var path in normalized) changedFiles.Add(path); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + exitCode = WriteDryRunInterrupted(options, jsonOptions); + return false; + } catch (Exception ex) { exitCode = WriteCommandError( @@ -164,12 +169,17 @@ private static bool TryResolveDryRunCandidates( { try { - var changed = GitHelper.GetChangedFilesBetweenRefs(projectPath, options.ChangedBetweenRefs[0], options.ChangedBetweenRefs[1]); + var changed = GitHelper.GetChangedFilesBetweenRefs(projectPath, options.ChangedBetweenRefs[0], options.ChangedBetweenRefs[1], cancellationToken); var normalized = NormalizeCommitFileTargets(projectPath, repoRoot, changed, out var rangeTouchedRelevantIgnoreFile); relevantIgnoreFileChanged |= rangeTouchedRelevantIgnoreFile; foreach (var path in normalized) changedFiles.Add(path); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + exitCode = WriteDryRunInterrupted(options, jsonOptions); + return false; + } catch { /* ignore git errors in dry-run */ } } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 1a4f084f21..f15a38c623 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -393,7 +393,18 @@ void ThrowIfDiscoveryCancelled() throw new IndexInterruptedException(0, null); } - var currentHeadForCheckpoint = GitHelper.TryGetHeadCommit(projectRoot); + string? currentHeadForCheckpoint; + try + { + ThrowIfDiscoveryCancelled(); + currentHeadForCheckpoint = GitHelper.TryGetHeadCommit(projectRoot, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + ConsoleUi.StopSpinner(spinnerCts); + throw new IndexInterruptedException(0, null); + } + var scanCheckpointPath = Path.Combine(projectRoot, ".cdidx", ScanCheckpointFileName); var checkpointedDirectories = LoadScanCheckpoint(scanCheckpointPath, currentHeadForCheckpoint); WriteFullScanJsonLiveness(options, "scanning files..."); @@ -1384,7 +1395,7 @@ void StopJsonHeartbeat() // full scan が「直近 full scan からブランチが動いた」をきちんと検知できる。 // 非 git workspace で null になった場合はキーごとクリアされる。Issue #1508。 writer.SetMeta(DbContext.IndexedHeadCommitMetaKey, currentHeadCommit); - writer.SetMeta(DbContext.IndexedHeadCommitBranchMetaKey, GitHelper.TryGetHeadBranch(projectRoot)); + writer.SetMeta(DbContext.IndexedHeadCommitBranchMetaKey, GitHelper.TryGetHeadBranch(projectRoot, cancellationToken)); writer.SetMeta( DbContext.LastFullScanElapsedMsMetaKey, stopwatch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)); @@ -1395,7 +1406,7 @@ void StopJsonHeartbeat() // reflects the true HEAD at the time of the most recent successful index. // #1509: あらゆる成功 index の終端で更新する HEAD トリプル (SHA + branch + 時刻) も // ここで stamp する。full scan / partial update を問わず最新の HEAD を保存する。 - StampIndexedHeadMetadata(writer, projectRoot); + StampIndexedHeadMetadata(writer, projectRoot, cancellationToken); if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("finalize", stopwatch)); var memoryTimelineForStamp = BuildMemoryTimeline(memorySamples); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 9b34d485bb..17272f7a4b 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -65,6 +65,7 @@ private static int RunUpdateMode( options, spinnerFrames, jsonOptions, + cancellationToken, out var targetPaths, out var relevantIgnoreFileChanged); if (resolveTargetsExitCode != null) @@ -986,7 +987,7 @@ void ThrowIfUpdateCancelled() } if (errors == 0) { - StampIndexedHeadMetadata(writer, projectRoot); + StampIndexedHeadMetadata(writer, projectRoot, cancellationToken); StampCommitScopedFreshHeadMetadata(writer, options, projectRoot, currentHeadCommit); if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("finalize", stopwatch)); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs index e2297c7ffd..8d573a6820 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs @@ -9,6 +9,7 @@ public static partial class IndexCommandRunner IndexCommandOptions options, string[] spinnerFrames, JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken, out HashSet targetPaths, out bool relevantIgnoreFileChanged) { @@ -22,16 +23,20 @@ public static partial class IndexCommandRunner { if (!options.Json) spinnerCts = ConsoleUi.StartSpinner("Resolving changed files...", spinnerFrames); - var repoRoot = GitHelper.TryGetRepositoryRoot(projectRoot) ?? Path.GetFullPath(projectRoot); + var repoRoot = GitHelper.TryGetRepositoryRoot(projectRoot, cancellationToken) ?? Path.GetFullPath(projectRoot); foreach (var commit in options.Commits) { - var changedFiles = GitHelper.GetChangedFilesFromCommit(projectRoot, commit); + var changedFiles = GitHelper.GetChangedFilesFromCommit(projectRoot, commit, cancellationToken); var normalized = NormalizeCommitFileTargets(projectRoot, repoRoot, changedFiles, out var commitTouchedRelevantIgnoreFile); relevantIgnoreFileChanged |= commitTouchedRelevantIgnoreFile; foreach (var f in normalized) targetPaths.Add(f); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return WriteCommandError( @@ -62,13 +67,17 @@ public static partial class IndexCommandRunner { if (!options.Json) spinnerCts = ConsoleUi.StartSpinner("Resolving changed files between refs...", spinnerFrames); - var repoRoot = GitHelper.TryGetRepositoryRoot(projectRoot) ?? Path.GetFullPath(projectRoot); - var changedFiles = GitHelper.GetChangedFilesBetweenRefs(projectRoot, options.ChangedBetweenRefs[0], options.ChangedBetweenRefs[1]); + var repoRoot = GitHelper.TryGetRepositoryRoot(projectRoot, cancellationToken) ?? Path.GetFullPath(projectRoot); + var changedFiles = GitHelper.GetChangedFilesBetweenRefs(projectRoot, options.ChangedBetweenRefs[0], options.ChangedBetweenRefs[1], cancellationToken); var normalized = NormalizeCommitFileTargets(projectRoot, repoRoot, changedFiles, out var rangeTouchedRelevantIgnoreFile); relevantIgnoreFileChanged |= rangeTouchedRelevantIgnoreFile; foreach (var f in normalized) targetPaths.Add(f); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return WriteCommandError( diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 15c21ba7b3..98179b75ef 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -129,8 +129,17 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C if (options.OptimizeOnly) return RunOptimizeFtsForDb(resolvedDbPath, options.Json, jsonOptions, options.ProjectPath); - var ignoreCase = GitHelper.ResolveIgnoreCase(options.ProjectPath); - var ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(options.ProjectPath) ?? Path.GetFullPath(options.ProjectPath!); + bool ignoreCase; + string ignoreRuleRoot; + try + { + ignoreCase = GitHelper.ResolveIgnoreCase(options.ProjectPath, indexCancellation.Token); + ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(options.ProjectPath, indexCancellation.Token) ?? Path.GetFullPath(options.ProjectPath!); + } + catch (OperationCanceledException) when (indexCancellation.IsCancellationRequested) + { + return WriteInterruptedResult(options.Json, jsonOptions, filesProcessed: 0, filesTotal: null); + } // --dry-run: scan files but do not write to database / --dry-run: ファイルスキャンのみでDBに書き込まない if (options.DryRun) @@ -233,7 +242,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C // `--rebuild` が DB を消す前に取り出す。incremental 経路で HEAD 差分を検知し、`status` // (no `--check`) でも worktree の HEAD 切替検出に利用する。 var priorIndexedHeadCommit = db.GetMetaString(DbContext.IndexedHeadCommitMetaKey); - var currentHeadCommit = GitHelper.TryGetHeadCommit(options.ProjectPath); + var currentHeadCommit = GitHelper.TryGetHeadCommit(options.ProjectPath, indexCancellation.Token); // Don't demote readiness yet. A transient usage error in update-mode preflight // (bad --commits hash, git unavailable, etc.) would permanently downgrade a healthy @@ -649,12 +658,12 @@ private static bool TryProbeDryRunFile(FileIndexer indexer, string absolutePath, // the index data itself is valid; the metadata stamp is best-effort. Issue #1509. // #1509: 成功 index 末尾で HEAD / branch / timestamp を codeindex_meta に保存する。 // git 不在時は NULL stamp、stamp 自体の例外は warn せず無視(index 本体は成功)。 - private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot) + private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot, CancellationToken cancellationToken) { try { - var headSha = GitHelper.TryGetHeadCommit(projectRoot); - var headBranch = GitHelper.TryGetHeadBranch(projectRoot); + var headSha = GitHelper.TryGetHeadCommit(projectRoot, cancellationToken); + var headBranch = GitHelper.TryGetHeadBranch(projectRoot, cancellationToken); var timestamp = headSha != null ? DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture) : null; @@ -662,12 +671,16 @@ private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot writer.SetMeta(DbContext.IndexedHeadBranchMetaKey, headBranch); writer.SetMeta(DbContext.IndexedHeadTimestampMetaKey, timestamp); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch { // Best-effort metadata only; never fail an otherwise-successful index run. // best-effort であり、stamp の失敗で index 全体を失敗扱いにしない。 } - StampWorkspacePathCaseSensitivity(writer, projectRoot); + StampWorkspacePathCaseSensitivity(writer, projectRoot, cancellationToken); } private static void StampCommitScopedFreshHeadMetadata(DbWriter writer, IndexCommandOptions options, string projectRoot, string? currentHeadCommit) @@ -713,15 +726,19 @@ private static bool TryChangedBetweenCoversCurrentHead(IndexCommandOptions optio // an unwritable git config / temp probe never blocks an otherwise-successful index. // #1546: workspace FS の大小区別を実プローブして codeindex_meta に保存する。 // probe 失敗時は黙って null stamp にして index 本体は成功扱いのままとする。 - private static void StampWorkspacePathCaseSensitivity(DbWriter writer, string projectRoot) + private static void StampWorkspacePathCaseSensitivity(DbWriter writer, string projectRoot, CancellationToken cancellationToken) { try { - var ignoreCase = GitHelper.ResolveIgnoreCase(projectRoot); + var ignoreCase = GitHelper.ResolveIgnoreCase(projectRoot, cancellationToken); PathCasing.SeedFromWorkspace(projectRoot, ignoreCase); var caseSensitive = (!ignoreCase).ToString(System.Globalization.CultureInfo.InvariantCulture); writer.SetMeta(DbContext.WorkspacePathCaseSensitiveMetaKey, caseSensitive); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch { // Best-effort metadata only; never fail an otherwise-successful index run. diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index b2f662d55d..6283f64c6b 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -36,6 +36,10 @@ public static int Run( { return RunCore(baseOptions, jsonOptions, projectRoot, resolvedDbPath, cts.Token); } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + return CommandExitCodes.Success; + } finally { Console.CancelKeyPress -= handler; @@ -50,10 +54,10 @@ internal static int RunCore( CancellationToken cancellationToken) { var debounce = TimeSpan.FromMilliseconds(baseOptions.WatchDebounceMs ?? DefaultDebounceMs); - var ignoreCase = GitHelper.ResolveIgnoreCase(projectRoot); + var ignoreCase = GitHelper.ResolveIgnoreCase(projectRoot, cancellationToken); var batcher = new FileChangeBatcher(debounce, ignoreCase: ignoreCase); - var ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(projectRoot) ?? Path.GetFullPath(projectRoot); + var ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(projectRoot, cancellationToken) ?? Path.GetFullPath(projectRoot); var fileIndexer = new FileIndexer(projectRoot, ignoreCase, ignoreRuleRoot); var watchExitCode = CommandExitCodes.Success; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 8750eb0da1..99088bf763 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3816,6 +3816,8 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso var priorHotspotFamilyVersions = GetHotspotFamilyMetaSnapshot(db, DbContext.GetHotspotFamilyVersionMetaKey); var priorHotspotFamilyMarkerFingerprints = GetHotspotFamilyMetaSnapshot(db, DbContext.GetHotspotFamilyMarkerFingerprintMetaKey); var priorIndexedProjectRoot = db.GetMetaString(DbContext.IndexedProjectRootMetaKey); + var requestToken = _currentRequestToken.Value; + requestToken.ThrowIfCancellationRequested(); // Capture git HEAD so subsequent queries can detect a worktree branch / HEAD switch // (`git switch other-branch` inside the worktree) without a `--check` workspace scan. // Like the CLI full-scan path, the value is only persisted at the end of a successful @@ -3823,7 +3825,7 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso // staleness until the next clean refresh. Issues #1508 and #1512. // worktree 内の HEAD 切替検出のため HEAD を捕捉。CLI full-scan と同じく成功時のみ // 書き込み、partial 失敗は旧 HEAD を残して次回 full scan で更新する。 - var currentHeadCommit = GitHelper.TryGetHeadCommit(projectPath); + var currentHeadCommit = GitHelper.TryGetHeadCommit(projectPath, requestToken); // On --rebuild, clear readiness before DropAll so a crash during the window // (empty tables recreated, MarkReady not yet run) cannot leave old trust bits @@ -3844,10 +3846,12 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso MarkSharedDbMigrated(); var writer = new DbWriter(db); - var indexer = new FileIndexer(projectPath, GitHelper.ResolveIgnoreCase(projectPath), GitHelper.TryGetRepositoryRoot(projectPath) ?? Path.GetFullPath(projectPath), maxFileBytes); + var indexer = new FileIndexer( + projectPath, + GitHelper.ResolveIgnoreCase(projectPath, requestToken), + GitHelper.TryGetRepositoryRoot(projectPath, requestToken) ?? Path.GetFullPath(projectPath), + maxFileBytes); using var postExtractionHooks = PostExtractionHookRunner.DiscoverDefault(); - var requestToken = _currentRequestToken.Value; - requestToken.ThrowIfCancellationRequested(); var currentHotspotFamilyMarkerFingerprints = GetHotspotFamilyMarkerFingerprints(indexer, requestToken); var currentCSharpSymbolNameContractVersion = DbContext.CSharpSymbolNameContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); var csharpSymbolNameContractMatchesCurrent = priorCSharpSymbolNameContractVersion == currentCSharpSymbolNameContractVersion; @@ -4145,7 +4149,7 @@ static long SumReadableFileBytes(IEnumerable paths) // untouched and surface staleness until the next clean refresh. Issues #1508 / #1512. // CLI full-scan と同じく成功時のみ HEAD を記録する。partial / 失敗は旧 HEAD を残す。 writer.SetMeta(DbContext.IndexedHeadCommitMetaKey, currentHeadCommit); - writer.SetMeta(DbContext.IndexedHeadCommitBranchMetaKey, GitHelper.TryGetHeadBranch(projectPath)); + writer.SetMeta(DbContext.IndexedHeadCommitBranchMetaKey, GitHelper.TryGetHeadBranch(projectPath, requestToken)); // #1509: also persist the always-updated HEAD/branch/timestamp triple so // status / consumers can detect cross-session staleness via // `commits_ahead_of_indexed_head`. Same best-effort contract — git unavailability @@ -4153,7 +4157,7 @@ static long SumReadableFileBytes(IEnumerable paths) // #1509: HEAD / branch / timestamp を保存し、cross-session staleness 検出を可能にする。 try { - var headBranch = GitHelper.TryGetHeadBranch(projectPath); + var headBranch = GitHelper.TryGetHeadBranch(projectPath, requestToken); var timestamp = currentHeadCommit != null ? GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture) : null; @@ -4161,6 +4165,10 @@ static long SumReadableFileBytes(IEnumerable paths) writer.SetMeta(DbContext.IndexedHeadBranchMetaKey, headBranch); writer.SetMeta(DbContext.IndexedHeadTimestampMetaKey, timestamp); } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + throw; + } catch { // Best-effort; never fail an otherwise-successful index run. @@ -4170,12 +4178,16 @@ static long SumReadableFileBytes(IEnumerable paths) // #1546: MCP 経由 index でも case-sensitivity stamp を残す。 try { - var ignoreCase = GitHelper.ResolveIgnoreCase(projectPath); + var ignoreCase = GitHelper.ResolveIgnoreCase(projectPath, requestToken); CodeIndex.Cli.PathCasing.SeedFromWorkspace(projectPath, ignoreCase); writer.SetMeta( DbContext.WorkspacePathCaseSensitiveMetaKey, (!ignoreCase).ToString(System.Globalization.CultureInfo.InvariantCulture)); } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + throw; + } catch { // Best-effort; never fail an otherwise-successful index run. diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 0ba73e2f32..2b59b03086 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -424,6 +424,72 @@ public void GetChangedFilesFromCommit_FailsWhenGitCommandTimesOut() } } + [Fact] + public void GetChangedFilesFromCommit_CancelDuringGitCommand_ThrowsOperationCanceled() + { + if (OperatingSystem.IsWindows()) + return; + + var repoDir = Path.Combine(_tempDir, "repo-cancel"); + Directory.CreateDirectory(repoDir); + var fakeGitDir = Path.Combine(_tempDir, "fake-git-cancel"); + Directory.CreateDirectory(fakeGitDir); + WriteFakeGitThatHangsOnDiffTree(fakeGitDir); + + var oldPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", fakeGitDir + Path.PathSeparator + oldPath); + try + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + var stopwatch = Stopwatch.StartNew(); + + var ex = Assert.Throws( + () => GitHelper.GetChangedFilesFromCommit(repoDir, "0123456789abcdef", cts.Token)); + + stopwatch.Stop(); + Assert.Equal(cts.Token, ex.CancellationToken); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2), $"Cancellation took {stopwatch.Elapsed}."); + } + finally + { + Environment.SetEnvironmentVariable("PATH", oldPath); + } + } + + [Fact] + public void ResolveIgnoreCase_CancelDuringGitCommand_ThrowsOperationCanceled() + { + if (OperatingSystem.IsWindows()) + return; + + var repoDir = Path.Combine(_tempDir, "repo-ignorecase-cancel"); + Directory.CreateDirectory(repoDir); + var fakeGitDir = Path.Combine(_tempDir, "fake-git-ignorecase-cancel"); + Directory.CreateDirectory(fakeGitDir); + WriteFakeGitThatHangsOnRevParse(fakeGitDir); + + var oldPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", fakeGitDir + Path.PathSeparator + oldPath); + try + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + var stopwatch = Stopwatch.StartNew(); + + var ex = Assert.Throws( + () => GitHelper.ResolveIgnoreCase(repoDir, cts.Token)); + + stopwatch.Stop(); + Assert.Equal(cts.Token, ex.CancellationToken); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2), $"Cancellation took {stopwatch.Elapsed}."); + } + finally + { + Environment.SetEnvironmentVariable("PATH", oldPath); + } + } + [Theory] [InlineData("feature")] [InlineData("v1.0.0")] @@ -902,6 +968,21 @@ exit 1 File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } + private static void WriteFakeGitThatHangsOnRevParse(string directory) + { + var script = Path.Combine(directory, "git"); + File.WriteAllText(script, """ +#!/bin/sh +if [ "$1" = "rev-parse" ]; then + sleep 5 + exit 0 +fi +exit 1 +"""); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + private static bool ProbeDirectoryIgnoreCaseLikeProduction(string path) { if (TryCreateCaseVariant(path, out var variant))