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
4 changes: 2 additions & 2 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding
- `QueryCommandRunnerBatchIssue4723Tests.cs`
CLI batch coverage for structured command objects, configurable input/output budgets, bounded parallel read overlap, input-order result emission, per-item failure isolation, and cancellation/console restoration. The deterministic overlap test blocks the first worker until the second finishes through batch-only test seams; keep those seams reset in `finally` and do not replace the signal with timing assertions.
- `QueryCommandRunnerBatchIssue4872Tests.cs`
Parallel batch session-reuse coverage. Keep the exact worker-slot session bound, serial/parallel result and input-order parity, between-item checkpointed-WAL snapshot and direct-session refresh, validation/reader-construction failure cleanup, hot-WAL snapshot fixture, warmup, and the generous 12-item/3-item ratio guard together. The checkpointed-WAL snapshot refresh gives each status wave two minutes of headroom for the loaded net9 full suite; keep its completion signals and result/session assertions intact rather than weakening the concurrency contract. The benchmark injects deterministic database-open/schema work through a test seam; its fixed delay models phase cost only, while worker coordination remains signal-driven.
Parallel batch session-reuse coverage. Keep the exact worker-slot session bound, serial/parallel result and input-order parity, between-item checkpointed-WAL snapshot and direct-session refresh, validation/reader-construction failure cleanup, hot-WAL snapshot fixture, warmup, and the generous 12-item/3-item ratio guard together. The interactive refresh fixtures run the synchronous batch loop on a dedicated thread, await worker completion without blocking a thread-pool thread, and query only the lightweight JSON file count needed to observe the database generation; do not restore `Task.Run`, synchronous waits, or broad `status` diagnostics there. The checkpointed-WAL snapshot refresh retains two minutes of headroom for the loaded net9 full suite; keep its completion and result/session assertions intact rather than weakening the concurrency contract. The benchmark injects deterministic database-open/schema work through a test seam; its fixed delay models phase cost only, while worker coordination remains signal-driven.
- `PropertyBasedParserTests.cs`
FsCheck-driven property tests for parser-heavy paths called out in issue #1572: `ArgHelper.WantsHelp` and `ProgramRunner.IsProjectPathArg` never throw on arbitrary inputs; `FileIndexer.NormalizePathSeparators` is idempotent under double application; the literal-safe FTS5 sanitizer (`DbReader.SanitizeFtsQuery`) always emits a query that a real in-memory FTS5 virtual table can parse. They complement, not replace, the example-based tests in `ArgHelperTests.cs` / `QueryCommandRunnerTests.cs`.
- `TestProjectHelper.cs`, `TestDeterminism.cs`, `RepositoryTestPaths.cs`, `TestConsoleLock.cs`
Expand Down Expand Up @@ -1649,7 +1649,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests"
- `QueryCommandRunnerBatchIssue4723Tests.cs`
structured command object、設定可能な input / output budget、上限付き parallel read の重複実行、入力順の result 出力、item ごとの failure isolation、cancellation / console 復元を対象とする CLI batch test です。決定的な overlap test は batch 専用 test seam を通じて第 1 worker を第 2 worker の完了まで block します。seam は `finally` で必ず reset し、signal を timing assertion に置き換えないでください。
- `QueryCommandRunnerBatchIssue4872Tests.cs`
parallel batch の session 再利用を検証します。worker slot 数と一致する厳密な session 上限、serial / parallel の result と入力順 parity、item 間の checkpoint 済み WAL snapshot / direct session 更新、validation / reader 構築失敗時の cleanup、hot-WAL snapshot fixture、warmup、十分に余裕を持たせた 12-item / 3-item ratio guard を一緒に維持してください。checkpoint 済み WAL snapshot の refresh test は、負荷がかかる net9 full suite に備えて各 status wave に 2 分の余裕を持たせます。completion signal と result / session assertion は concurrency 契約を弱めず維持してください。benchmark は test seam から決定的な database-open / schema 作業を注入します。固定 delay は phase cost のモデル化だけに使い、worker coordination は引き続き signal で制御します。
parallel batch の session 再利用を検証します。worker slot 数と一致する厳密な session 上限、serial / parallel の result と入力順 parity、item 間の checkpoint 済み WAL snapshot / direct session 更新、validation / reader 構築失敗時の cleanup、hot-WAL snapshot fixture、warmup、十分に余裕を持たせた 12-item / 3-item ratio guard を一緒に維持してください。interactive refresh fixture は同期 batch loop を専用 thread で実行し、thread-pool thread を block せず worker 完了を await し、database generation の観測に必要な軽量 JSON file count だけを query します。この箇所に `Task.Run`、同期 wait、広範な `status` diagnostics を戻さないでください。checkpoint 済み WAL snapshot の refresh test は、負荷がかかる net9 full suite に備えて 2 分の余裕を維持します。completion と result / session assertion は concurrency 契約を弱めず維持してください。benchmark は test seam から決定的な database-open / schema 作業を注入します。固定 delay は phase cost のモデル化だけに使い、worker coordination は引き続き signal で制御します。
- `PropertyBasedParserTests.cs`
issue #1572 で挙げられたパーサー系経路に対する FsCheck 駆動の property テスト: `ArgHelper.WantsHelp` と `ProgramRunner.IsProjectPathArg` が任意入力で例外を投げないこと、`FileIndexer.NormalizePathSeparators` が二重適用で idempotent であること、literal-safe な FTS5 サニタイザ (`DbReader.SanitizeFtsQuery`) が常にインメモリ FTS5 仮想テーブルで parse 可能なクエリを出力すること。`ArgHelperTests.cs` / `QueryCommandRunnerTests.cs` の例ベーステストを置き換えるものではなく補完します。
- `TestProjectHelper.cs`、`TestDeterminism.cs`、`RepositoryTestPaths.cs`、`TestConsoleLock.cs`
Expand Down
16 changes: 16 additions & 0 deletions changelog.d/unreleased/5033.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: internal
issues:
- 5033
affected:
- tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs
- TESTING_GUIDE.md
---

## English

- **The parallel detached-snapshot fixture no longer depends on thread-pool timing or broad status diagnostics (#5033)** — its interactive batch loop now runs on a dedicated test thread, uses asynchronous completion signals, and checks generation refresh through lightweight file-count queries so the net8 test remains deterministic under suite load.

## 日本語

- **parallel detached-snapshot fixture が thread-pool timing や広範な status diagnostics に依存しなくなりました (#5033)** — interactive batch loop を専用 test thread で実行し、非同期 completion signal と軽量な file-count query で generation refresh を確認するため、suite 負荷下でも net8 test が安定して完了します。
164 changes: 118 additions & 46 deletions tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,34 +119,38 @@ INSERT INTO files(path, lang, size, lines, checksum, modified)
using var input = new InteractiveBatchTextReader();
using var stdout = new StringWriter();
using var stderr = new StringWriter();
using var firstWaveCompleted = new CountdownEvent(2);
var firstWaveCompleted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
using var cancellation = new CancellationTokenSource();
var statusWaveTimeout = TimeSpan.FromMinutes(2);
Task<int>? runTask = null;
var openedSessions = 0;
var completedFirstWaveCommands = 0;
QueryCommandRunner.BatchParallelSessionOpenedForTesting =
() => Interlocked.Increment(ref openedSessions);
QueryCommandRunner.BatchParallelCommandCompletedForTesting = lineNumber =>
{
if (lineNumber <= 2)
firstWaveCompleted.Signal();
if (lineNumber <= 2
&& Interlocked.Increment(ref completedFirstWaveCommands) == 2)
{
firstWaveCompleted.TrySetResult();
}
};

try
{
runTask = Task.Run(() =>
{
using var capture = ConsoleCapture.Start(stdout, stderr, input);
return QueryCommandRunner.RunBatch(
["--db", dbPath, "--json-summary", "--parallel", "2"],
_jsonOptions,
cancellationToken: cancellation.Token);
});
input.WriteLine("""{"command":"status","args":["--json"]}""");
input.WriteLine("""{"command":"status","args":["--json"]}""");
Assert.True(
firstWaveCompleted.Wait(statusWaveTimeout),
"The first parallel batch wave did not complete.");
runTask = StartIssue4872InteractiveBatch(
dbPath,
input,
stdout,
stderr,
cancellation.Token);
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
await WaitForIssue4872FirstWaveAsync(
firstWaveCompleted.Task,
runTask,
statusWaveTimeout);

using (var update = writer.CreateCommand())
{
Expand All @@ -160,22 +164,24 @@ INSERT INTO files(path, lang, size, lines, checksum, modified)
Assert.Equal(initialDbLength, new FileInfo(dbPath).Length);
Assert.Equal(0, new FileInfo(dbPath + "-wal").Length);

input.WriteLine("""{"command":"status","args":["--json"]}""");
input.WriteLine("""{"command":"status","args":["--json"]}""");
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
input.Complete();

var exitCode = await runTask.WaitAsync(statusWaveTimeout);
Assert.Equal(CommandExitCodes.Success, exitCode);
Assert.True(
exitCode == CommandExitCodes.Success,
$"Parallel batch exited with code {exitCode}: {stdout}");
Assert.Equal(string.Empty, stderr.ToString());

var lines = ParseJsonLines(stdout.ToString());
try
{
Assert.Equal(5, lines.Count);
Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(4, Volatile.Read(ref openedSessions));
}
finally
Expand All @@ -199,6 +205,12 @@ INSERT INTO files(path, lang, size, lines, checksum, modified)
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
}
catch (TimeoutException exception)
{
throw new TimeoutException(
"The dedicated parallel batch test thread did not drain after cancellation.",
exception);
}
}
}
}
Expand Down Expand Up @@ -240,33 +252,37 @@ INSERT INTO files(path, lang, size, lines, checksum, modified)
using var input = new InteractiveBatchTextReader();
using var stdout = new StringWriter();
using var stderr = new StringWriter();
using var firstWaveCompleted = new CountdownEvent(2);
var firstWaveCompleted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
using var cancellation = new CancellationTokenSource();
Task<int>? runTask = null;
var openedSessions = 0;
var completedFirstWaveCommands = 0;
QueryCommandRunner.BatchParallelSessionOpenedForTesting =
() => Interlocked.Increment(ref openedSessions);
QueryCommandRunner.BatchParallelCommandCompletedForTesting = lineNumber =>
{
if (lineNumber <= 2)
firstWaveCompleted.Signal();
if (lineNumber <= 2
&& Interlocked.Increment(ref completedFirstWaveCommands) == 2)
{
firstWaveCompleted.TrySetResult();
}
};

try
{
runTask = Task.Run(() =>
{
using var capture = ConsoleCapture.Start(stdout, stderr, input);
return QueryCommandRunner.RunBatch(
["--db", dbPath, "--json-summary", "--parallel", "2"],
_jsonOptions,
cancellationToken: cancellation.Token);
});
input.WriteLine("""{"command":"status","args":["--json"]}""");
input.WriteLine("""{"command":"status","args":["--json"]}""");
Assert.True(
firstWaveCompleted.Wait(TimeSpan.FromSeconds(60)),
"The first parallel batch wave did not complete.");
runTask = StartIssue4872InteractiveBatch(
dbPath,
input,
stdout,
stderr,
cancellation.Token);
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
await WaitForIssue4872FirstWaveAsync(
firstWaveCompleted.Task,
runTask,
TimeSpan.FromSeconds(60));

using (var update = writer.CreateCommand())
{
Expand All @@ -277,22 +293,24 @@ INSERT INTO files(path, lang, size, lines, checksum, modified)
Assert.Equal(1, update.ExecuteNonQuery());
}

input.WriteLine("""{"command":"status","args":["--json"]}""");
input.WriteLine("""{"command":"status","args":["--json"]}""");
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
input.WriteLine("""{"command":"files","args":["--format","count","--json"]}""");
input.Complete();

var exitCode = await runTask.WaitAsync(TimeSpan.FromSeconds(60));
Assert.Equal(CommandExitCodes.Success, exitCode);
Assert.True(
exitCode == CommandExitCodes.Success,
$"Parallel batch exited with code {exitCode}: {stdout}");
Assert.Equal(string.Empty, stderr.ToString());

var lines = ParseJsonLines(stdout.ToString());
try
{
Assert.Equal(5, lines.Count);
Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("files").GetInt32());
Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("count").GetInt32());
Assert.Equal(4, Volatile.Read(ref openedSessions));
}
finally
Expand All @@ -316,6 +334,12 @@ INSERT INTO files(path, lang, size, lines, checksum, modified)
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
}
catch (TimeoutException exception)
{
throw new TimeoutException(
"The dedicated parallel batch test thread did not drain after cancellation.",
exception);
}
}
}
}
Expand Down Expand Up @@ -529,4 +553,52 @@ private static string ReadJournalMode(SqliteConnection connection)
command.CommandText = "PRAGMA journal_mode";
return Assert.IsType<string>(command.ExecuteScalar());
}

private Task<int> StartIssue4872InteractiveBatch(
string dbPath,
InteractiveBatchTextReader input,
StringWriter stdout,
StringWriter stderr,
CancellationToken cancellationToken)
=> Task.Factory.StartNew(
() =>
{
using (var capture = ConsoleCapture.Start(stdout, stderr, input))
{
return QueryCommandRunner.RunBatch(
["--db", dbPath, "--json-summary", "--parallel", "2"],
_jsonOptions,
cancellationToken: cancellationToken);
}
},
CancellationToken.None,
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);

private static async Task WaitForIssue4872FirstWaveAsync(
Task firstWaveCompleted,
Task<int> runTask,
TimeSpan timeout)
{
Task boundary;
try
{
boundary = await Task.WhenAny(firstWaveCompleted, runTask)
.WaitAsync(timeout);
}
catch (TimeoutException exception)
{
throw new TimeoutException(
"The first parallel batch wave did not complete before its synchronization boundary.",
exception);
}
if (ReferenceEquals(boundary, runTask))
{
var exitCode = await runTask;
Assert.Fail(
$"The parallel batch exited with code {exitCode} before the first wave completed.");
}

await firstWaveCompleted;
}
}
Loading