diff --git a/changelog.d/unreleased/1421.fixed.md b/changelog.d/unreleased/1421.fixed.md new file mode 100644 index 0000000000..1fd20466da --- /dev/null +++ b/changelog.d/unreleased/1421.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1421 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs +--- + +## English + +- **Stopped spinner delays from blocking thread-pool workers (#1421)** - `ConsoleUi.StartSpinner` now awaits between frames instead of synchronously waiting inside the background task. + +## 日本語 + +- **スピナー待機がスレッドプール worker をブロックしないようにしました (#1421)** - `ConsoleUi.StartSpinner` はバックグラウンドタスク内で同期 wait せず、フレーム間を await するようになりました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 6be1e0d37d..3c3a21a157 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -221,7 +221,7 @@ private static string FormatDurationAsHms(TimeSpan duration) var cts = new CancellationTokenSource(); var ct = cts.Token; - Task.Run(() => + _ = Task.Run(async () => { int i = 0; while (!ct.IsCancellationRequested) @@ -234,7 +234,7 @@ private static string FormatDurationAsHms(TimeSpan duration) Console.Out.Flush(); } i++; - try { Task.Delay(SpinnerFrameDelayMs, ct).Wait(ct); } catch (OperationCanceledException) { break; } + try { await Task.Delay(SpinnerFrameDelayMs, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } }, ct); return cts; diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 805753d74b..87e5b15f68 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using System.Reflection.Emit; using System.Text; using System.Text.RegularExpressions; using CodeIndex.Cli; @@ -11,6 +13,33 @@ namespace CodeIndex.Tests; [Collection("SQLite pool sensitive")] public class ConsoleUiTests { + private static readonly Dictionary SingleByteOpCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.GetValue(null) is OpCode opCode && opCode.Size == 1) + .Select(field => (OpCode)field.GetValue(null)!) + .ToDictionary(opCode => opCode.Value); + + private static readonly Dictionary MultiByteOpCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.GetValue(null) is OpCode opCode && opCode.Size == 2) + .Select(field => (OpCode)field.GetValue(null)!) + .ToDictionary(opCode => (short)(opCode.Value & 0xff)); + + [Fact] + public void StartSpinner_BackgroundLoop_DoesNotBlockOnTaskWait() + { + var methods = typeof(ConsoleUi).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Concat(typeof(ConsoleUi) + .GetNestedTypes(BindingFlags.NonPublic) + .SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))) + .Where(method => method.Name.Contains("StartSpinner", StringComparison.Ordinal) + || (method.DeclaringType?.Name.Contains("StartSpinner", StringComparison.Ordinal) ?? false)) + .ToArray(); + + Assert.NotEmpty(methods); + Assert.DoesNotContain(methods, CallsTaskWait); + } + [Fact] public void PrintUsage_WithBanner_IncludesAsciiArt() { @@ -1506,6 +1535,67 @@ public override void Write(char value) public override string ToString() => builder.ToString(); } + private static bool CallsTaskWait(MethodInfo method) + { + var body = method.GetMethodBody(); + var il = body?.GetILAsByteArray(); + if (il == null) + return false; + + var module = method.Module; + for (var i = 0; i < il.Length;) + { + var opCode = ReadOpCode(il, ref i); + if ((opCode == OpCodes.Call || opCode == OpCodes.Callvirt) && i + 4 <= il.Length) + { + var token = BitConverter.ToInt32(il, i); + i += 4; + if (module.ResolveMember(token) is MethodInfo called + && called.Name == nameof(Task.Wait) + && called.DeclaringType == typeof(Task)) + { + return true; + } + + continue; + } + + i += OperandByteCount(opCode, il, i); + } + + return false; + } + + private static OpCode ReadOpCode(byte[] il, ref int offset) + { + var first = il[offset++]; + if (first != 0xfe) + return SingleByteOpCodes[(short)first]; + + var second = il[offset++]; + return MultiByteOpCodes[(short)second]; + } + + private static int OperandByteCount(OpCode opCode, byte[] il, int operandOffset) + => opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineBrTarget + or OperandType.InlineField + or OperandType.InlineI + or OperandType.InlineMethod + or OperandType.InlineSig + or OperandType.InlineString + or OperandType.InlineTok + or OperandType.InlineType + or OperandType.ShortInlineR => 4, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (4 * BitConverter.ToInt32(il, operandOffset)), + _ => throw new NotSupportedException($"Unsupported IL operand type: {opCode.OperandType}"), + }; + private static string CaptureUsageOutput(bool showBanner = true) { using var capture = ConsoleCapture.Start(captureOut: true);