Summary
ConsoleUi.StartSpinner runs its animation loop inside Task.Run but uses a synchronous Task.Delay(...).Wait(ct) between frames. This is the classic sync-over-async anti-pattern: it pins one thread-pool worker for the entire spinner lifetime.
Evidence
src/CodeIndex/Cli/ConsoleUi.cs:72-84:
Task.Run(() =>
{
int i = 0;
while (!ct.IsCancellationRequested)
{
var frame = frames[i % frames.Length];
var line = isThemed ? $"\r{frame}" : $"\r{frame} {message}";
Console.Write(line);
Console.Out.Flush();
i++;
try { Task.Delay(SpinnerFrameDelayMs, ct).Wait(ct); } catch (OperationCanceledException) { break; }
}
}, ct);
The blocking .Wait(ct) parks a worker thread for SpinnerFrameDelayMs between every frame, instead of yielding it back to the pool.
Impact
- Each in-flight indexing operation pins one thread-pool worker.
- Under MCP / tests / nested cdidx invocations, multiple spinners can quickly saturate the thread-pool minimum-thread budget, causing other awaits to queue.
- The spinner thread also can't react to other cancellation sources without polling.
Proposed direction
Rewrite as a proper async loop:
_ = Task.Run(async () =>
{
int i = 0;
while (!ct.IsCancellationRequested)
{
Console.Write(...);
Console.Out.Flush();
i++;
try { await Task.Delay(SpinnerFrameDelayMs, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}, ct);
Repro env
- Branch:
main @ 2ee912d (release v1.21.0)
Summary
ConsoleUi.StartSpinnerruns its animation loop insideTask.Runbut uses a synchronousTask.Delay(...).Wait(ct)between frames. This is the classic sync-over-async anti-pattern: it pins one thread-pool worker for the entire spinner lifetime.Evidence
src/CodeIndex/Cli/ConsoleUi.cs:72-84:The blocking
.Wait(ct)parks a worker thread forSpinnerFrameDelayMsbetween every frame, instead of yielding it back to the pool.Impact
Proposed direction
Rewrite as a proper async loop:
Repro env
main@ 2ee912d (release v1.21.0)