Skip to content

Commit e5085b2

Browse files
authored
Merge pull request #2722 from Widthdom/fix-issue1660-1663
Stabilize command-dispatch exit codes
2 parents d30a2a6 + 2b9e9b2 commit e5085b2

8 files changed

Lines changed: 154 additions & 7 deletions

File tree

USER_GUIDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1111,9 +1111,10 @@ If a query itself begins with `-`, pass it as `--query <query>` or `-- <query>`.
11111111
| `3` | Permanent database error |
11121112
| `4` | Feature unavailable on this build (for example CLI `--json` on a manually trimmed custom build) |
11131113
| `5` | Stale index (`status --check` found DB/workspace differences) |
1114-
| `6` | Transient database error (SQLite `BUSY` / `LOCKED`, retry with backoff) |
1114+
| `6` | Transient database error (SQLite `BUSY` / `LOCKED` / `READONLY`, retry with backoff after fixing the transient holder or mount state) |
11151115
| `7` | Invalid argument value (for example invalid `--kind`, `--color`, or `--metrics`) |
11161116
| `8` | Cancelled by signal / Ctrl-C (`SIGINT` / `SIGTERM`-style cancellation path) |
1117+
| `99` | Unhandled exception after command dispatch; run `cdidx report` and inspect the lifecycle log |
11171118

11181119
### Error codes
11191120

@@ -3102,6 +3103,10 @@ raw match density を正確に測る、といった理由で全 raw chunk hit
31023103
| `3` | データベースエラー |
31033104
| `4` | この build では機能未提供(例: trim 済み自己完結リリース上の CLI `--json`|
31043105
| `5` | stale index(`status --check` が DB / workspace の差分を検出) |
3106+
| `6` | 一時的なデータベースエラー(SQLite `BUSY` / `LOCKED` / `READONLY`。一時的な保持者や mount 状態を解消してから backoff 付き retry 推奨) |
3107+
| `7` | 引数値が不正(例: 不正な `--kind``--color``--metrics`|
3108+
| `8` | シグナル / Ctrl-C によるキャンセル(`SIGINT` / `SIGTERM` 系のキャンセル経路) |
3109+
| `99` | コマンド dispatch 後の想定外例外。`cdidx report` とライフサイクルログを確認 |
31053110

31063111
### エラーコード
31073112

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1660
5+
---
6+
7+
## English
8+
9+
- Return the stable `UnhandledException` exit code `99` for unexpected command-dispatch failures instead of reusing the database-error exit code.
10+
11+
## 日本語
12+
13+
- コマンド実行中の予期しない失敗で database error の終了コードを流用せず、安定した `UnhandledException` 終了コード `99` を返すようにしました。
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1663
5+
---
6+
7+
## English
8+
9+
- Classify unhandled SQLite `BUSY`, `LOCKED`, and `READONLY` failures as transient database exit code `6`, while keeping permanent SQLite failures on database exit code `3`.
10+
11+
## 日本語
12+
13+
- catch-all まで到達した SQLite の `BUSY``LOCKED``READONLY` 失敗を一時的なデータベース終了コード `6` に分類し、永続的な SQLite 失敗はデータベース終了コード `3` のままにしました。

src/CodeIndex/Cli/CommandExitCodes.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static class CommandExitCodes
1515
public const int TransientDatabaseError = 6;
1616
public const int InvalidArgument = 7;
1717
public const int CancelledBySignal = 8;
18+
public const int UnhandledException = 99;
1819
public const int ExUsage = 64;
1920
public const int Interrupted = CancelledBySignal;
2021
public const int LegacyInterrupted = 130;

src/CodeIndex/Cli/ProgramRunner.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Text.Json.Serialization;
88
using CodeIndex.Database;
99
using CodeIndex.Mcp;
10+
using Microsoft.Data.Sqlite;
1011

1112
namespace CodeIndex.Cli;
1213

@@ -292,10 +293,11 @@ _ when IsProjectPathArg(commandName)
292293
return exitCode;
293294
}
294295

295-
GlobalToolLog.Error("unhandled_exception", ex);
296+
var unhandledExitCode = MapUnhandledExceptionExitCode(ex);
297+
GlobalToolLog.Error($"command_complete exit_code={unhandledExitCode} unhandled_exception", ex);
296298
Console.Error.WriteLine("Error: command failed before it could complete. Run `cdidx report` for details.");
297-
EmitCommandMetric(args[0], args, commandStartTimestamp, commandStopwatch, CommandExitCodes.DatabaseError, ex.GetType().Name);
298-
return CommandExitCodes.DatabaseError;
299+
EmitCommandMetric(args[0], args, commandStartTimestamp, commandStopwatch, unhandledExitCode, ex.GetType().Name);
300+
return unhandledExitCode;
299301
}
300302
}
301303

@@ -570,6 +572,36 @@ private static bool IsTruthyEnvironmentVariable(string name)
570572
_ => CommandExitCodes.DatabaseError,
571573
};
572574

575+
internal static int MapUnhandledExceptionExitCode(Exception ex)
576+
{
577+
var sqliteException = FindSqliteException(ex);
578+
if (sqliteException is null)
579+
return CommandExitCodes.UnhandledException;
580+
581+
return sqliteException.SqliteErrorCode switch
582+
{
583+
5 or 6 or 8 => CommandExitCodes.TransientDatabaseError,
584+
_ => CommandExitCodes.DatabaseError,
585+
};
586+
}
587+
588+
private static SqliteException? FindSqliteException(Exception ex)
589+
{
590+
if (ex is SqliteException sqliteException)
591+
return sqliteException;
592+
if (ex is AggregateException aggregate)
593+
{
594+
foreach (var inner in aggregate.InnerExceptions)
595+
{
596+
var found = FindSqliteException(inner);
597+
if (found is not null)
598+
return found;
599+
}
600+
}
601+
602+
return ex.InnerException is null ? null : FindSqliteException(ex.InnerException);
603+
}
604+
573605
private sealed class QuietStderrScope : IDisposable
574606
{
575607
private readonly TextWriter _originalError;

tests/CodeIndex.Tests/GlobalToolLogTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public void TryStart_WritesInvariantUtcTimestampAndStackTrace()
101101
appVersion: "test",
102102
beforeDispatchForTesting: ThrowForGlobalToolLogTest));
103103

104-
Assert.Equal(CommandExitCodes.DatabaseError, exitCode);
104+
Assert.Equal(CommandExitCodes.UnhandledException, exitCode);
105105
Assert.Contains("Run `cdidx report`", stderr);
106106
var logPath = Path.Combine(logRoot, $"stderr-{DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}.log");
107107
var log = File.ReadAllText(logPath);

tests/CodeIndex.Tests/ProgramCliTests.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using CodeIndex.Cli;
22
using CodeIndex.Models;
3+
using Microsoft.Data.Sqlite;
34
using System.Text.Json;
45

56
namespace CodeIndex.Tests;
@@ -150,6 +151,88 @@ public void QueryQuietFlag_PreservesErrorLines()
150151
Assert.DoesNotContain("Hint:", stderr);
151152
}
152153

154+
[Fact]
155+
public void Run_UnhandledExceptionReturnsUnhandledExitCode()
156+
{
157+
lock (TestConsoleLock.Gate)
158+
{
159+
var originalError = Console.Error;
160+
using var stderr = new StringWriter();
161+
try
162+
{
163+
Console.SetError(stderr);
164+
165+
var exitCode = ProgramRunner.Run(
166+
["status"],
167+
appVersion: "1.0.0-test",
168+
beforeDispatchForTesting: () => throw new InvalidOperationException("boom"));
169+
170+
Assert.Equal(CommandExitCodes.UnhandledException, exitCode);
171+
Assert.Contains("Error: command failed before it could complete.", stderr.ToString());
172+
Assert.DoesNotContain("InvalidOperationException", stderr.ToString());
173+
}
174+
finally
175+
{
176+
Console.SetError(originalError);
177+
}
178+
}
179+
}
180+
181+
[Theory]
182+
[InlineData(5)]
183+
[InlineData(6)]
184+
[InlineData(8)]
185+
public void Run_UnhandledSqliteTransientExceptionReturnsTransientDatabaseExitCode(int sqliteErrorCode)
186+
{
187+
lock (TestConsoleLock.Gate)
188+
{
189+
var originalError = Console.Error;
190+
using var stderr = new StringWriter();
191+
try
192+
{
193+
Console.SetError(stderr);
194+
195+
var exitCode = ProgramRunner.Run(
196+
["status"],
197+
appVersion: "1.0.0-test",
198+
beforeDispatchForTesting: () => throw new SqliteException("database unavailable", sqliteErrorCode));
199+
200+
Assert.Equal(CommandExitCodes.TransientDatabaseError, exitCode);
201+
Assert.Contains("Error: command failed before it could complete.", stderr.ToString());
202+
}
203+
finally
204+
{
205+
Console.SetError(originalError);
206+
}
207+
}
208+
}
209+
210+
[Fact]
211+
public void Run_UnhandledPermanentSqliteExceptionReturnsDatabaseExitCode()
212+
{
213+
lock (TestConsoleLock.Gate)
214+
{
215+
var originalError = Console.Error;
216+
using var stderr = new StringWriter();
217+
try
218+
{
219+
Console.SetError(stderr);
220+
221+
var exitCode = ProgramRunner.Run(
222+
["status"],
223+
appVersion: "1.0.0-test",
224+
beforeDispatchForTesting: () => throw new SqliteException("database disk image is malformed", 11));
225+
226+
Assert.Equal(CommandExitCodes.DatabaseError, exitCode);
227+
Assert.Contains("Error: command failed before it could complete.", stderr.ToString());
228+
}
229+
finally
230+
{
231+
Console.SetError(originalError);
232+
}
233+
}
234+
}
235+
153236
[Fact]
154237
public void Completions_HelpLikeValueReturnsCompletionsError()
155238
{

tests/CodeIndex.Tests/ProgramRunnerTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ public void Run_UnhandledException_ReturnsSanitizedSingleLineError()
203203
appVersion: "1.10.0",
204204
beforeDispatchForTesting: () => throw new InvalidOperationException("boom")));
205205

206-
Assert.Equal(CommandExitCodes.DatabaseError, exitCode);
206+
Assert.Equal(CommandExitCodes.UnhandledException, exitCode);
207207
Assert.Equal(string.Empty, stdout);
208208

209209
var trimmed = stderr.TrimEnd();
@@ -577,7 +577,7 @@ public void Run_ForcedGlobalToolLogging_WritesUnhandledExceptionChain()
577577
appVersion: "1.10.0",
578578
beforeDispatchForTesting: () => throw outer));
579579

580-
Assert.Equal(CommandExitCodes.DatabaseError, exitCode);
580+
Assert.Equal(CommandExitCodes.UnhandledException, exitCode);
581581
Assert.Equal(string.Empty, stdout);
582582
Assert.StartsWith("Error: command failed before it could complete.", stderr.TrimEnd());
583583
Assert.DoesNotContain("root cause", stderr);

0 commit comments

Comments
 (0)