From fd8f40485bfd82f920e925799cc6ef53e0a9fbb9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:59:30 +0900 Subject: [PATCH] Fix console output synchronization for #1734 --- changelog.d/unreleased/1734.fixed.md | 17 +++++++ src/CodeIndex/Cli/ConsoleUi.cs | 24 ++++++++++ src/CodeIndex/Program.cs | 1 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 59 +++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 changelog.d/unreleased/1734.fixed.md diff --git a/changelog.d/unreleased/1734.fixed.md b/changelog.d/unreleased/1734.fixed.md new file mode 100644 index 0000000000..3c9974326e --- /dev/null +++ b/changelog.d/unreleased/1734.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1734 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Program.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs +--- + +## English + +- **Console output is synchronized before spinner/progress rendering (#1734)** — `cdidx` now wraps stdout and stderr with synchronized writers at startup so spinner frames and main-thread progress lines are emitted as whole writes instead of interleaving characters. + +## 日本語 + +- **スピナー / 進捗表示の前にコンソール出力を同期するようになりました (#1734)** — `cdidx` は起動時に stdout / stderr を同期 writer で包むため、スピナーフレームとメインスレッドの進捗行が文字単位で混ざらず、まとまった書き込みとして出力されます。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 9822ebf2fa..6be1e0d37d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -103,6 +103,8 @@ public static string FormatSummaryLine(string label, object? value, int labelWid private const int SpinnerStopDelayMs = 20; private const int ConsoleLineMargin = 1; private static readonly object TerminalLock = new(); + private static TextWriter? _synchronizedOut; + private static TextWriter? _synchronizedError; private static readonly string[] ByteUnits = ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB"]; private static readonly string[] DefaultBrailleSpinnerFrames = @@ -128,6 +130,26 @@ internal static string FoundSummary(int count, string singular, string? plural = : $"Found {Counted(count, singular, plural)}."; } + internal static void EnsureConsoleWritersSynchronized() + { + lock (TerminalLock) + { + var output = Console.Out; + if (!ReferenceEquals(output, _synchronizedOut)) + { + _synchronizedOut = TextWriter.Synchronized(output); + Console.SetOut(_synchronizedOut); + } + + var error = Console.Error; + if (!ReferenceEquals(error, _synchronizedError)) + { + _synchronizedError = TextWriter.Synchronized(error); + Console.SetError(_synchronizedError); + } + } + } + // --- Spinner / スピナー --- public static string FormatDuration(TimeSpan duration, DurationOutputFormat format = DurationOutputFormat.Auto) @@ -185,6 +207,8 @@ private static string FormatDurationAsHms(TimeSpan duration) /// public static CancellationTokenSource? StartSpinner(string message, string[] frames) { + EnsureConsoleWritersSynchronized(); + // Braille frames are single-char; themed frames are longer strings containing the display text // ブレイルフレームは1文字、テーマフレームは表示テキストを含む長い文字列 bool isThemed = frames.Length > 0 && frames[0].Length > 2; diff --git a/src/CodeIndex/Program.cs b/src/CodeIndex/Program.cs index 7c1231f469..76ca0232f9 100644 --- a/src/CodeIndex/Program.cs +++ b/src/CodeIndex/Program.cs @@ -5,4 +5,5 @@ // characters (box-drawing, block elements, etc.) to appear as '?'. // Windows のコンソールは既定で OEM コードページを使用するため、Unicode 文字が文字化けします。 Console.OutputEncoding = Encoding.UTF8; +ConsoleUi.EnsureConsoleWritersSynchronized(); return ProgramRunner.Run(args); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 79019ae9a4..805753d74b 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -219,6 +219,50 @@ public void PrintWarning_FlushesBothConsoleStreams() Assert.True(output.FlushCount > 0); } + [Fact] + public void EnsureConsoleWritersSynchronized_SerializesConcurrentWholeStringWrites() + { + using var output = new SlowChunkingTextWriter(); + using var capture = ConsoleCapture.Start(output, error: null); + ConsoleUi.EnsureConsoleWritersSynchronized(); + + const int iterations = 40; + var left = new Thread(() => + { + for (var i = 0; i < iterations; i++) + Console.Write("[spinner-frame]\n"); + }); + var right = new Thread(() => + { + for (var i = 0; i < iterations; i++) + Console.Write("[progress-line]\n"); + }); + + left.Start(); + right.Start(); + left.Join(); + right.Join(); + + var lines = output.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(iterations * 2, lines.Length); + Assert.All(lines, line => Assert.True( + line is "[spinner-frame]" or "[progress-line]", + $"Unexpected interleaved line: {line}")); + } + + [Fact] + public void StartSpinner_RedirectedOutput_UsesSynchronizedWriterBeforeFallbackLine() + { + using var output = new StringWriter(); + using var capture = ConsoleCapture.Start(output, error: null); + + var cts = ConsoleUi.StartSpinner("Indexing...", ["|"]); + + Assert.Null(cts); + Assert.Contains("Indexing...", output.ToString()); + } + [Fact] public void GetWindowWidth_ColumnsEnvVarSet_UsesColumnsValue() { @@ -1447,6 +1491,21 @@ public override void Flush() } } + private sealed class SlowChunkingTextWriter : TextWriter + { + private readonly StringBuilder builder = new(); + + public override Encoding Encoding => Encoding.UTF8; + + public override void Write(char value) + { + builder.Append(value); + Thread.Sleep(1); + } + + public override string ToString() => builder.ToString(); + } + private static string CaptureUsageOutput(bool showBanner = true) { using var capture = ConsoleCapture.Start(captureOut: true);