From c99d2ed1d7614eebb3171fa43e605abcc02a2b51 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:09:51 +0900 Subject: [PATCH 1/4] Fix unhandled exception exit code (#1660) --- USER_GUIDE.md | 5 +++++ changelog.d/unreleased/1660.fixed.md | 10 +++++++++ src/CodeIndex/Cli/CommandExitCodes.cs | 1 + src/CodeIndex/Cli/ProgramRunner.cs | 10 ++++++--- tests/CodeIndex.Tests/ProgramCliTests.cs | 27 ++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/1660.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6a60f225fc..c8b4268b31 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1114,6 +1114,7 @@ If a query itself begins with `-`, pass it as `--query ` or `-- `. | `6` | Transient database error (SQLite `BUSY` / `LOCKED`, retry with backoff) | | `7` | Invalid argument value (for example invalid `--kind`, `--color`, or `--metrics`) | | `8` | Cancelled by signal / Ctrl-C (`SIGINT` / `SIGTERM`-style cancellation path) | +| `99` | Unhandled exception after command dispatch; run `cdidx report` and inspect the lifecycle log | ### Error codes @@ -3096,6 +3097,10 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `3` | データベースエラー | | `4` | この build では機能未提供(例: trim 済み自己完結リリース上の CLI `--json`) | | `5` | stale index(`status --check` が DB / workspace の差分を検出) | +| `6` | 一時的なデータベースエラー(SQLite `BUSY` / `LOCKED`。backoff 付き retry 推奨) | +| `7` | 引数値が不正(例: 不正な `--kind`、`--color`、`--metrics`) | +| `8` | シグナル / Ctrl-C によるキャンセル(`SIGINT` / `SIGTERM` 系のキャンセル経路) | +| `99` | コマンド dispatch 後の想定外例外。`cdidx report` とライフサイクルログを確認 | ### エラーコード diff --git a/changelog.d/unreleased/1660.fixed.md b/changelog.d/unreleased/1660.fixed.md new file mode 100644 index 0000000000..09d54b3041 --- /dev/null +++ b/changelog.d/unreleased/1660.fixed.md @@ -0,0 +1,10 @@ +--- +issues: [1660] +category: fixed +--- + +English: +- Return the stable `UnhandledException` exit code `99` for unexpected command-dispatch failures instead of reusing the database-error exit code. + +日本語: +- コマンド実行中の予期しない失敗で database error の終了コードを流用せず、安定した `UnhandledException` 終了コード `99` を返すようにしました。 diff --git a/src/CodeIndex/Cli/CommandExitCodes.cs b/src/CodeIndex/Cli/CommandExitCodes.cs index b43c0f5541..0531fcbf16 100644 --- a/src/CodeIndex/Cli/CommandExitCodes.cs +++ b/src/CodeIndex/Cli/CommandExitCodes.cs @@ -15,6 +15,7 @@ public static class CommandExitCodes public const int TransientDatabaseError = 6; public const int InvalidArgument = 7; public const int CancelledBySignal = 8; + public const int UnhandledException = 99; public const int ExUsage = 64; public const int Interrupted = CancelledBySignal; public const int LegacyInterrupted = 130; diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index f7dcba0acb..b35314990a 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -275,10 +275,11 @@ _ when IsProjectPathArg(commandName) return exitCode; } - GlobalToolLog.Error("unhandled_exception", ex); + var unhandledExitCode = MapUnhandledExceptionExitCode(ex); + GlobalToolLog.Error($"command_complete exit_code={unhandledExitCode} unhandled_exception", ex); Console.Error.WriteLine("Error: command failed before it could complete. Run `cdidx report` for details."); - EmitCommandMetric(args[0], args, commandStartTimestamp, commandStopwatch, CommandExitCodes.DatabaseError, ex.GetType().Name); - return CommandExitCodes.DatabaseError; + EmitCommandMetric(args[0], args, commandStartTimestamp, commandStopwatch, unhandledExitCode, ex.GetType().Name); + return unhandledExitCode; } } @@ -463,6 +464,9 @@ private static bool IsTruthyEnvironmentVariable(string name) _ => CommandExitCodes.DatabaseError, }; + internal static int MapUnhandledExceptionExitCode(Exception ex) => + CommandExitCodes.UnhandledException; + private sealed class QuietStderrScope : IDisposable { private readonly TextWriter _originalError; diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 64dfa7d058..e09ca5c674 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -150,6 +150,33 @@ public void QueryQuietFlag_PreservesErrorLines() Assert.DoesNotContain("Hint:", stderr); } + [Fact] + public void Run_UnhandledExceptionReturnsUnhandledExitCode() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + + var exitCode = ProgramRunner.Run( + ["status"], + appVersion: "1.0.0-test", + beforeDispatchForTesting: () => throw new InvalidOperationException("boom")); + + Assert.Equal(CommandExitCodes.UnhandledException, exitCode); + Assert.Contains("Error: command failed before it could complete.", stderr.ToString()); + Assert.DoesNotContain("InvalidOperationException", stderr.ToString()); + } + finally + { + Console.SetError(originalError); + } + } + } + [Fact] public void Completions_HelpLikeValueReturnsCompletionsError() { From f7fba8f7f7ee9e8170acbd0e295655873af75ce9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:14:24 +0900 Subject: [PATCH 2/4] Classify transient SQLite exit codes (#1663) --- USER_GUIDE.md | 4 +- changelog.d/unreleased/1663.fixed.md | 10 +++++ src/CodeIndex/Cli/ProgramRunner.cs | 32 +++++++++++++- tests/CodeIndex.Tests/ProgramCliTests.cs | 56 ++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/1663.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c8b4268b31..68195ee1d0 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1111,7 +1111,7 @@ If a query itself begins with `-`, pass it as `--query ` or `-- `. | `3` | Permanent database error | | `4` | Feature unavailable on this build (for example CLI `--json` on a manually trimmed custom build) | | `5` | Stale index (`status --check` found DB/workspace differences) | -| `6` | Transient database error (SQLite `BUSY` / `LOCKED`, retry with backoff) | +| `6` | Transient database error (SQLite `BUSY` / `LOCKED` / `READONLY`, retry with backoff after fixing the transient holder or mount state) | | `7` | Invalid argument value (for example invalid `--kind`, `--color`, or `--metrics`) | | `8` | Cancelled by signal / Ctrl-C (`SIGINT` / `SIGTERM`-style cancellation path) | | `99` | Unhandled exception after command dispatch; run `cdidx report` and inspect the lifecycle log | @@ -3097,7 +3097,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `3` | データベースエラー | | `4` | この build では機能未提供(例: trim 済み自己完結リリース上の CLI `--json`) | | `5` | stale index(`status --check` が DB / workspace の差分を検出) | -| `6` | 一時的なデータベースエラー(SQLite `BUSY` / `LOCKED`。backoff 付き retry 推奨) | +| `6` | 一時的なデータベースエラー(SQLite `BUSY` / `LOCKED` / `READONLY`。一時的な保持者や mount 状態を解消してから backoff 付き retry 推奨) | | `7` | 引数値が不正(例: 不正な `--kind`、`--color`、`--metrics`) | | `8` | シグナル / Ctrl-C によるキャンセル(`SIGINT` / `SIGTERM` 系のキャンセル経路) | | `99` | コマンド dispatch 後の想定外例外。`cdidx report` とライフサイクルログを確認 | diff --git a/changelog.d/unreleased/1663.fixed.md b/changelog.d/unreleased/1663.fixed.md new file mode 100644 index 0000000000..b3340540c7 --- /dev/null +++ b/changelog.d/unreleased/1663.fixed.md @@ -0,0 +1,10 @@ +--- +issues: [1663] +category: fixed +--- + +English: +- Classify unhandled SQLite `BUSY`, `LOCKED`, and `READONLY` failures as transient database exit code `6`, while keeping permanent SQLite failures on database exit code `3`. + +日本語: +- catch-all まで到達した SQLite の `BUSY`、`LOCKED`、`READONLY` 失敗を一時的なデータベース終了コード `6` に分類し、永続的な SQLite 失敗はデータベース終了コード `3` のままにしました。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index b35314990a..7a3fbd326d 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; using CodeIndex.Database; using CodeIndex.Mcp; +using Microsoft.Data.Sqlite; namespace CodeIndex.Cli; @@ -464,8 +465,35 @@ private static bool IsTruthyEnvironmentVariable(string name) _ => CommandExitCodes.DatabaseError, }; - internal static int MapUnhandledExceptionExitCode(Exception ex) => - CommandExitCodes.UnhandledException; + internal static int MapUnhandledExceptionExitCode(Exception ex) + { + var sqliteException = FindSqliteException(ex); + if (sqliteException is null) + return CommandExitCodes.UnhandledException; + + return sqliteException.SqliteErrorCode switch + { + 5 or 6 or 8 => CommandExitCodes.TransientDatabaseError, + _ => CommandExitCodes.DatabaseError, + }; + } + + private static SqliteException? FindSqliteException(Exception ex) + { + if (ex is SqliteException sqliteException) + return sqliteException; + if (ex is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + var found = FindSqliteException(inner); + if (found is not null) + return found; + } + } + + return ex.InnerException is null ? null : FindSqliteException(ex.InnerException); + } private sealed class QuietStderrScope : IDisposable { diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index e09ca5c674..06f7cecb62 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -1,5 +1,6 @@ using CodeIndex.Cli; using CodeIndex.Models; +using Microsoft.Data.Sqlite; using System.Text.Json; namespace CodeIndex.Tests; @@ -177,6 +178,61 @@ public void Run_UnhandledExceptionReturnsUnhandledExitCode() } } + [Theory] + [InlineData(5)] + [InlineData(6)] + [InlineData(8)] + public void Run_UnhandledSqliteTransientExceptionReturnsTransientDatabaseExitCode(int sqliteErrorCode) + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + + var exitCode = ProgramRunner.Run( + ["status"], + appVersion: "1.0.0-test", + beforeDispatchForTesting: () => throw new SqliteException("database unavailable", sqliteErrorCode)); + + Assert.Equal(CommandExitCodes.TransientDatabaseError, exitCode); + Assert.Contains("Error: command failed before it could complete.", stderr.ToString()); + } + finally + { + Console.SetError(originalError); + } + } + } + + [Fact] + public void Run_UnhandledPermanentSqliteExceptionReturnsDatabaseExitCode() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + + var exitCode = ProgramRunner.Run( + ["status"], + appVersion: "1.0.0-test", + beforeDispatchForTesting: () => throw new SqliteException("database disk image is malformed", 11)); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Contains("Error: command failed before it could complete.", stderr.ToString()); + } + finally + { + Console.SetError(originalError); + } + } + } + [Fact] public void Completions_HelpLikeValueReturnsCompletionsError() { From 0211aa2cd194701844019bedecf5c41b2a3417c7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:24:53 +0900 Subject: [PATCH 3/4] Update unhandled exception tests (#1660) --- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index ce87b1e7f2..abb66980d7 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -142,7 +142,7 @@ public void Run_UnhandledException_ReturnsSanitizedSingleLineError() appVersion: "1.10.0", beforeDispatchForTesting: () => throw new InvalidOperationException("boom"))); - Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal(CommandExitCodes.UnhandledException, exitCode); Assert.Equal(string.Empty, stdout); var trimmed = stderr.TrimEnd(); @@ -516,7 +516,7 @@ public void Run_ForcedGlobalToolLogging_WritesUnhandledExceptionChain() appVersion: "1.10.0", beforeDispatchForTesting: () => throw outer)); - Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal(CommandExitCodes.UnhandledException, exitCode); Assert.Equal(string.Empty, stdout); Assert.StartsWith("Error: command failed before it could complete.", stderr.TrimEnd()); Assert.DoesNotContain("root cause", stderr); From 2b9e9b29cd5435a31447c4b01f56f2b8b69e9223 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:13:27 +0900 Subject: [PATCH 4/4] Fix CI expectations for exit codes (#1660) --- changelog.d/unreleased/1660.fixed.md | 9 ++++++--- changelog.d/unreleased/1663.fixed.md | 9 ++++++--- tests/CodeIndex.Tests/GlobalToolLogTests.cs | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/changelog.d/unreleased/1660.fixed.md b/changelog.d/unreleased/1660.fixed.md index 09d54b3041..4d82546aab 100644 --- a/changelog.d/unreleased/1660.fixed.md +++ b/changelog.d/unreleased/1660.fixed.md @@ -1,10 +1,13 @@ --- -issues: [1660] category: fixed +issues: + - 1660 --- -English: +## English + - Return the stable `UnhandledException` exit code `99` for unexpected command-dispatch failures instead of reusing the database-error exit code. -日本語: +## 日本語 + - コマンド実行中の予期しない失敗で database error の終了コードを流用せず、安定した `UnhandledException` 終了コード `99` を返すようにしました。 diff --git a/changelog.d/unreleased/1663.fixed.md b/changelog.d/unreleased/1663.fixed.md index b3340540c7..623beb982a 100644 --- a/changelog.d/unreleased/1663.fixed.md +++ b/changelog.d/unreleased/1663.fixed.md @@ -1,10 +1,13 @@ --- -issues: [1663] category: fixed +issues: + - 1663 --- -English: +## English + - Classify unhandled SQLite `BUSY`, `LOCKED`, and `READONLY` failures as transient database exit code `6`, while keeping permanent SQLite failures on database exit code `3`. -日本語: +## 日本語 + - catch-all まで到達した SQLite の `BUSY`、`LOCKED`、`READONLY` 失敗を一時的なデータベース終了コード `6` に分類し、永続的な SQLite 失敗はデータベース終了コード `3` のままにしました。 diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index c08ca95cf0..1144674f96 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -101,7 +101,7 @@ public void TryStart_WritesInvariantUtcTimestampAndStackTrace() appVersion: "test", beforeDispatchForTesting: ThrowForGlobalToolLogTest)); - Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal(CommandExitCodes.UnhandledException, exitCode); Assert.Contains("Run `cdidx report`", stderr); var logPath = Path.Combine(logRoot, $"stderr-{DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}.log"); var log = File.ReadAllText(logPath);