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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1111,9 +1111,10 @@ If a query itself begins with `-`, pass it as `--query <query>` or `-- <query>`.
| `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 |

### Error codes

Expand Down Expand Up @@ -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` / `READONLY`。一時的な保持者や mount 状態を解消してから backoff 付き retry 推奨) |
| `7` | 引数値が不正(例: 不正な `--kind`、`--color`、`--metrics`) |
| `8` | シグナル / Ctrl-C によるキャンセル(`SIGINT` / `SIGTERM` 系のキャンセル経路) |
| `99` | コマンド dispatch 後の想定外例外。`cdidx report` とライフサイクルログを確認 |

### エラーコード

Expand Down
13 changes: 13 additions & 0 deletions changelog.d/unreleased/1660.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
category: fixed
issues:
- 1660
---

## 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` を返すようにしました。
13 changes: 13 additions & 0 deletions changelog.d/unreleased/1663.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
category: fixed
issues:
- 1663
---

## 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` のままにしました。
1 change: 1 addition & 0 deletions src/CodeIndex/Cli/CommandExitCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 35 additions & 3 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Text.Json.Serialization;
using CodeIndex.Database;
using CodeIndex.Mcp;
using Microsoft.Data.Sqlite;

namespace CodeIndex.Cli;

Expand Down Expand Up @@ -291,10 +292,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;
}
}

Expand Down Expand Up @@ -479,6 +481,36 @@ private static bool IsTruthyEnvironmentVariable(string name)
_ => CommandExitCodes.DatabaseError,
};

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
{
private readonly TextWriter _originalError;
Expand Down
2 changes: 1 addition & 1 deletion tests/CodeIndex.Tests/GlobalToolLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions tests/CodeIndex.Tests/ProgramCliTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CodeIndex.Cli;
using CodeIndex.Models;
using Microsoft.Data.Sqlite;
using System.Text.Json;

namespace CodeIndex.Tests;
Expand Down Expand Up @@ -150,6 +151,88 @@ 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);
}
}
}

[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()
{
Expand Down
4 changes: 2 additions & 2 deletions tests/CodeIndex.Tests/ProgramRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading