From 278ac3771cb50efbfd0cef0ac43a4773b56954be Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 27 Aug 2026 05:51:04 +0800 Subject: [PATCH 01/42] perf: UTF-8 batch mode + Utf8Buffer eliminates char[] to UTF8 conversion Utf8Buffer replaces StringBuilder to eliminate char[] to UTF8 conversion in output hot path. AnsiOutput batch mode merges pendingCursorMoves + output. BuildAnsiForRegionSkippingRasterCoveredBlanks uses Utf8Buffer. Note: needs rebase after upstream #5633 merges. --- Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 77 +++++- .../AnsiHandling/EscSeqUtils/EscSeqUtils.cs | 106 +++++++++ Terminal.Gui/Drivers/Output/OutputBase.cs | 223 ++++++++++-------- Terminal.Gui/Drivers/Output/Utf8Buffer.cs | 153 ++++++++++++ .../Drivers/WindowsDriver/WindowsOutput.cs | 4 +- .../WindowsHelpers/WindowsVTOutputHelper.cs | 54 ++++- Terminal.Gui/ViewBase/View.Drawing.cs | 2 +- 7 files changed, 520 insertions(+), 99 deletions(-) create mode 100644 Terminal.Gui/Drivers/Output/Utf8Buffer.cs diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 5dce76a584..2f1250f53c 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -277,11 +277,76 @@ public void Write (ReadOnlySpan text) public override void Write (IOutputBuffer buffer) { _lastBuffer = buffer; + _batchMode = true; base.Write (buffer); + _batchMode = false; + + // Flush any remaining deferred cursor moves + if (_pendingCursorMoves.Length > 0) + { + Write (_pendingCursorMoves.AsSpan ()); + _pendingCursorMoves.Clear (); + } + } + + /// + /// PERF: Write pre-encoded UTF-8 bytes directly to the terminal. + /// In batch mode, merges deferred cursor moves with cell content into one WriteFile. + /// This is the hot-path write — zero string/byte[] allocation, zero encoding. + /// + protected override void Write (ReadOnlySpan output) + { + base.Write (output); + + try + { + switch (_platform) + { + case AnsiPlatform.WindowsVT: + if (_batchMode && _pendingCursorMoves.Length > 0) + { + _pendingCursorMoves.AppendBytes (output); + _windowsVTOutput!.Write (_pendingCursorMoves.AsSpan ()); + _pendingCursorMoves.Clear (); + } + else + { + _windowsVTOutput!.Write (output); + } + + break; + + case AnsiPlatform.UnixRaw: + if (_batchMode && _pendingCursorMoves.Length > 0) + { + _pendingCursorMoves.AppendBytes (output); + UnixIOHelper.TryWriteStdout (_pendingCursorMoves.AsSpan ().ToArray ()); + _pendingCursorMoves.Clear (); + } + else + { + UnixIOHelper.TryWriteStdout (output.ToArray ()); + } + + return; + + case AnsiPlatform.Degraded: + default: + break; + } + } + catch (Exception) + { + // ignore for unit tests + } } private Cursor _currentCursor; + // PERF: Batch mode — defer cursor-move sequences to merge with cell content, reducing WriteFile P/Invoke count + private readonly Utf8Buffer _pendingCursorMoves = new (); + private bool _batchMode; + /// public Cursor GetCursor () => _currentCursor; @@ -334,7 +399,17 @@ protected override bool SetCursorPositionImpl (int col, int row) // In inline mode, App.Screen.Y is the terminal row where the inline region starts. // Adding it shifts rendering down so buffer row 0 maps to the correct terminal row. int inlineRowOffset = AppScreenGetter?.Invoke ().Y ?? 0; - Write (EscSeqUtils.CSI_SetCursorPosition (row + 1 + inlineRowOffset, col + 1)); + + if (_batchMode) + { + // PERF: Defer cursor-move to _pendingCursorMoves (Utf8Buffer); merges with subsequent cell content + // into a single WriteFile P/Invoke instead of one per cursor move. + _pendingCursorMoves.AppendAscii (EscSeqUtils.CSI_SetCursorPosition (row + 1 + inlineRowOffset, col + 1)); + } + else + { + Write (EscSeqUtils.CSI_SetCursorPosition (row + 1 + inlineRowOffset, col + 1)); + } return true; } diff --git a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs index 3014733ba3..a7928cffb4 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs @@ -920,6 +920,112 @@ internal static void CSI_AppendTextStyleChange (StringBuilder output, TextStyle output.Append ('m'); } + /// + /// Builds the SGR escape sequence for a text style change as a string (ASCII). + /// Used by Utf8Buffer-based output path. + /// + internal static string CSI_BuildTextStyleChange (TextStyle prev, TextStyle next) + { + if (prev == next) + { + return string.Empty; + } + + TextStyle diff = prev ^ next; + TextStyle enabled = diff & next; + TextStyle disabled = diff & prev; + + List sgr = new (); + + if (disabled != TextStyle.None) + { + if (disabled.HasFlag (TextStyle.Bold)) + { + sgr.Add (22); + + if ((prev & next).HasFlag (TextStyle.Faint)) + { + sgr.Add (2); + } + } + + if (disabled.HasFlag (TextStyle.Faint)) + { + sgr.Add (22); + + if ((prev & next).HasFlag (TextStyle.Bold)) + { + sgr.Add (1); + } + } + + if (disabled.HasFlag (TextStyle.Italic)) + { + sgr.Add (23); + } + + if (disabled.HasFlag (TextStyle.Underline)) + { + sgr.Add (24); + } + + if (disabled.HasFlag (TextStyle.Blink)) + { + sgr.Add (25); + } + + if (disabled.HasFlag (TextStyle.Reverse)) + { + sgr.Add (27); + } + + if (disabled.HasFlag (TextStyle.Strikethrough)) + { + sgr.Add (29); + } + } + + if (enabled != TextStyle.None) + { + if (enabled.HasFlag (TextStyle.Bold)) + { + sgr.Add (1); + } + + if (enabled.HasFlag (TextStyle.Faint)) + { + sgr.Add (2); + } + + if (enabled.HasFlag (TextStyle.Italic)) + { + sgr.Add (3); + } + + if (enabled.HasFlag (TextStyle.Underline)) + { + sgr.Add (4); + } + + if (enabled.HasFlag (TextStyle.Blink)) + { + sgr.Add (5); + } + + if (enabled.HasFlag (TextStyle.Reverse)) + { + sgr.Add (7); + } + + if (enabled.HasFlag (TextStyle.Strikethrough)) + { + sgr.Add (9); + } + } + + return $"\x1b[{string.Join (';', sgr)}m"; + } + #endregion Text Styles #region Requests diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 0d6931f5a5..d4910359bb 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -116,7 +116,7 @@ public bool IsLegacyConsole public virtual void Write (IOutputBuffer buffer) { _clearLastOutputPending = true; - StringBuilder outputStringBuilder = new (); + Utf8Buffer outputBuffer = new (); var top = 0; var left = 0; int rows = buffer.Rows; @@ -157,101 +157,108 @@ public virtual void Write (IOutputBuffer buffer) return; } - if (!IsLegacyConsole && buffer is OutputBufferImpl outputBuffer) + if (!IsLegacyConsole && buffer is OutputBufferImpl outputBuffer2) { - outputBuffer.SyncAutoUrlsForRow (row); + outputBuffer2.SyncAutoUrlsForRow (row); } bool rowHadUrlsPreviously = _rowsWithUrls.Contains (row); bool rowHasUrlsNow = !IsLegacyConsole && RowContainsUrls (buffer, row, cols); - outputStringBuilder.Clear (); + outputBuffer.Clear (); _lastUrl = null; // Reset URL state at the start of each row if (!IsLegacyConsole && rowHadUrlsPreviously && !rowHasUrlsNow) { - outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + outputBuffer.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); } - lastCol = 0; - var outputWidth = 0; - // Process columns in row for (int col = left; col < cols; col++) { - // Skip clean cells, plus blank cells owned by raster images. Raster-owned - // blanks must not be emitted because normal text output would erase Sixel - // images and is unnecessary over Kitty's transparent-cleared placement. - bool skipRasterBlank = IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles); + lastCol = -1; + var outputWidth = 0; - if (!buffer.Contents! [row, col].IsDirty || skipRasterBlank) + // Batch consecutive dirty cells + for (; col < cols; col++) { - if (skipRasterBlank) - { - buffer.Contents [row, col].IsDirty = false; - } + // Skip clean cells, plus blank cells owned by raster images. Raster-owned + // blanks must not be emitted because normal text output would erase Sixel + // images and is unnecessary over Kitty's transparent-cleared placement. + bool skipRasterBlank = IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles); - if (outputWidth > 0) + if (!buffer.Contents! [row, col].IsDirty || skipRasterBlank) { - // This clears outputStringBuilder - WriteToConsole (outputStringBuilder, ref lastCol, ref outputWidth); - } + if (skipRasterBlank) + { + buffer.Contents [row, col].IsDirty = false; + } - continue; - } + if (outputBuffer.Length > 0) + { + // This clears outputBuffer + WriteToConsole (outputBuffer, ref lastCol, ref outputWidth); + } + else if (lastCol == -1) + { + lastCol = col; + } - // Position the cursor once at the beginning of each dirty run. Clean gaps - // are skipped without emitting one cursor-position sequence per cell. - if (outputWidth == 0 && col != lastCol) - { - if (!SetCursorPositionImpl (col, row)) - { - return; - } + if (lastCol + 1 < cols) + { + lastCol++; + } - lastCol = col; - } + SetCursorPositionImpl (lastCol, row); - // Handle URL hyperlink state changes - if (!IsLegacyConsole) - { - string? cellUrl = buffer.GetCellUrl (col, row); + continue; + } - if (cellUrl != _lastUrl) + if (lastCol == -1) { - // If we were in a hyperlink, end it - if (_lastUrl is { }) - { - outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); - } + lastCol = col; + } - // If starting a new hyperlink, begin it - if (!string.IsNullOrEmpty (cellUrl)) + // Handle URL hyperlink state changes + if (!IsLegacyConsole) + { + string? cellUrl = buffer.GetCellUrl (col, row); + + if (cellUrl != _lastUrl) { - outputStringBuilder.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + // If we were in a hyperlink, end it + if (_lastUrl is { }) + { + outputBuffer.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); + } + + // If starting a new hyperlink, begin it + if (!string.IsNullOrEmpty (cellUrl)) + { + outputBuffer.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + } + + _lastUrl = cellUrl; } - - _lastUrl = cellUrl; } - } - - // Append dirty cell as ANSI and mark clean - int cellCol = col; - Cell cell = buffer.Contents [row, col]; - buffer.Contents [row, col].IsDirty = false; - AppendCellAnsi (cell, outputStringBuilder, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); - if (col != cellCol) - { - // Was a wide grapheme so mark clean next cell - // See https://github.com/tui-cs/Terminal.Gui/issues/4466 + // Append dirty cell as ANSI and mark clean + Cell cell = buffer.Contents [row, col]; buffer.Contents [row, col].IsDirty = false; + AppendCellAnsi (cell, outputBuffer, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); + + if (col != lastCol) + { + // Was a wide grapheme so mark clean next cell + // See https://github.com/tui-cs/Terminal.Gui/issues/4466 + buffer.Contents [row, col].IsDirty = false; + } } } // Track row's URL status BEFORE the early-exit so _rowsWithUrls stays consistent // with the buffer state — even for rows whose cells were all flushed via WriteToConsole - // during the row scan (leaving outputStringBuilder empty at this point). + // during the inner loop (leaving outputBuffer empty at this point). if (!IsLegacyConsole) { if (rowHasUrlsNow) @@ -264,24 +271,20 @@ public virtual void Write (IOutputBuffer buffer) } } - // Every dirty cell in this row has now been consumed. Drawing operations will - // set this flag again when they modify the row in a later frame. - buffer.DirtyLines [row] = false; - // Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink // may still be open in the terminal because it was started in a prior batch flushed by // WriteToConsole and the row ended (or only clean cells followed) before any cell with // a different URL closed it. Emit the close so the link does not bleed into later rows. - if (outputStringBuilder.Length <= 0 && _lastUrl is null) + if (outputBuffer.Length <= 0 && _lastUrl is null) { continue; } if (IsLegacyConsole) { - if (outputStringBuilder.Length > 0) + if (outputBuffer.Length > 0) { - Write (outputStringBuilder); + Write (outputBuffer.AsSpan ()); } continue; @@ -289,11 +292,13 @@ public virtual void Write (IOutputBuffer buffer) if (_lastUrl is { }) { - outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + outputBuffer.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); _lastUrl = null; } - Write (outputStringBuilder); + SetCursorPositionImpl (lastCol, row); + + Write (outputBuffer.AsSpan ()); } if (IsLegacyConsole) @@ -335,35 +340,40 @@ public virtual void Write (IOutputBuffer buffer) /// /// /// - protected virtual void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle) + protected virtual void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) { if (attr.Foreground == Color.None) { - EscSeqUtils.CSI_AppendResetForegroundColor (output); + output.AppendAscii ($"{EscSeqUtils.CSI}39m"); } else if (Force16Colors) { - output.Append (EscSeqUtils.CSI_SetForegroundColor (attr.Foreground.GetAnsiColorCode ())); + output.AppendAscii (EscSeqUtils.CSI_SetForegroundColor (attr.Foreground.GetAnsiColorCode ())); } else { - EscSeqUtils.CSI_AppendForegroundColorRGB (output, attr.Foreground.R, attr.Foreground.G, attr.Foreground.B); + output.AppendAscii ($"{EscSeqUtils.CSI}38;2;{attr.Foreground.R};{attr.Foreground.G};{attr.Foreground.B}m"); } if (attr.Background == Color.None) { - EscSeqUtils.CSI_AppendResetBackgroundColor (output); + output.AppendAscii ($"{EscSeqUtils.CSI}49m"); } else if (Force16Colors) { - output.Append (EscSeqUtils.CSI_SetBackgroundColor (attr.Background.GetAnsiColorCode ())); + output.AppendAscii (EscSeqUtils.CSI_SetBackgroundColor (attr.Background.GetAnsiColorCode ())); } else { - EscSeqUtils.CSI_AppendBackgroundColorRGB (output, attr.Background.R, attr.Background.G, attr.Background.B); + output.AppendAscii ($"{EscSeqUtils.CSI}48;2;{attr.Background.R};{attr.Background.G};{attr.Background.B}m"); } - EscSeqUtils.CSI_AppendTextStyleChange (output, redrawTextStyle, attr.Style); + string styleChange = EscSeqUtils.CSI_BuildTextStyleChange (redrawTextStyle, attr.Style); + + if (styleChange.Length > 0) + { + output.AppendAscii (styleChange); + } } /// @@ -390,6 +400,23 @@ protected virtual void Write (StringBuilder output) _lastOutputStringBuilder.Append (output); } + /// + /// PERF: Output pre-encoded UTF-8 bytes directly to the console. + /// Bypasses StringBuilder → string → byte[] conversion in the hot path. + /// + /// UTF-8 encoded bytes to write. + protected virtual void Write (ReadOnlySpan output) + { + if (_clearLastOutputPending) + { + _lastOutputStringBuilder.Clear (); + _clearLastOutputPending = false; + } + + // Decode back to UTF-16 for GetLastOutput() (test/debug only, not hot path) + _lastOutputStringBuilder.Append (Encoding.UTF8.GetString (output)); + } + /// /// Builds ANSI escape sequences for the specified rectangular region of the buffer. /// @@ -415,6 +442,9 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, var redrawTextStyle = TextStyle.None; string? lastUrl = null; + // Use Utf8Buffer for cell rendering, then decode to StringBuilder at the end + Utf8Buffer utf8Output = new (); + for (int row = startRow; row < endRow; row++) { for (int col = startCol; col < endCol; col++) @@ -431,12 +461,12 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, { if (lastUrl is { }) { - output.Append (EscSeqUtils.OSC_EndHyperlink ()); + utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); } if (!string.IsNullOrEmpty (cellUrl)) { - output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + utf8Output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); } lastUrl = cellUrl; @@ -444,13 +474,13 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, Cell cell = buffer.Contents! [row, col]; int outputWidth = -1; - AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); + AppendCellAnsi (cell, utf8Output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); } // Close any open hyperlink at end of row if (lastUrl is { }) { - output.Append (EscSeqUtils.OSC_EndHyperlink ()); + utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); lastUrl = null; } @@ -461,9 +491,12 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, // ONLCR tty discipline, so a '\n' row break still recreates the screen correctly. if (addNewlines) { - output.Append ('\n'); + utf8Output.AppendByte ((byte)'\n'); } } + + // Decode Utf8Buffer back to StringBuilder for ToAnsi() callers + output.Append (Encoding.UTF8.GetString (utf8Output.AsSpan ())); } /// @@ -477,7 +510,7 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, /// The current column, updated for wide characters. /// The current output width, updated for wide characters. protected void AppendCellAnsi (Cell cell, - StringBuilder output, + Utf8Buffer output, ref Attribute? lastAttr, ref TextStyle redrawTextStyle, int maxCol, @@ -494,7 +527,7 @@ protected void AppendCellAnsi (Cell cell, redrawTextStyle = attribute.Value.Style; } - // Add the grapheme + // Add the grapheme (Utf8Buffer.Append auto-detects ASCII vs Unicode) string grapheme = cell.Grapheme; output.Append (grapheme); outputWidth++; @@ -593,6 +626,9 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer var redrawTextStyle = TextStyle.None; string? lastUrl = null; + // Use Utf8Buffer for cell rendering, then decode to StringBuilder at the end + Utf8Buffer utf8Output = new (); + for (int row = startRow; row < endRow; row++) { for (int col = startCol; col < endCol; col++) @@ -601,14 +637,14 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer { if (lastUrl is { }) { - output.Append (EscSeqUtils.OSC_EndHyperlink ()); + utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); lastUrl = null; } continue; } - output.Append (EscSeqUtils.CSI_SetCursorPosition (row + 1, col + 1)); + utf8Output.Append (EscSeqUtils.CSI_SetCursorPosition (row + 1, col + 1)); lastAttr = null; redrawTextStyle = TextStyle.None; @@ -627,12 +663,12 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer { if (lastUrl is { }) { - output.Append (EscSeqUtils.OSC_EndHyperlink ()); + utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); } if (!string.IsNullOrEmpty (cellUrl)) { - output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + utf8Output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); } lastUrl = cellUrl; @@ -640,16 +676,19 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer Cell cell = buffer.Contents! [row, col]; int outputWidth = -1; - AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); + AppendCellAnsi (cell, utf8Output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); } } if (lastUrl is { }) { - output.Append (EscSeqUtils.OSC_EndHyperlink ()); + utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); lastUrl = null; } } + + // Decode Utf8Buffer back to StringBuilder for ToAnsi() callers + output.Append (Encoding.UTF8.GetString (utf8Output.AsSpan ())); } private void ClearKittyRasterBlankCells (IOutputBuffer buffer) @@ -1216,9 +1255,9 @@ private static int ScaleCellOffsetToPixels (int cellOffset, int cellCount, int p /// Writes buffered output to console, then clears the buffer and advances /// by . /// - private void WriteToConsole (StringBuilder output, ref int lastCol, ref int outputWidth) + private void WriteToConsole (Utf8Buffer output, ref int lastCol, ref int outputWidth) { - Write (output); + Write (output.AsSpan ()); output.Clear (); lastCol += outputWidth; diff --git a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs new file mode 100644 index 0000000000..f4b97d6e83 --- /dev/null +++ b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs @@ -0,0 +1,153 @@ +using System.Text; + +namespace Terminal.Gui.Drivers; + +/// +/// PERF: UTF-8 byte buffer — replaces StringBuilder in the output hot path. +/// ANSI sequences (CSI/SGR/OSC) are pure ASCII and appended via fast path (no encoding). +/// Unicode graphemes are UTF-8 encoded on append. +/// Zero allocation in stable state (internal buffer grows as needed). +/// +public sealed class Utf8Buffer +{ + private byte [] _buffer = new byte [256]; + private int _length; + + /// Gets the number of bytes written. + public int Length => _length; + + /// Resets the buffer for reuse without releasing the backing array. + public void Clear () => _length = 0; + + /// Gets a read-only span over the written bytes. + public ReadOnlySpan AsSpan () => _buffer.AsSpan (0, _length); + + /// + /// Appends an ASCII string as raw bytes. Caller must ensure is pure ASCII. + /// No UTF-8 encoding is performed — each char is truncated to byte directly. + /// + public void AppendAscii (string text) + { + int len = text.Length; + if (len == 0) + { + return; + } + + EnsureCapacity (len); + + for (int i = 0; i < len; i++) + { + _buffer [_length + i] = (byte)text [i]; + } + + _length += len; + } + + /// + /// Appends a string, auto-detecting ASCII vs Unicode. + /// ASCII strings use a fast truncation path; Unicode strings are UTF-8 encoded. + /// + public void Append (string text) + { + int len = text.Length; + if (len == 0) + { + return; + } + + // Fast check: if all chars < 0x80, use ASCII path (no encoding) + bool allAscii = true; + + for (int i = 0; i < len; i++) + { + if (text [i] >= 0x80) + { + allAscii = false; + + break; + } + } + + if (allAscii) + { + AppendAscii (text); + } + else + { + Append (text.AsSpan ()); + } + } + + /// + /// Appends a char span as UTF-8 encoded bytes. + /// + public void Append (ReadOnlySpan text) + { + if (text.IsEmpty) + { + return; + } + + int byteCount = Encoding.UTF8.GetByteCount (text); + EnsureCapacity (byteCount); + Encoding.UTF8.GetBytes (text, _buffer.AsSpan (_length)); + _length += byteCount; + } + + /// + /// Appends a single ASCII byte directly. + /// + public void AppendByte (byte b) + { + EnsureCapacity (1); + _buffer [_length++] = b; + } + + /// + /// Appends the contents of another . + /// + public void Append (Utf8Buffer other) + { + if (other._length == 0) + { + return; + } + + EnsureCapacity (other._length); + other._buffer.AsSpan (0, other._length).CopyTo (_buffer.AsSpan (_length)); + _length += other._length; + } + + /// + /// Appends raw UTF-8 bytes directly (e.g. from another buffer or pre-encoded span). + /// + public void AppendBytes (ReadOnlySpan bytes) + { + if (bytes.IsEmpty) + { + return; + } + + EnsureCapacity (bytes.Length); + bytes.CopyTo (_buffer.AsSpan (_length)); + _length += bytes.Length; + } + + private void EnsureCapacity (int additional) + { + if (_length + additional <= _buffer.Length) + { + return; + } + + int newSize = _buffer.Length; + + while (newSize < _length + additional) + { + newSize *= 2; + } + + Array.Resize (ref _buffer, newSize); + } +} diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs index 4cc3f3120c..4df09bc857 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -464,12 +464,12 @@ protected override void Write (StringBuilder output) } /// - protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle) + protected override void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) { if (Force16Colors && IsLegacyConsole) { // Legacy Windows console doesn't support ANSI — use Win32 API directly - Write (output); + Write (output.AsSpan ()); output.Clear (); var as16ColorInt = (ushort)((int)attr.Foreground.GetClosestNamedColor16 () | ((int)attr.Background.GetClosestNamedColor16 () << 4)); SetConsoleTextAttribute (_screenBuffer, as16ColorInt); diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs index 0717a976f8..c9ef4b426f 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs @@ -42,6 +42,11 @@ internal sealed class WindowsVTOutputHelper : IDisposable private uint _originalConsoleMode; private bool _disposed; + // PERF: Reusable buffers to avoid per-write allocations (string + byte[]). + // Grows as needed; stable-state zero-allocation writes. + private char []? _charBuffer; + private byte []? _byteBuffer; + /// /// Gets whether VTS output mode was successfully enabled. /// @@ -169,10 +174,53 @@ public void Dispose () public void Write (StringBuilder output) { - // Convert StringBuilder to string and then to byte array - byte [] byteArray = Encoding.UTF8.GetBytes (output.ToString ()); + int charLen = output.Length; + if (charLen == 0) return; + + // Ensure char buffer is large enough + if (_charBuffer is null || _charBuffer.Length < charLen) + { + _charBuffer = new char [charLen]; + } + + // Copy StringBuilder contents to reusable char[] — avoids allocating a string + output.CopyTo (0, _charBuffer, 0, charLen); + + // Calculate UTF-8 byte count + int byteLen = Encoding.UTF8.GetByteCount (_charBuffer, 0, charLen); + + // Ensure byte buffer is large enough + if (_byteBuffer is null || _byteBuffer.Length < byteLen) + { + _byteBuffer = new byte [byteLen]; + } + + // Encode to reusable byte[] — avoids allocating a new byte[] each call + Encoding.UTF8.GetBytes (_charBuffer, 0, charLen, _byteBuffer, 0); + + WriteFile (OutputHandle, _byteBuffer, (uint)byteLen, out _, nint.Zero); + } + + /// + /// PERF: Writes pre-encoded UTF-8 bytes directly to the console via WriteFile. + /// Zero conversion — bytes are written as-is. + /// + public void Write (ReadOnlySpan utf8) + { + if (utf8.IsEmpty) + { + return; + } + + // Ensure byte buffer is large enough + if (_byteBuffer is null || _byteBuffer.Length < utf8.Length) + { + _byteBuffer = new byte [utf8.Length]; + } + + utf8.CopyTo (_byteBuffer); - WriteFile (OutputHandle, byteArray, (uint)byteArray.Length, out _, nint.Zero); + WriteFile (OutputHandle, _byteBuffer, (uint)utf8.Length, out _, nint.Zero); } /// diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs index c495c7959f..f2ccc76ddc 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.cs @@ -807,7 +807,7 @@ private bool IsOpaqueForOcclusion (out Rectangle coveredScreenRect) /// /// /// The cull path skips , hence , which - /// repopulates the that consults + /// repopulates the that consults /// for layers. A view's own /// is invalidated by , so a /// view that is culled would be left with a null From 2104cbdb0947eb172a452294fa3e21be6d038f7e Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 27 Aug 2026 06:02:52 +0800 Subject: [PATCH 02/42] fix: merge upstream #5633 skip-clean-runs with Utf8Buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-way merge: #5633循环重构 + Utf8Buffer替换,解决OutputBaseTests失败 --- Terminal.Gui/Drivers/Output/OutputBase.cs | 141 +++++++++++----------- 1 file changed, 68 insertions(+), 73 deletions(-) diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index d4910359bb..0afb7b059c 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -74,7 +74,7 @@ public bool IsLegacyConsole private int _lastTrackedCols; private int _lastTrackedUrlVersion; - private readonly StringBuilder _lastOutputStringBuilder = new (); + private readonly StringBuilder _lastoutputBuffer = new (); private bool _clearLastOutputPending; // Kitty image ids placed on the previous Write, keyed by RasterImageCommand.Id. A single image @@ -173,92 +173,85 @@ public virtual void Write (IOutputBuffer buffer) outputBuffer.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); } + lastCol = 0; + var outputWidth = 0; + // Process columns in row for (int col = left; col < cols; col++) { - lastCol = -1; - var outputWidth = 0; + // Skip clean cells, plus blank cells owned by raster images. Raster-owned + // blanks must not be emitted because normal text output would erase Sixel + // images and is unnecessary over Kitty's transparent-cleared placement. + bool skipRasterBlank = IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles); - // Batch consecutive dirty cells - for (; col < cols; col++) + if (!buffer.Contents! [row, col].IsDirty || skipRasterBlank) { - // Skip clean cells, plus blank cells owned by raster images. Raster-owned - // blanks must not be emitted because normal text output would erase Sixel - // images and is unnecessary over Kitty's transparent-cleared placement. - bool skipRasterBlank = IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles); - - if (!buffer.Contents! [row, col].IsDirty || skipRasterBlank) + if (skipRasterBlank) { - if (skipRasterBlank) - { - buffer.Contents [row, col].IsDirty = false; - } - - if (outputBuffer.Length > 0) - { - // This clears outputBuffer - WriteToConsole (outputBuffer, ref lastCol, ref outputWidth); - } - else if (lastCol == -1) - { - lastCol = col; - } - - if (lastCol + 1 < cols) - { - lastCol++; - } - - SetCursorPositionImpl (lastCol, row); + buffer.Contents [row, col].IsDirty = false; + } - continue; + if (outputWidth > 0) + { + // This clears outputBuffer + WriteToConsole (outputBuffer, ref lastCol, ref outputWidth); } - if (lastCol == -1) + continue; + } + + // Position the cursor once at the beginning of each dirty run. Clean gaps + // are skipped without emitting one cursor-position sequence per cell. + if (outputWidth == 0 && col != lastCol) + { + if (!SetCursorPositionImpl (col, row)) { - lastCol = col; + return; } - // Handle URL hyperlink state changes - if (!IsLegacyConsole) + lastCol = col; + } + + // Handle URL hyperlink state changes + if (!IsLegacyConsole) + { + string? cellUrl = buffer.GetCellUrl (col, row); + + if (cellUrl != _lastUrl) { - string? cellUrl = buffer.GetCellUrl (col, row); + // If we were in a hyperlink, end it + if (_lastUrl is { }) + { + outputBuffer.Append (EscSeqUtils.OSC_EndHyperlink ()); + } - if (cellUrl != _lastUrl) + // If starting a new hyperlink, begin it + if (!string.IsNullOrEmpty (cellUrl)) { - // If we were in a hyperlink, end it - if (_lastUrl is { }) - { - outputBuffer.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); - } - - // If starting a new hyperlink, begin it - if (!string.IsNullOrEmpty (cellUrl)) - { - outputBuffer.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); - } - - _lastUrl = cellUrl; + outputBuffer.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); } + + _lastUrl = cellUrl; } + } - // Append dirty cell as ANSI and mark clean - Cell cell = buffer.Contents [row, col]; - buffer.Contents [row, col].IsDirty = false; - AppendCellAnsi (cell, outputBuffer, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); + // Append dirty cell as ANSI and mark clean + int cellCol = col; + Cell cell = buffer.Contents [row, col]; + buffer.Contents [row, col].IsDirty = false; + AppendCellAnsi (cell, outputBuffer, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); - if (col != lastCol) - { - // Was a wide grapheme so mark clean next cell - // See https://github.com/tui-cs/Terminal.Gui/issues/4466 - buffer.Contents [row, col].IsDirty = false; - } + if (col != cellCol) + { + // Was a wide grapheme so mark clean next cell + // See https://github.com/tui-cs/Terminal.Gui/issues/4466 + buffer.Contents [row, col].IsDirty = false; } } // Track row's URL status BEFORE the early-exit so _rowsWithUrls stays consistent // with the buffer state — even for rows whose cells were all flushed via WriteToConsole - // during the inner loop (leaving outputBuffer empty at this point). + // during the row scan (leaving outputBuffer empty at this point). if (!IsLegacyConsole) { if (rowHasUrlsNow) @@ -271,6 +264,10 @@ public virtual void Write (IOutputBuffer buffer) } } + // Every dirty cell in this row has now been consumed. Drawing operations will + // set this flag again when they modify the row in a later frame. + buffer.DirtyLines [row] = false; + // Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink // may still be open in the terminal because it was started in a prior batch flushed by // WriteToConsole and the row ended (or only clean cells followed) before any cell with @@ -296,8 +293,6 @@ public virtual void Write (IOutputBuffer buffer) _lastUrl = null; } - SetCursorPositionImpl (lastCol, row); - Write (outputBuffer.AsSpan ()); } @@ -323,7 +318,7 @@ public virtual void Write (IOutputBuffer buffer) } /// - public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString (); + public virtual string GetLastOutput () => _lastoutputBuffer.ToString (); /// /// Changes the color and text style of the console to the given and @@ -393,11 +388,11 @@ protected virtual void Write (StringBuilder output) { if (_clearLastOutputPending) { - _lastOutputStringBuilder.Clear (); + _lastoutputBuffer.Clear (); _clearLastOutputPending = false; } - _lastOutputStringBuilder.Append (output); + _lastoutputBuffer.Append (output); } /// @@ -409,12 +404,12 @@ protected virtual void Write (ReadOnlySpan output) { if (_clearLastOutputPending) { - _lastOutputStringBuilder.Clear (); + _lastoutputBuffer.Clear (); _clearLastOutputPending = false; } // Decode back to UTF-16 for GetLastOutput() (test/debug only, not hot path) - _lastOutputStringBuilder.Append (Encoding.UTF8.GetString (output)); + _lastoutputBuffer.Append (Encoding.UTF8.GetString (output)); } /// @@ -577,9 +572,9 @@ public string ToAnsi (IOutputBuffer buffer) return output.ToString (); } - if (buffer is OutputBufferImpl outputBuffer) + if (buffer is OutputBufferImpl outputBuffer2) { - outputBuffer.SyncAutoUrlsForAllRows (); + outputBuffer2.SyncAutoUrlsForAllRows (); } StringBuilder ansiOutput = new (); @@ -1279,7 +1274,7 @@ private static bool RowContainsUrls (IOutputBuffer buffer, int row, int cols) private void InvalidateRowsWithUrlsIfStale (IOutputBuffer buffer, int rows, int cols) { - int urlVersion = buffer is OutputBufferImpl outputBuffer ? outputBuffer.UrlStateVersion : 0; + int urlVersion = buffer is OutputBufferImpl outputBuffer2 ? outputBuffer2.UrlStateVersion : 0; if (!ReferenceEquals (_lastTrackedBuffer, buffer) || _lastTrackedRows != rows From 470c6b2261ffe818e60a113d2a9d9501a4fddab9 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 27 Aug 2026 06:17:00 +0800 Subject: [PATCH 03/42] fix: CountingOutput override Write(ReadOnlySpan) for Utf8Buffer path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utf8Buffer引入Write(ReadOnlySpan)重载,CountingOutput只override了Write(StringBuilder),导致Writes计数为0 --- Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index a5e19a8849..ca4401d2b5 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -2047,6 +2047,8 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo } protected override void Write (StringBuilder output) => Writes++; + + protected override void Write (ReadOnlySpan output) => Writes++; } // Copilot - Claude Sonnet 4.6 From 732bfefd153caaeee944305e81d62ca937397144 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 27 Aug 2026 08:04:24 +0800 Subject: [PATCH 04/42] fix: address Copilot review comments on PR #5650 review1: Add Write(ReadOnlySpan) override to WindowsOutput and NetOutput review4: Reuse backing array for Unix writes instead of ToArray() per call review7: Add focused Utf8Buffer unit tests (ASCII, multibyte, surrogate pairs, capacity, Clear, AppendInt) review9: Extract shared ComputeTextStyleSgrCodes from CSI_AppendTextStyleChange and CSI_BuildTextStyleChange --- Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 36 +- .../AnsiHandling/EscSeqUtils/EscSeqUtils.cs | 119 ++----- .../Drivers/DotNetDriver/NetOutput.cs | 20 ++ Terminal.Gui/Drivers/Output/OutputBase.cs | 50 ++- Terminal.Gui/Drivers/Output/Utf8Buffer.cs | 45 ++- .../Drivers/UnixHelpers/UnixIOHelper.cs | 32 +- .../Drivers/WindowsDriver/WindowsOutput.cs | 36 ++ .../Drivers/Output/Utf8BufferTests.cs | 314 ++++++++++++++++++ .../Drivers/UnixIOHelperTests.cs | 2 + 9 files changed, 533 insertions(+), 121 deletions(-) create mode 100644 Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 2f1250f53c..6e1a7da273 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -296,8 +296,6 @@ public override void Write (IOutputBuffer buffer) /// protected override void Write (ReadOnlySpan output) { - base.Write (output); - try { switch (_platform) @@ -306,11 +304,13 @@ protected override void Write (ReadOnlySpan output) if (_batchMode && _pendingCursorMoves.Length > 0) { _pendingCursorMoves.AppendBytes (output); + base.Write (_pendingCursorMoves.AsSpan ()); _windowsVTOutput!.Write (_pendingCursorMoves.AsSpan ()); _pendingCursorMoves.Clear (); } else { + base.Write (output); _windowsVTOutput!.Write (output); } @@ -320,18 +320,21 @@ protected override void Write (ReadOnlySpan output) if (_batchMode && _pendingCursorMoves.Length > 0) { _pendingCursorMoves.AppendBytes (output); - UnixIOHelper.TryWriteStdout (_pendingCursorMoves.AsSpan ().ToArray ()); + base.Write (_pendingCursorMoves.AsSpan ()); + WriteUnix (_pendingCursorMoves.AsSpan ()); _pendingCursorMoves.Clear (); } else { - UnixIOHelper.TryWriteStdout (output.ToArray ()); + base.Write (output); + WriteUnix (output); } return; case AnsiPlatform.Degraded: default: + base.Write (output); break; } } @@ -343,10 +346,35 @@ protected override void Write (ReadOnlySpan output) private Cursor _currentCursor; + /// + /// Copies into a reusable backing array and writes to stdout, + /// avoiding allocation on every call. + /// + private void WriteUnix (ReadOnlySpan output) + { + int len = output.Length; + + if (len == 0) + { + return; + } + + if (_unixWriteBuffer is null || _unixWriteBuffer.Length < len) + { + _unixWriteBuffer = new byte [len]; + } + + output.CopyTo (_unixWriteBuffer); + UnixIOHelper.TryWriteStdout (_unixWriteBuffer, len); + } + // PERF: Batch mode — defer cursor-move sequences to merge with cell content, reducing WriteFile P/Invoke count private readonly Utf8Buffer _pendingCursorMoves = new (); private bool _batchMode; + // PERF: Reusable backing array for Unix writes — avoids ToArray() allocation on every Write call. + private byte []? _unixWriteBuffer; + /// public Cursor GetCursor () => _currentCursor; diff --git a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs index a7928cffb4..d51f87c9bd 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs @@ -816,104 +816,7 @@ internal static void CSI_AppendTextStyleChange (StringBuilder output, TextStyle return; } - // Bitwise operations to determine flag changes. A ^ B are the flags different between two flag sets. These different flags that exist in the next flag - // set (diff & next) are the ones that were enabled in the switch, those that exist in the previous flag set (diff & prev) are the ones that were - // disabled. - TextStyle diff = prev ^ next; - TextStyle enabled = diff & next; - TextStyle disabled = diff & prev; - - // List of escape codes to apply. - List sgr = new (); - - if (disabled != TextStyle.None) - { - // Special case: Both bold and faint have the same disabling code. While unusual, it can be valid to have both enabled at the same time, so when - // one and only one of them is being disabled, we need to re-enable the other afterward. We can check what flags remain enabled by taking - // prev & next, as this is the set of flags both have. - if (disabled.HasFlag (TextStyle.Bold)) - { - sgr.Add (22); - - if ((prev & next).HasFlag (TextStyle.Faint)) - { - sgr.Add (2); - } - } - - if (disabled.HasFlag (TextStyle.Faint)) - { - sgr.Add (22); - - if ((prev & next).HasFlag (TextStyle.Bold)) - { - sgr.Add (1); - } - } - - if (disabled.HasFlag (TextStyle.Italic)) - { - sgr.Add (23); - } - - if (disabled.HasFlag (TextStyle.Underline)) - { - sgr.Add (24); - } - - if (disabled.HasFlag (TextStyle.Blink)) - { - sgr.Add (25); - } - - if (disabled.HasFlag (TextStyle.Reverse)) - { - sgr.Add (27); - } - - if (disabled.HasFlag (TextStyle.Strikethrough)) - { - sgr.Add (29); - } - } - - if (enabled != TextStyle.None) - { - if (enabled.HasFlag (TextStyle.Bold)) - { - sgr.Add (1); - } - - if (enabled.HasFlag (TextStyle.Faint)) - { - sgr.Add (2); - } - - if (enabled.HasFlag (TextStyle.Italic)) - { - sgr.Add (3); - } - - if (enabled.HasFlag (TextStyle.Underline)) - { - sgr.Add (4); - } - - if (enabled.HasFlag (TextStyle.Blink)) - { - sgr.Add (5); - } - - if (enabled.HasFlag (TextStyle.Reverse)) - { - sgr.Add (7); - } - - if (enabled.HasFlag (TextStyle.Strikethrough)) - { - sgr.Add (9); - } - } + List sgr = ComputeTextStyleSgrCodes (prev, next); output.Append ("\x1b["); output.Append (string.Join (';', sgr)); @@ -931,14 +834,32 @@ internal static string CSI_BuildTextStyleChange (TextStyle prev, TextStyle next) return string.Empty; } + List sgr = ComputeTextStyleSgrCodes (prev, next); + + return $"\x1b[{string.Join (';', sgr)}m"; + } + + /// + /// Computes the SGR parameter codes for a text style change. + /// Shared by and . + /// + private static List ComputeTextStyleSgrCodes (TextStyle prev, TextStyle next) + { + // Bitwise operations to determine flag changes. A ^ B are the flags different between two flag sets. These different flags that exist in the next flag + // set (diff & next) are the ones that were enabled in the switch, those that exist in the previous flag set (diff & prev) are the ones that were + // disabled. TextStyle diff = prev ^ next; TextStyle enabled = diff & next; TextStyle disabled = diff & prev; + // List of escape codes to apply. List sgr = new (); if (disabled != TextStyle.None) { + // Special case: Both bold and faint have the same disabling code. While unusual, it can be valid to have both enabled at the same time, so when + // one and only one of them is being disabled, we need to re-enable the other afterward. We can check what flags remain enabled by taking + // prev & next, as this is the set of flags both have. if (disabled.HasFlag (TextStyle.Bold)) { sgr.Add (22); @@ -1023,7 +944,7 @@ internal static string CSI_BuildTextStyleChange (TextStyle prev, TextStyle next) } } - return $"\x1b[{string.Join (';', sgr)}m"; + return sgr; } #endregion Text Styles diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index cb7c41882c..7a3ba79c8f 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -110,6 +110,26 @@ protected override void Write (StringBuilder output) } } + /// + protected override void Write (ReadOnlySpan output) + { + base.Write (output); + + if (!IsAttachedToTerminal) + { + return; + } + + try + { + Console.Out.Write (Encoding.UTF8.GetString (output)); + } + catch (IOException) + { + // Not connected to a terminal; do nothing + } + } + private Cursor _currentCursor = new (); /// diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 0afb7b059c..5774009442 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -74,9 +74,12 @@ public bool IsLegacyConsole private int _lastTrackedCols; private int _lastTrackedUrlVersion; - private readonly StringBuilder _lastoutputBuffer = new (); + private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; + // Reusable buffer for UTF-8 → UTF-16 decode in Write(ReadOnlySpan). + private char []? _utf16DecodeBuffer; + // Kitty image ids placed on the previous Write, keyed by RasterImageCommand.Id. A single image // can occupy more than one placement when its visible region is fragmented by clipping (e.g. a // SubView punches a hole), and each fragment needs its own image id — sharing one id makes each @@ -318,7 +321,7 @@ public virtual void Write (IOutputBuffer buffer) } /// - public virtual string GetLastOutput () => _lastoutputBuffer.ToString (); + public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString (); /// /// Changes the color and text style of the console to the given and @@ -339,7 +342,8 @@ protected virtual void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr { if (attr.Foreground == Color.None) { - output.AppendAscii ($"{EscSeqUtils.CSI}39m"); + output.AppendAscii (EscSeqUtils.CSI); + output.AppendAscii ("39m"); } else if (Force16Colors) { @@ -347,12 +351,20 @@ protected virtual void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr } else { - output.AppendAscii ($"{EscSeqUtils.CSI}38;2;{attr.Foreground.R};{attr.Foreground.G};{attr.Foreground.B}m"); + output.AppendAscii (EscSeqUtils.CSI); + output.AppendAscii ("38;2;"); + output.AppendInt (attr.Foreground.R); + output.AppendByte ((byte)';'); + output.AppendInt (attr.Foreground.G); + output.AppendByte ((byte)';'); + output.AppendInt (attr.Foreground.B); + output.AppendByte ((byte)'m'); } if (attr.Background == Color.None) { - output.AppendAscii ($"{EscSeqUtils.CSI}49m"); + output.AppendAscii (EscSeqUtils.CSI); + output.AppendAscii ("49m"); } else if (Force16Colors) { @@ -360,7 +372,14 @@ protected virtual void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr } else { - output.AppendAscii ($"{EscSeqUtils.CSI}48;2;{attr.Background.R};{attr.Background.G};{attr.Background.B}m"); + output.AppendAscii (EscSeqUtils.CSI); + output.AppendAscii ("48;2;"); + output.AppendInt (attr.Background.R); + output.AppendByte ((byte)';'); + output.AppendInt (attr.Background.G); + output.AppendByte ((byte)';'); + output.AppendInt (attr.Background.B); + output.AppendByte ((byte)'m'); } string styleChange = EscSeqUtils.CSI_BuildTextStyleChange (redrawTextStyle, attr.Style); @@ -388,11 +407,11 @@ protected virtual void Write (StringBuilder output) { if (_clearLastOutputPending) { - _lastoutputBuffer.Clear (); + _lastOutputStringBuilder.Clear (); _clearLastOutputPending = false; } - _lastoutputBuffer.Append (output); + _lastOutputStringBuilder.Append (output); } /// @@ -404,12 +423,21 @@ protected virtual void Write (ReadOnlySpan output) { if (_clearLastOutputPending) { - _lastoutputBuffer.Clear (); + _lastOutputStringBuilder.Clear (); _clearLastOutputPending = false; } - // Decode back to UTF-16 for GetLastOutput() (test/debug only, not hot path) - _lastoutputBuffer.Append (Encoding.UTF8.GetString (output)); + // Decode UTF-8 to UTF-16 for GetLastOutput() using a reusable buffer. + // Allocates only when the buffer must grow; stable-state zero-allocation. + int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + + if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) + { + _utf16DecodeBuffer = new char [maxCharCount]; + } + + int charCount = Encoding.UTF8.GetChars (output, _utf16DecodeBuffer); + _lastOutputStringBuilder.Append (_utf16DecodeBuffer, 0, charCount); } /// diff --git a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs index f4b97d6e83..474e871706 100644 --- a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs +++ b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs @@ -72,11 +72,10 @@ public void Append (string text) if (allAscii) { AppendAscii (text); + return; } - else - { - Append (text.AsSpan ()); - } + + Append (text.AsSpan ()); } /// @@ -104,6 +103,44 @@ public void AppendByte (byte b) _buffer [_length++] = b; } + /// + /// Appends an integer as ASCII digits directly (no string allocation). + /// + public void AppendInt (int value) + { + if (value == 0) + { + AppendByte ((byte)'0'); + return; + } + + if (value < 0) + { + AppendByte ((byte)'-'); + value = -value; + } + + int temp = value; + int digitCount = 0; + + while (temp > 0) + { + digitCount++; + temp /= 10; + } + + EnsureCapacity (digitCount); + int start = _length + digitCount; + + while (value > 0) + { + _buffer [--start] = (byte)('0' + value % 10); + value /= 10; + } + + _length += digitCount; + } + /// /// Appends the contents of another . /// diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 0d5bc69900..0ca9503190 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -305,7 +305,7 @@ public static bool TryWriteStdout (byte [] buffer) return false; } - return TryWriteAll (fd, buffer, write); + return TryWriteAll (fd, buffer, buffer.Length, write); } catch { @@ -313,10 +313,36 @@ public static bool TryWriteStdout (byte [] buffer) } } - internal static bool TryWriteAll (int fd, byte [] buffer, Func writeFunc) + /// + /// Writes bytes from to stdout. + /// Avoids allocating a new array when the caller already has a reusable buffer. + /// + /// Buffer containing data to write. + /// Number of bytes to write (starting from index 0). + /// True if write was successful, false otherwise. + public static bool TryWriteStdout (byte [] buffer, int count) + { + try + { + int fd = TerminalDevice.OutputFd; + + if (fd < 0) + { + return false; + } + + return TryWriteAll (fd, buffer, count, write); + } + catch + { + return false; + } + } + + internal static bool TryWriteAll (int fd, byte [] buffer, int count, Func writeFunc) { int offset = 0; - int remaining = buffer.Length; + int remaining = count; while (remaining > 0) { diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs index 4df09bc857..6916a3242a 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -90,6 +90,9 @@ private enum DesiredAccess : uint private readonly ConsoleColor _foreground; private readonly ConsoleColor _background; + // Reusable buffer for UTF-8 → UTF-16 decode in Write(ReadOnlySpan). + private char []? _utf16DecodeBuffer; + public WindowsOutput () { //Logging.Information ($"Creating {nameof (WindowsOutput)}"); @@ -463,6 +466,39 @@ protected override void Write (StringBuilder output) } } + /// + protected override void Write (ReadOnlySpan output) + { + base.Write (output); + + if (!IsAttachedToTerminal || output.IsEmpty) + { + return; + } + + if (!OperatingSystem.IsWindows ()) + { + return; + } + + // Decode UTF-8 to UTF-16 using a reusable buffer, then write via WriteConsole. + int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + + if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) + { + _utf16DecodeBuffer = new char [maxCharCount]; + } + + int charCount = Encoding.UTF8.GetChars (output, _utf16DecodeBuffer); + ReadOnlySpan charSpan = _utf16DecodeBuffer.AsSpan (0, charCount); + nint handle = !IsLegacyConsole ? _outputHandle : _screenBuffer; + + if (!WriteConsole (handle, charSpan, (uint)charCount, out _, nint.Zero)) + { + // Don't throw in unit tests + } + } + /// protected override void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) { diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs new file mode 100644 index 0000000000..409f662627 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs @@ -0,0 +1,314 @@ +using System.Text; + +namespace DriverTests.Output; + +/// +/// Focused tests for encoding, reuse, and capacity behavior. +/// +[Collection ("Driver Tests")] +public class Utf8BufferTests +{ + [Fact] + public void AppendAscii_EmptyString_DoesNothing () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii (string.Empty); + + Assert.Equal (0, buffer.Length); + } + + [Fact] + public void AppendAscii_PureAscii_StoresRawBytes () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii ("Hello"); + + Assert.Equal (5, buffer.Length); + Assert.Equal ("Hello", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AppendAscii_CsiEscapeSequence_StoresExactBytes () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii ("\x1b[38;2;1;2;3m"); + + ReadOnlySpan span = buffer.AsSpan (); + Assert.Equal ((byte)0x1b, span [0]); + Assert.Equal ((byte)'[', span [1]); + Assert.Equal ("38;2;1;2;3m", Encoding.UTF8.GetString (span [2..])); + } + + [Fact] + public void Append_AsciiString_UsesFastPath () + { + Utf8Buffer buffer = new (); + buffer.Append ("ABC"); + + Assert.Equal (3, buffer.Length); + Assert.Equal ("ABC", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void Append_MultibyteUtf8_EncodesCorrectly () + { + Utf8Buffer buffer = new (); + // "héllo" — 'é' is U+00E9, 2 bytes in UTF-8 (0xC3 0xA9) + buffer.Append ("héllo"); + + Assert.Equal (6, buffer.Length); + Assert.Equal ("héllo", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void Append_CjkCharacters_EncodesMultibyte () + { + Utf8Buffer buffer = new (); + // Chinese "你好" — each char is 3 bytes in UTF-8 + buffer.Append ("你好"); + + Assert.Equal (6, buffer.Length); + Assert.Equal ("你好", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void Append_SurrogatePair_Emoji_EncodesAs4Bytes () + { + Utf8Buffer buffer = new (); + // U+1F600 (😀) is a surrogate pair in UTF-16, 4 bytes in UTF-8 + buffer.Append ("😀"); + + Assert.Equal (4, buffer.Length); + Assert.Equal ("😀", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void Append_MixedAsciiAndUnicode_EncodesCorrectly () + { + Utf8Buffer buffer = new (); + buffer.Append ("A你B😀C"); + + Assert.Equal ("A你B😀C", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void Append_EmptyString_DoesNothing () + { + Utf8Buffer buffer = new (); + buffer.Append (string.Empty); + + Assert.Equal (0, buffer.Length); + } + + [Fact] + public void Append_CharSpan_Empty_DoesNothing () + { + Utf8Buffer buffer = new (); + buffer.Append (ReadOnlySpan.Empty); + + Assert.Equal (0, buffer.Length); + } + + [Fact] + public void AppendByte_SingleByte_StoresAtCorrectPosition () + { + Utf8Buffer buffer = new (); + buffer.AppendByte (0x41); + buffer.AppendByte (0x42); + + Assert.Equal (2, buffer.Length); + Assert.Equal ("AB", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AppendInt_Zero_AppendsDigitZero () + { + Utf8Buffer buffer = new (); + buffer.AppendInt (0); + + Assert.Equal ("0", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AppendInt_Positive_AppendsDigits () + { + Utf8Buffer buffer = new (); + buffer.AppendInt (255); + + Assert.Equal ("255", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AppendInt_Negative_AppendsMinusAndDigits () + { + Utf8Buffer buffer = new (); + buffer.AppendInt (-42); + + Assert.Equal ("-42", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Theory] + [InlineData (1)] + [InlineData (9)] + [InlineData (10)] + [InlineData (99)] + [InlineData (100)] + [InlineData (999)] + [InlineData (1000)] + [InlineData (int.MaxValue)] + public void AppendInt_VariousValues_MatchesToString (int value) + { + Utf8Buffer buffer = new (); + buffer.AppendInt (value); + + Assert.Equal (value.ToString (), Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AppendBytes_RawBytes_StoresExactContent () + { + Utf8Buffer buffer = new (); + byte [] data = [0x1b, (byte)'[', (byte)'m']; + + buffer.AppendBytes (data); + + Assert.Equal (3, buffer.Length); + Assert.Equal ("\x1b[m", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AppendBytes_EmptySpan_DoesNothing () + { + Utf8Buffer buffer = new (); + buffer.AppendBytes (ReadOnlySpan.Empty); + + Assert.Equal (0, buffer.Length); + } + + [Fact] + public void Append_OtherBuffer_CopiesContent () + { + Utf8Buffer source = new (); + source.AppendAscii ("Hello"); + source.Append ("世界"); + + Utf8Buffer dest = new (); + dest.Append (source); + + Assert.Equal (source.Length, dest.Length); + Assert.Equal (Encoding.UTF8.GetString (source.AsSpan ()), Encoding.UTF8.GetString (dest.AsSpan ())); + } + + [Fact] + public void Append_OtherEmptyBuffer_DoesNothing () + { + Utf8Buffer source = new (); + Utf8Buffer dest = new (); + dest.Append (source); + + Assert.Equal (0, dest.Length); + } + + [Fact] + public void Clear_ResetsLength_KeepsCapacity () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii ("Hello"); + + buffer.Clear (); + + Assert.Equal (0, buffer.Length); + Assert.True (buffer.AsSpan ().IsEmpty); + } + + [Fact] + public void Clear_ThenAppend_WorksCorrectly () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii ("First"); + buffer.Clear (); + buffer.AppendAscii ("Second"); + + Assert.Equal (6, buffer.Length); + Assert.Equal ("Second", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void AsSpan_ReturnsWrittenBytesOnly () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii ("AB"); + + ReadOnlySpan span = buffer.AsSpan (); + + Assert.Equal (2, span.Length); + Assert.Equal ((byte)'A', span [0]); + Assert.Equal ((byte)'B', span [1]); + } + + [Fact] + public void Capacity_GrowsAutomatically_WhenExceeded () + { + Utf8Buffer buffer = new (); + + // Append a large string that exceeds the initial 256-byte capacity. + string large = new ('x', 1000); + buffer.Append (large); + + Assert.Equal (1000, buffer.Length); + Assert.Equal (large, Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void Capacity_GrowsForMultibyteContent () + { + Utf8Buffer buffer = new (); + + // Append 500 CJK chars (3 bytes each = 1500 bytes, exceeds initial 256). + string large = new ('你', 500); + buffer.Append (large); + + Assert.Equal (1500, buffer.Length); + Assert.Equal (large, Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void MultipleAppends_AccumulateCorrectly () + { + Utf8Buffer buffer = new (); + buffer.AppendAscii ("\x1b["); + buffer.AppendAscii ("38;2;"); + buffer.AppendInt (255); + buffer.AppendByte ((byte)';'); + buffer.AppendInt (128); + buffer.AppendByte ((byte)';'); + buffer.AppendInt (0); + buffer.AppendByte ((byte)'m'); + buffer.Append ("X"); + + Assert.Equal ("\x1b[38;2;255;128;0mX", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + + [Fact] + public void ReuseAfterClear_DoesNotCorruptData () + { + Utf8Buffer buffer = new (); + + for (int i = 0; i < 10; i++) + { + buffer.Clear (); + buffer.AppendAscii ($"Iter{i}"); + Assert.Equal ($"Iter{i}", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + } + + [Fact] + public void Append_CharSpan_Unicode_EncodesCorrectly () + { + Utf8Buffer buffer = new (); + buffer.Append ("héllo".AsSpan ()); + + Assert.Equal (6, buffer.Length); + Assert.Equal ("héllo", Encoding.UTF8.GetString (buffer.AsSpan ())); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs index 6aeda50505..4a808acd69 100644 --- a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs @@ -23,6 +23,7 @@ public void TryWriteAll_ReturnsFalse_WhenWriteReturnsZero () bool result = UnixIOHelper.TryWriteAll (1, payload, + payload.Length, (_, _, _) => { calls++; @@ -43,6 +44,7 @@ public void TryWriteAll_RetriesUntilAllBytesWritten () bool result = UnixIOHelper.TryWriteAll (1, payload, + payload.Length, (_, buffer, count) => { counts.Add (count); From 6b8516eafeb59df2c25caaab40f0c3b0c197f860 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 27 Aug 2026 08:27:10 +0800 Subject: [PATCH 05/42] fix: address Copilot review comments from PR #5651 (shared with #5650) review2: Route Write(StringBuilder) through byte-span path in batch mode to merge deferred cursor moves review3: try/finally guard _batchMode and _pendingCursorMoves in Write(IOutputBuffer) review4: Validate count range in TryWriteStdout(byte[], int) to prevent native buffer overread review7: Guard EnsureCapacity against int overflow with long arithmetic and Array.MaxLength cap --- Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 28 +++++++++++++++---- Terminal.Gui/Drivers/Output/Utf8Buffer.cs | 20 +++++++++++-- .../Drivers/UnixHelpers/UnixIOHelper.cs | 6 ++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 6e1a7da273..16acfbb76f 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -212,6 +212,15 @@ public Size GetSize () /// protected override void Write (StringBuilder output) { + // In batch mode, route through the byte-span path to merge with deferred cursor moves. + if (_batchMode && _pendingCursorMoves.Length > 0) + { + byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ()); + Write (utf8); + + return; + } + base.Write (output); try @@ -278,14 +287,21 @@ public override void Write (IOutputBuffer buffer) { _lastBuffer = buffer; _batchMode = true; - base.Write (buffer); - _batchMode = false; - // Flush any remaining deferred cursor moves - if (_pendingCursorMoves.Length > 0) + try { - Write (_pendingCursorMoves.AsSpan ()); - _pendingCursorMoves.Clear (); + base.Write (buffer); + } + finally + { + _batchMode = false; + + // Flush any remaining deferred cursor moves + if (_pendingCursorMoves.Length > 0) + { + Write (_pendingCursorMoves.AsSpan ()); + _pendingCursorMoves.Clear (); + } } } diff --git a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs index 474e871706..677110a5af 100644 --- a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs +++ b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs @@ -178,13 +178,27 @@ private void EnsureCapacity (int additional) return; } - int newSize = _buffer.Length; + // Guard against int overflow: if required size exceeds Array.MaxLength, throw instead of hanging. + long required = (long)_length + additional; - while (newSize < _length + additional) + if (required > Array.MaxLength) + { + throw new OutOfMemoryException ($"Utf8Buffer capacity {required} exceeds Array.MaxLength."); + } + + // Perform doubling in long to avoid int overflow, then cast back. + long newSize = _buffer.Length; + + while (newSize < required) { newSize *= 2; } - Array.Resize (ref _buffer, newSize); + if (newSize > Array.MaxLength) + { + newSize = Array.MaxLength; + } + + Array.Resize (ref _buffer, (int)newSize); } } diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 0ca9503190..27b26bc9f8 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -331,6 +331,12 @@ public static bool TryWriteStdout (byte [] buffer, int count) return false; } + // Guard against out-of-range count to prevent native read beyond buffer bounds. + if (count < 0 || count > buffer.Length) + { + return false; + } + return TryWriteAll (fd, buffer, count, write); } catch From 09da5e77f0ed88a4024090e79992ffd67850ee5d Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Mon, 31 Aug 2026 17:56:23 -0500 Subject: [PATCH 06/42] Fix timeout callback queue locking --- Terminal.Gui/App/Timeout/TimedEvents.cs | 190 +++- .../Application/Timeouts/TimedEventsTests.cs | 825 ++++++++++++++++++ 2 files changed, 977 insertions(+), 38 deletions(-) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 9443c4ba59..45eb8af169 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -40,8 +40,12 @@ namespace Terminal.Gui.App; public class TimedEvents : ITimedEvents { internal SortedList _timeouts = new (); + private readonly Dictionary _activeTimeouts = new (ReferenceEqualityComparer.Instance); + private readonly Dictionary _cancelledTimeouts = new (ReferenceEqualityComparer.Instance); + private readonly object _runTimersLockToken = new (); private readonly object _timeoutsLockToken = new (); private readonly ITimeProvider? _timeProvider; + private int _runTimersPending; /// /// Initializes a new instance of with the default system time provider. @@ -102,11 +106,43 @@ private long GetTimestampTicks () /// public void RunTimers () { - lock (_timeoutsLockToken) + Interlocked.Exchange (ref _runTimersPending, 1); + System.Runtime.ExceptionServices.ExceptionDispatchInfo? error = null; + + while (true) { - if (_timeouts.Count > 0) + // A monitor is reentrant, so nested runs on this thread remain supported. A competing thread returns while + // the active runner drains the due timeouts. + if (!Monitor.TryEnter (_runTimersLockToken)) + { + error?.Throw (); + + return; + } + + try + { + Interlocked.Exchange (ref _runTimersPending, 0); + + try + { + RunTimersImpl (); + } + catch (Exception ex) + { + error ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture (ex); + } + } + finally { - RunTimersImpl (); + Monitor.Exit (_runTimersLockToken); + } + + if (Interlocked.Exchange (ref _runTimersPending, 0) == 0) + { + error?.Throw (); + + return; } } } @@ -114,19 +150,40 @@ public void RunTimers () /// public bool Remove (object token) { + Timeout? timeout = token as Timeout; + + if (timeout is null) + { + return false; + } + lock (_timeoutsLockToken) { - int idx = _timeouts.IndexOfValue ((token as Timeout)!); + int idx = _timeouts.IndexOfValue (timeout); - if (idx == -1) + if (idx >= 0) + { + _timeouts.RemoveAt (idx); + + return true; + } + + if (!_activeTimeouts.TryGetValue (timeout, out int activeCount)) { return false; } - _timeouts.RemoveAt (idx); - } + _cancelledTimeouts.TryGetValue (timeout, out int cancelledCount); - return true; + if (cancelledCount >= activeCount) + { + return false; + } + + _cancelledTimeouts [timeout] = cancelledCount + 1; + + return true; + } } /// @@ -198,21 +255,31 @@ public bool CheckTimers (out int waitTimeout) private void AddTimeout (TimeSpan time, Timeout timeout) { + long k; + lock (_timeoutsLockToken) { - long k = GetTimestampTicks () + time.Ticks; + k = AddTimeoutCore (time, timeout); + } - // if user wants to run as soon as possible set timer such that it expires right away (no race conditions) - if (time == TimeSpan.Zero) - { - // Use a more substantial buffer (1ms) to ensure it's truly in the past - // even under debugger overhead and extreme timing variations - k -= TimeSpan.TicksPerMillisecond; - } + Added?.Invoke (this, new (timeout, k)); + } + + private long AddTimeoutCore (TimeSpan time, Timeout timeout) + { + long k = GetTimestampTicks () + time.Ticks; - _timeouts.Add (NudgeToUniqueKey (k), timeout); - Added?.Invoke (this, new (timeout, k)); + // if user wants to run as soon as possible set timer such that it expires right away (no race conditions) + if (time == TimeSpan.Zero) + { + // Use a more substantial buffer (1ms) to ensure it's truly in the past + // even under debugger overhead and extreme timing variations + k -= TimeSpan.TicksPerMillisecond; } + + _timeouts.Add (NudgeToUniqueKey (k), timeout); + + return k; } /// @@ -223,12 +290,9 @@ private void AddTimeout (TimeSpan time, Timeout timeout) /// private long NudgeToUniqueKey (long k) { - lock (_timeoutsLockToken) + while (_timeouts.ContainsKey (k)) { - while (_timeouts.ContainsKey (k)) - { - k++; - } + k++; } return k; @@ -236,51 +300,96 @@ private long NudgeToUniqueKey (long k) private void RunTimersImpl () { - long now = GetTimestampTicks (); - // Process due timeouts one at a time, without blocking the entire queue while (true) { - Timeout? timeoutToExecute = null; - long scheduledTime = 0; + Timeout timeoutToExecute; // Find the next due timeout lock (_timeoutsLockToken) { if (_timeouts.Count == 0) { - break; // No more timeouts + return; } // Re-evaluate current time for each iteration - now = GetTimestampTicks (); + long now = GetTimestampTicks (); // Check if the earliest timeout is due - scheduledTime = _timeouts.Keys [0]; + long scheduledTime = _timeouts.Keys [0]; if (scheduledTime > now) { - // Earliest timeout is not yet due, we're done - break; + return; } // This timeout is due - remove it from the queue timeoutToExecute = _timeouts.Values [0]; _timeouts.RemoveAt (0); + _activeTimeouts.TryGetValue (timeoutToExecute, out int activeCount); + _activeTimeouts [timeoutToExecute] = activeCount + 1; } // Execute the callback outside the lock // This allows nested Run() calls to access the timeout queue - if (timeoutToExecute != null) + bool repeat = false; + + try { - bool repeat = timeoutToExecute.Callback! (); + repeat = timeoutToExecute.Callback! (); + } + finally + { + CompleteTimeout (timeoutToExecute, repeat); + } + } + } - if (repeat) - { - AddTimeout (timeoutToExecute.Span, timeoutToExecute); - } + private void CompleteTimeout (Timeout timeout, bool repeat) + { + long k; + + lock (_timeoutsLockToken) + { + int activeCount = _activeTimeouts [timeout]; + int remainingActiveCount = activeCount - 1; + + if (remainingActiveCount == 0) + { + _activeTimeouts.Remove (timeout); + } + else + { + _activeTimeouts [timeout] = remainingActiveCount; } + + _cancelledTimeouts.TryGetValue (timeout, out int cancelledCount); + bool cancelled = repeat && cancelledCount > 0; + + if (cancelled && cancelledCount == 1) + { + _cancelledTimeouts.Remove (timeout); + } + else if (cancelled && cancelledCount > 1) + { + _cancelledTimeouts [timeout] = cancelledCount - 1; + } + + if (remainingActiveCount == 0) + { + _cancelledTimeouts.Remove (timeout); + } + + if (!repeat || cancelled) + { + return; + } + + k = AddTimeoutCore (timeout.Span, timeout); } + + Added?.Invoke (this, new (timeout, k)); } /// @@ -289,6 +398,11 @@ public void StopAll () lock (_timeoutsLockToken) { _timeouts.Clear (); + + foreach (KeyValuePair activeTimeout in _activeTimeouts) + { + _cancelledTimeouts [activeTimeout.Key] = activeTimeout.Value; + } } } } diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 33142b0875..809bb2617c 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -7,6 +7,801 @@ namespace ApplicationTests.TimedEventTests; [Collection("Application Timer Tests")] public class TimedEventsTests { + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Callback_Does_Not_Block_Add_From_Other_Thread () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim addStarted = new (); + using ManualResetEventSlim addCompleted = new (); + Task? addTask = null; + var addStartedBeforeCallbackReturned = false; + var addCompletedBeforeCallbackReturned = false; + + timedEvents.Add ( + TimeSpan.Zero, + () => + { + addTask = RunOnDedicatedThread ( + () => + { + try + { + addStarted.Set (); + timedEvents.Add (TimeSpan.FromHours (1), () => false); + } + finally + { + addCompleted.Set (); + } + }); + addStartedBeforeCallbackReturned = addStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + addCompletedBeforeCallbackReturned = addCompleted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return false; + }); + + timedEvents.RunTimers (); + await addTask!; + + Assert.True (addStartedBeforeCallbackReturned, "The Add task should start before the callback returns."); + Assert.True (addCompletedBeforeCallbackReturned, "Add should complete before the callback returns."); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Callback_Does_Not_Block_Invoke_From_Other_Thread () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim invokeStarted = new (); + using ManualResetEventSlim invokeCompleted = new (); + Task? invokeTask = null; + Exception? invokeError = null; + var invokeStartedBeforeCallbackCompleted = false; + var invokeReturnedBeforeCallbackCompleted = false; + var invoked = false; + + app.AddTimeout ( + TimeSpan.Zero, + () => + { + invokeTask = RunOnDedicatedThread ( + () => + { + try + { + invokeStarted.Set (); + app.Invoke (() => invoked = true); + } + catch (Exception ex) + { + invokeError = ex; + } + finally + { + invokeCompleted.Set (); + } + }); + invokeStartedBeforeCallbackCompleted = invokeStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + invokeReturnedBeforeCallbackCompleted = invokeCompleted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return false; + }); + + try + { + app.TimedEvents!.RunTimers (); + + if (invokeCompleted.Wait (TimeSpan.FromSeconds (5), cancellationToken)) + { + app.TimedEvents.RunTimers (); + } + } + finally + { + app.Dispose (); + } + + await invokeTask!; + + Assert.NotNull (invokeTask); + Assert.Null (invokeError); + Assert.True (invokeStartedBeforeCallbackCompleted, "The Invoke task should start before the callback returns."); + Assert.True (invokeReturnedBeforeCallbackCompleted, "Invoke should enqueue before the callback returns."); + Assert.True (invoked); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Callback_Does_Not_Block_SynchronizationContext_Post () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + SynchronizationContext context = new MainLoopSyncContext (app); + using ManualResetEventSlim postStarted = new (); + using ManualResetEventSlim postCompleted = new (); + Task? postTask = null; + Exception? postError = null; + var postStartedBeforeCallbackCompleted = false; + var postReturnedBeforeCallbackCompleted = false; + var posted = false; + + app.AddTimeout ( + TimeSpan.Zero, + () => + { + postTask = RunOnDedicatedThread ( + () => + { + try + { + postStarted.Set (); + context.Post (_ => posted = true, null); + } + catch (Exception ex) + { + postError = ex; + } + finally + { + postCompleted.Set (); + } + }); + postStartedBeforeCallbackCompleted = postStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + postReturnedBeforeCallbackCompleted = postCompleted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return false; + }); + + try + { + app.TimedEvents!.RunTimers (); + + if (postCompleted.Wait (TimeSpan.FromSeconds (5), cancellationToken)) + { + app.TimedEvents.RunTimers (); + } + } + finally + { + app.Dispose (); + } + + await postTask!; + + Assert.NotNull (postTask); + Assert.Null (postError); + Assert.True (postStartedBeforeCallbackCompleted, "The Post task should start before the callback returns."); + Assert.True (postReturnedBeforeCallbackCompleted, "Post should enqueue before the callback returns."); + Assert.True (posted); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Callback_Does_Not_Block_SynchronizationContext_Send_Enqueue () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + SynchronizationContext context = new MainLoopSyncContext (app); + using ManualResetEventSlim sendStarted = new (); + using ManualResetEventSlim sendQueued = new (); + using ManualResetEventSlim sendReturned = new (); + Task? sendTask = null; + Exception? sendError = null; + var sendStartedBeforeCallbackCompleted = false; + var sendQueuedBeforeCallbackCompleted = false; + var sendQueuedAfterCallbackCompleted = false; + var sendReturnedAfterCallback = false; + var sent = false; + EventHandler handler = (_, _) => sendQueued.Set (); + + app.AddTimeout ( + TimeSpan.Zero, + () => + { + sendTask = RunOnDedicatedThread ( + () => + { + try + { + sendStarted.Set (); + context.Send (_ => sent = true, null); + } + catch (Exception ex) + { + sendError = ex; + } + finally + { + sendReturned.Set (); + } + }); + sendStartedBeforeCallbackCompleted = sendStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + sendQueuedBeforeCallbackCompleted = sendQueued.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return false; + }); + + app.TimedEvents!.Added += handler; + + try + { + app.TimedEvents.RunTimers (); + sendQueuedAfterCallbackCompleted = sendQueued.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (sendQueuedAfterCallbackCompleted && !sendReturned.IsSet) + { + app.TimedEvents.RunTimers (); + } + + sendReturnedAfterCallback = sendReturned.Wait (TimeSpan.FromSeconds (5), cancellationToken); + } + finally + { + if (sendTask is not null && !sendReturned.IsSet) + { + sendQueuedAfterCallbackCompleted = WaitForCleanup (sendQueued); + + if (sendQueuedAfterCallbackCompleted) + { + app.TimedEvents.RunTimers (); + sendReturnedAfterCallback = WaitForCleanup (sendReturned); + } + } + + app.TimedEvents.Added -= handler; + app.Dispose (); + + if (sendTask is not null) + { + await sendTask; + } + } + + Assert.NotNull (sendTask); + Assert.Null (sendError); + Assert.True (sendStartedBeforeCallbackCompleted, "The Send task should start before the callback returns."); + Assert.True (sendQueuedBeforeCallbackCompleted, "Send should enqueue before the callback returns."); + Assert.True (sendQueuedAfterCallbackCompleted, "Send should enqueue before cleanup times out."); + Assert.True (sendReturnedAfterCallback, "Send should return after its callback runs on the timer runner."); + Assert.True (sent); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Callback_Does_Not_Block_Remove_From_Other_Thread () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + object timeoutToRemove = timedEvents.Add (TimeSpan.FromHours (1), () => false); + using ManualResetEventSlim removeStarted = new (); + using ManualResetEventSlim removeCompleted = new (); + Task? removeTask = null; + var removeStartedBeforeCallbackReturned = false; + var removeCompletedBeforeCallbackReturned = false; + + timedEvents.Add ( + TimeSpan.Zero, + () => + { + removeTask = RunOnDedicatedThread ( + () => + { + try + { + removeStarted.Set (); + + return timedEvents.Remove (timeoutToRemove); + } + finally + { + removeCompleted.Set (); + } + }); + removeStartedBeforeCallbackReturned = removeStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + removeCompletedBeforeCallbackReturned = removeCompleted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return false; + }); + + timedEvents.RunTimers (); + bool removed = await removeTask!; + + Assert.True (removeStartedBeforeCallbackReturned, "The Remove task should start before the callback returns."); + Assert.True (removeCompletedBeforeCallbackReturned, "Remove should complete before the callback returns."); + Assert.True (removed); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Concurrent_Callers_Do_Not_Execute_Callbacks_Concurrently () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim firstCallbackStarted = new (); + using ManualResetEventSlim releaseFirstCallback = new (); + using ManualResetEventSlim concurrentCallbackStarted = new (); + var activeCallbacks = 0; + var callbackCount = 0; + var firstCallbackReleased = false; + + Func callback = () => + { + if (Interlocked.Increment (ref activeCallbacks) > 1) + { + concurrentCallbackStarted.Set (); + } + + int currentCallback = Interlocked.Increment (ref callbackCount); + + if (currentCallback == 1) + { + firstCallbackStarted.Set (); + firstCallbackReleased = releaseFirstCallback.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + } + + Interlocked.Decrement (ref activeCallbacks); + + return false; + }; + + timedEvents.Add (TimeSpan.Zero, callback); + timedEvents.Add (TimeSpan.Zero, callback); + + Task firstRunner = RunOnDedicatedThread (timedEvents.RunTimers); + Task? secondRunner = null; + var firstCallbackStartedBeforeTimeout = false; + var secondRunnerReturned = false; + var callbacksRanConcurrently = false; + + try + { + firstCallbackStartedBeforeTimeout = firstCallbackStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + if (firstCallbackStartedBeforeTimeout) + { + secondRunner = RunOnDedicatedThread (timedEvents.RunTimers); + secondRunnerReturned = await CompletesWithinAsync ( + secondRunner, + cancellationToken); + callbacksRanConcurrently = concurrentCallbackStarted.IsSet; + } + } + finally + { + releaseFirstCallback.Set (); + } + + await firstRunner; + + if (secondRunner is not null) + { + await secondRunner; + } + + Assert.True (firstCallbackStartedBeforeTimeout, "The first callback should start."); + Assert.True (firstCallbackReleased, "The first callback should be released before its wait times out."); + Assert.True (secondRunnerReturned, "A competing RunTimers caller should return while another runner is active."); + Assert.False (callbacksRanConcurrently); + Assert.Equal (2, callbackCount); + } + + // CoPilot - GPT-5 + [Fact] + public async Task Added_Handler_Does_Not_Block_Add_From_Other_Thread () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim addStarted = new (); + using ManualResetEventSlim addCompleted = new (); + Task? addTask = null; + var addStartedBeforeHandlerReturned = false; + var addCompletedBeforeHandlerReturned = false; + EventHandler? handler = null; + + handler = (_, _) => + { + timedEvents.Added -= handler; + addTask = RunOnDedicatedThread ( + () => + { + try + { + addStarted.Set (); + timedEvents.Add (TimeSpan.FromHours (1), () => false); + } + finally + { + addCompleted.Set (); + } + }); + addStartedBeforeHandlerReturned = addStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + addCompletedBeforeHandlerReturned = addCompleted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + }; + timedEvents.Added += handler; + + timedEvents.Add (TimeSpan.FromHours (1), () => false); + await addTask!; + + Assert.True (addStartedBeforeHandlerReturned, "The Add task should start before the Added handler returns."); + Assert.True (addCompletedBeforeHandlerReturned, "Add should complete before the Added handler returns."); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Remove_Active_Repeating_Timeout_Prevents_Reschedule () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim callbackStarted = new (); + using ManualResetEventSlim releaseCallback = new (); + var callbackReleased = false; + object timeout = timedEvents.Add ( + TimeSpan.Zero, + () => + { + callbackStarted.Set (); + callbackReleased = releaseCallback.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return true; + }); + Task runnerTask = RunOnDedicatedThread (timedEvents.RunTimers); + Task? removeTask = null; + var callbackStartedBeforeTimeout = false; + var removeReturnedBeforeCallback = false; + + try + { + callbackStartedBeforeTimeout = callbackStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + if (callbackStartedBeforeTimeout) + { + removeTask = RunOnDedicatedThread (() => timedEvents.Remove (timeout)); + removeReturnedBeforeCallback = await CompletesWithinAsync ( + removeTask, + cancellationToken); + } + } + finally + { + releaseCallback.Set (); + } + + await runnerTask; + bool removed = removeTask is not null && await removeTask; + + Assert.True (callbackStartedBeforeTimeout, "The repeating callback should start."); + Assert.True (callbackReleased, "The repeating callback should be released before its wait times out."); + Assert.True (removeReturnedBeforeCallback, "Remove should return while the callback is active."); + Assert.True (removed); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_StopAll_During_Active_Repeating_Timeout_Prevents_Reschedule () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim callbackStarted = new (); + using ManualResetEventSlim releaseCallback = new (); + var callbackReleased = false; + + timedEvents.Add ( + TimeSpan.Zero, + () => + { + callbackStarted.Set (); + callbackReleased = releaseCallback.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + return true; + }); + + Task runnerTask = RunOnDedicatedThread (timedEvents.RunTimers); + Task? stopAllTask = null; + var callbackStartedBeforeTimeout = false; + var stopAllReturnedBeforeCallback = false; + + try + { + callbackStartedBeforeTimeout = callbackStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + if (callbackStartedBeforeTimeout) + { + stopAllTask = RunOnDedicatedThread (timedEvents.StopAll); + stopAllReturnedBeforeCallback = await CompletesWithinAsync ( + stopAllTask, + cancellationToken); + } + } + finally + { + releaseCallback.Set (); + } + + await runnerTask; + + if (stopAllTask is not null) + { + await stopAllTask; + } + + Assert.True (callbackStartedBeforeTimeout, "The repeating callback should start."); + Assert.True (callbackReleased, "The repeating callback should be released before its wait times out."); + Assert.True (stopAllReturnedBeforeCallback, "StopAll should return while the callback is active."); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Competing_Caller_Requests_Follow_Up_Run () + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + long currentTicks = DateTime.UnixEpoch.Ticks; + var timeReadCount = 0; + var callbackCount = 0; + using ManualResetEventSlim dueCheckStarted = new (); + using ManualResetEventSlim releaseDueCheck = new (); + var dueCheckReleased = false; + + DateTime GetCurrentTime () + { + long capturedTicks = Interlocked.Read (ref currentTicks); + + if (Interlocked.Increment (ref timeReadCount) == 2) + { + dueCheckStarted.Set (); + dueCheckReleased = releaseDueCheck.Wait (TimeSpan.FromSeconds (5), cancellationToken); + } + + return new (capturedTicks, DateTimeKind.Utc); + } + + TimedEvents timedEvents = new (new FuncTimeProvider (GetCurrentTime)); + timedEvents.Add ( + TimeSpan.FromSeconds (1), + () => + { + Interlocked.Increment (ref callbackCount); + + return false; + }); + + Task firstRunner = RunOnDedicatedThread (timedEvents.RunTimers); + Task? secondRunner = null; + var dueCheckStartedBeforeTimeout = false; + var secondRunnerReturned = false; + + try + { + dueCheckStartedBeforeTimeout = dueCheckStarted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + if (dueCheckStartedBeforeTimeout) + { + Interlocked.Exchange (ref currentTicks, DateTime.UnixEpoch.AddSeconds (2).Ticks); + secondRunner = RunOnDedicatedThread (timedEvents.RunTimers); + secondRunnerReturned = await CompletesWithinAsync ( + secondRunner, + cancellationToken); + } + } + finally + { + releaseDueCheck.Set (); + } + + await firstRunner; + + if (secondRunner is not null) + { + await secondRunner; + } + + Assert.True (dueCheckStartedBeforeTimeout, "The first runner should reach the due-time check."); + Assert.True (dueCheckReleased, "The due-time check should be released before its wait times out."); + Assert.True (secondRunnerReturned, "The competing RunTimers call should return while the runner is active."); + Assert.Equal (1, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Competing_Caller_Requests_Follow_Up_Run_When_Callback_Throws () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim callbackStarted = new (); + using ManualResetEventSlim releaseCallback = new (); + InvalidOperationException expectedException = new ("Expected callback failure."); + var followUpCallbackCount = 0; + var callbackReleased = false; + + timedEvents.Add ( + TimeSpan.Zero, + () => + { + callbackStarted.Set (); + callbackReleased = releaseCallback.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + throw expectedException; + }); + timedEvents.Add ( + TimeSpan.Zero, + () => + { + Interlocked.Increment (ref followUpCallbackCount); + + return false; + }); + + Task firstRunner = RunOnDedicatedThread (timedEvents.RunTimers); + Task? secondRunner = null; + var callbackStartedBeforeTimeout = false; + var secondRunnerReturned = false; + + try + { + callbackStartedBeforeTimeout = callbackStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (callbackStartedBeforeTimeout) + { + secondRunner = RunOnDedicatedThread (timedEvents.RunTimers); + secondRunnerReturned = await CompletesWithinAsync (secondRunner, cancellationToken); + } + } + finally + { + releaseCallback.Set (); + } + + InvalidOperationException actualException = await Assert.ThrowsAsync ( + async () => await firstRunner); + + if (secondRunner is not null) + { + await secondRunner; + } + + Assert.True (callbackStartedBeforeTimeout, "The throwing callback should start."); + Assert.True (callbackReleased, "The throwing callback should be released before its wait times out."); + Assert.True (secondRunnerReturned, "The competing RunTimers call should return while the runner is active."); + Assert.Same (expectedException, actualException); + Assert.Equal (1, followUpCallbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void RunTimers_StopAll_Cancels_Duplicate_Active_Timeout_Occurrences () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + var callbackCount = 0; + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.FromSeconds (1) }; + + timeout.Callback = () => + { + callbackCount++; + + if (callbackCount == 1) + { + timedEvents.RunTimers (); + + return true; + } + + timedEvents.StopAll (); + + return true; + }; + + timedEvents.Add (timeout); + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + + timedEvents.RunTimers (); + + Assert.Equal (2, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Remove_Cancels_Repeating_Duplicate_Active_Timeout_Occurrence () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim removeCompleted = new (); + var callbackCount = 0; + var removeCompletedBeforeCallbackReturned = false; + Task? removeTask = null; + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.FromSeconds (1) }; + + timeout.Callback = () => + { + callbackCount++; + + if (callbackCount == 1) + { + timedEvents.RunTimers (); + + return true; + } + + removeTask = RunOnDedicatedThread ( + () => + { + try + { + return timedEvents.Remove (timeout); + } + finally + { + removeCompleted.Set (); + } + }); + removeCompletedBeforeCallbackReturned = removeCompleted.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + + return false; + }; + + timedEvents.Add (timeout); + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + + timedEvents.RunTimers (); + bool removed = await removeTask!; + + Assert.Equal (2, callbackCount); + Assert.True (removeCompletedBeforeCallbackReturned, "Remove should return while both occurrences are active."); + Assert.True (removed); + Assert.Empty (timedEvents.Timeouts); + } + [Fact] public void HighFrequency_Concurrent_Invocations_No_Lost_Timeouts () { @@ -166,4 +961,34 @@ public void StopAll_Stops_All_Timeouts () Assert.Equal (0, executeCount); } + + private static async Task CompletesWithinAsync (Task task, CancellationToken cancellationToken) + { + try + { + await task.WaitAsync (TimeSpan.FromSeconds (5), cancellationToken).ConfigureAwait (false); + + return true; + } + catch (TimeoutException) + { + return false; + } + } + + private static Task RunOnDedicatedThread (Action action) => + Task.Factory.StartNew ( + action, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + private static Task RunOnDedicatedThread (Func action) => + Task.Factory.StartNew ( + action, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + private static bool WaitForCleanup (ManualResetEventSlim waitHandle) => waitHandle.Wait (TimeSpan.FromSeconds (5)); } From 16c971a6a5b7367a993769a7bd4ec98b102cdcce Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Mon, 31 Aug 2026 19:04:06 -0500 Subject: [PATCH 07/42] Track active timeout occurrences individually --- Terminal.Gui/App/Timeout/TimedEvents.cs | 87 ++++++++----------- .../Application/Timeouts/TimedEventsTests.cs | 45 ++++++++++ 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 45eb8af169..8c5c4459df 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -40,8 +40,7 @@ namespace Terminal.Gui.App; public class TimedEvents : ITimedEvents { internal SortedList _timeouts = new (); - private readonly Dictionary _activeTimeouts = new (ReferenceEqualityComparer.Instance); - private readonly Dictionary _cancelledTimeouts = new (ReferenceEqualityComparer.Instance); + private readonly List _activeTimeoutOccurrences = []; private readonly object _runTimersLockToken = new (); private readonly object _timeoutsLockToken = new (); private readonly ITimeProvider? _timeProvider; @@ -168,21 +167,19 @@ public bool Remove (object token) return true; } - if (!_activeTimeouts.TryGetValue (timeout, out int activeCount)) + foreach (ActiveTimeoutOccurrence occurrence in _activeTimeoutOccurrences) { - return false; - } + if (!ReferenceEquals (occurrence.Timeout, timeout) || occurrence.IsCancelled) + { + continue; + } - _cancelledTimeouts.TryGetValue (timeout, out int cancelledCount); + occurrence.IsCancelled = true; - if (cancelledCount >= activeCount) - { - return false; + return true; } - _cancelledTimeouts [timeout] = cancelledCount + 1; - - return true; + return false; } } @@ -303,7 +300,7 @@ private void RunTimersImpl () // Process due timeouts one at a time, without blocking the entire queue while (true) { - Timeout timeoutToExecute; + ActiveTimeoutOccurrence occurrence; // Find the next due timeout lock (_timeoutsLockToken) @@ -325,10 +322,10 @@ private void RunTimersImpl () } // This timeout is due - remove it from the queue - timeoutToExecute = _timeouts.Values [0]; + Timeout timeoutToExecute = _timeouts.Values [0]; _timeouts.RemoveAt (0); - _activeTimeouts.TryGetValue (timeoutToExecute, out int activeCount); - _activeTimeouts [timeoutToExecute] = activeCount + 1; + occurrence = new (timeoutToExecute); + _activeTimeoutOccurrences.Add (occurrence); } // Execute the callback outside the lock @@ -337,59 +334,33 @@ private void RunTimersImpl () try { - repeat = timeoutToExecute.Callback! (); + repeat = occurrence.Timeout.Callback! (); } finally { - CompleteTimeout (timeoutToExecute, repeat); + CompleteTimeout (occurrence, repeat); } } } - private void CompleteTimeout (Timeout timeout, bool repeat) + private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) { long k; lock (_timeoutsLockToken) { - int activeCount = _activeTimeouts [timeout]; - int remainingActiveCount = activeCount - 1; + bool removed = _activeTimeoutOccurrences.Remove (occurrence); + Debug.Assert (removed); - if (remainingActiveCount == 0) - { - _activeTimeouts.Remove (timeout); - } - else - { - _activeTimeouts [timeout] = remainingActiveCount; - } - - _cancelledTimeouts.TryGetValue (timeout, out int cancelledCount); - bool cancelled = repeat && cancelledCount > 0; - - if (cancelled && cancelledCount == 1) - { - _cancelledTimeouts.Remove (timeout); - } - else if (cancelled && cancelledCount > 1) - { - _cancelledTimeouts [timeout] = cancelledCount - 1; - } - - if (remainingActiveCount == 0) - { - _cancelledTimeouts.Remove (timeout); - } - - if (!repeat || cancelled) + if (!repeat || occurrence.IsCancelled) { return; } - k = AddTimeoutCore (timeout.Span, timeout); + k = AddTimeoutCore (occurrence.Timeout.Span, occurrence.Timeout); } - Added?.Invoke (this, new (timeout, k)); + Added?.Invoke (this, new (occurrence.Timeout, k)); } /// @@ -399,10 +370,22 @@ public void StopAll () { _timeouts.Clear (); - foreach (KeyValuePair activeTimeout in _activeTimeouts) + foreach (ActiveTimeoutOccurrence occurrence in _activeTimeoutOccurrences) { - _cancelledTimeouts [activeTimeout.Key] = activeTimeout.Value; + occurrence.IsCancelled = true; } } } + + private sealed class ActiveTimeoutOccurrence + { + public ActiveTimeoutOccurrence (Timeout timeout) + { + Timeout = timeout; + } + + public bool IsCancelled { get; set; } + + public Timeout Timeout { get; } + } } diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 809bb2617c..adf8ad6909 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -746,6 +746,51 @@ public void RunTimers_StopAll_Cancels_Duplicate_Active_Timeout_Occurrences () Assert.Empty (timedEvents.Timeouts); } + // CoPilot - GPT-5 + [Fact] + public void RunTimers_StopAll_Cancels_Active_Occurrence_Not_Later_Same_Timeout_Occurrence () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + var callbackCount = 0; + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.FromSeconds (1) }; + + timeout.Callback = () => + { + callbackCount++; + + if (callbackCount == 1) + { + timedEvents.StopAll (); + timeout.Span = TimeSpan.FromSeconds (1); + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + // Distinguish an incorrect reschedule of this pre-StopAll occurrence. + timeout.Span = TimeSpan.FromSeconds (10); + + return true; + } + + return callbackCount == 2; + }; + + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + + timedEvents.RunTimers (); + + Assert.Equal (2, callbackCount); + Assert.Single (timedEvents.Timeouts); + + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + Assert.Equal (3, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + // CoPilot - GPT-5 [Fact] public async Task RunTimers_Remove_Cancels_Repeating_Duplicate_Active_Timeout_Occurrence () From 2513f6d6074d45f290a398198a27c1172bf22293 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Mon, 31 Aug 2026 19:37:04 -0500 Subject: [PATCH 08/42] Aggregate pending timer callback exceptions --- Terminal.Gui/App/Timeout/TimedEvents.cs | 27 ++++++-- .../Application/Timeouts/TimedEventsTests.cs | 61 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 8c5c4459df..d5fc6bed04 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.ExceptionServices; namespace Terminal.Gui.App; @@ -106,7 +107,7 @@ private long GetTimestampTicks () public void RunTimers () { Interlocked.Exchange (ref _runTimersPending, 1); - System.Runtime.ExceptionServices.ExceptionDispatchInfo? error = null; + List? errors = null; while (true) { @@ -114,7 +115,7 @@ public void RunTimers () // the active runner drains the due timeouts. if (!Monitor.TryEnter (_runTimersLockToken)) { - error?.Throw (); + ThrowErrors (errors); return; } @@ -129,7 +130,8 @@ public void RunTimers () } catch (Exception ex) { - error ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture (ex); + errors ??= []; + errors.Add (ExceptionDispatchInfo.Capture (ex)); } } finally @@ -139,13 +141,30 @@ public void RunTimers () if (Interlocked.Exchange (ref _runTimersPending, 0) == 0) { - error?.Throw (); + ThrowErrors (errors); return; } } } + private static void ThrowErrors (List? errors) + { + if (errors is null) + { + return; + } + + if (errors.Count == 1) + { + errors [0].Throw (); + + return; + } + + throw new AggregateException (errors.ConvertAll (error => error.SourceException)); + } + /// public bool Remove (object token) { diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index adf8ad6909..52df88d8de 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -711,6 +711,67 @@ public async Task RunTimers_Competing_Caller_Requests_Follow_Up_Run_When_Callbac Assert.Empty (timedEvents.Timeouts); } + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Competing_Caller_Aggregates_Follow_Up_Callback_Exception () + { + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim callbackStarted = new (); + using ManualResetEventSlim releaseCallback = new (); + InvalidOperationException firstException = new ("Expected first callback failure."); + ArgumentException followUpException = new ("Expected follow-up callback failure."); + var callbackReleased = false; + + timedEvents.Add ( + TimeSpan.Zero, + () => + { + callbackStarted.Set (); + callbackReleased = releaseCallback.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + throw firstException; + }); + timedEvents.Add (TimeSpan.Zero, () => throw followUpException); + + Task firstRunner = RunOnDedicatedThread (timedEvents.RunTimers); + Task? secondRunner = null; + var callbackStartedBeforeTimeout = false; + var secondRunnerReturned = false; + + try + { + callbackStartedBeforeTimeout = callbackStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (callbackStartedBeforeTimeout) + { + secondRunner = RunOnDedicatedThread (timedEvents.RunTimers); + secondRunnerReturned = await CompletesWithinAsync (secondRunner, cancellationToken); + } + } + finally + { + releaseCallback.Set (); + } + + AggregateException actualException = await Assert.ThrowsAsync ( + async () => await firstRunner); + + if (secondRunner is not null) + { + await secondRunner; + } + + Assert.True (callbackStartedBeforeTimeout, "The first throwing callback should start."); + Assert.True (callbackReleased, "The first throwing callback should be released before its wait times out."); + Assert.True (secondRunnerReturned, "The competing RunTimers call should request the follow-up pass."); + Assert.Collection ( + actualException.InnerExceptions, + exception => Assert.Same (firstException, exception), + exception => Assert.Same (followUpException, exception)); + Assert.Empty (timedEvents.Timeouts); + } + // CoPilot - GPT-5 [Fact] public void RunTimers_StopAll_Cancels_Duplicate_Active_Timeout_Occurrences () From 4387f67afcc8e868b03cc82ade6ea2f5c7730542 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Mon, 31 Aug 2026 21:09:11 -0500 Subject: [PATCH 09/42] Simplify timer runner and cancellation semantics --- .../App/MainLoop/MainLoopSyncContext.cs | 4 + Terminal.Gui/App/Timeout/ITimedEvents.cs | 49 ++- Terminal.Gui/App/Timeout/TimedEvents.cs | 181 +++++----- .../Application/Timeouts/TimedEventsTests.cs | 324 ++++++++++++------ 4 files changed, 355 insertions(+), 203 deletions(-) diff --git a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs index 7fbe940851..3b28910a97 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs @@ -27,6 +27,10 @@ public override void Post (SendOrPostCallback d, object? state) } /// + /// + /// A call from outside the main-loop thread blocks until the main loop executes the callback. As with other + /// synchronous UI dispatch APIs, this can deadlock if the main-loop thread is waiting for the calling thread. + /// public override void Send (SendOrPostCallback d, object? state) { ArgumentNullException.ThrowIfNull (d); diff --git a/Terminal.Gui/App/Timeout/ITimedEvents.cs b/Terminal.Gui/App/Timeout/ITimedEvents.cs index 376f71f516..6deeec096b 100644 --- a/Terminal.Gui/App/Timeout/ITimedEvents.cs +++ b/Terminal.Gui/App/Timeout/ITimedEvents.cs @@ -18,29 +18,53 @@ public interface ITimedEvents object Add (TimeSpan time, Func callback); /// + /// + /// Adding the same instance more than once creates multiple occurrences that share one + /// cancellation token. Calling with that token cancels all matching occurrences that precede + /// the removal operation's synchronization point. + /// object Add (Timeout timeout); /// /// Invoked when a new timeout is added. To be used in the case when /// is . /// + /// + /// The event is raised after the timeout is added and after the timeout queue lock is released. A concurrent + /// call can execute a due timeout before its handler runs. + /// event EventHandler? Added; /// - /// Removes a previously scheduled timeout. + /// Cancels all timeout occurrences associated with a previously returned token. /// /// - /// The token parameter is the value returned by or . + /// + /// The token parameter is the value returned by or + /// . All matching queued occurrences are removed. + /// + /// + /// A matching callback that is already executing is not interrupted, but it will not be rescheduled if it + /// returns . Occurrences registered after the cancellation takes effect are not affected, + /// including occurrences created by adding the same instance again. A concurrent + /// or call can be ordered before or after the + /// cancellation. + /// /// /// - /// if the timeout is successfully removed; otherwise, . - /// This method also returns if the timeout is not found. + /// if at least one queued occurrence was removed or a cancellation request was recorded for + /// at least one active occurrence; otherwise, . /// bool Remove (object token); /// - /// Runs all timeouts that are due. + /// Runs timeouts that are due. /// + /// + /// Timeout callbacks are serialized. A nested call on the active runner thread is supported. A call from a + /// competing thread returns without running callbacks; remaining due timeouts are processed by a later successful + /// call. A callback exception propagates directly from the call that executes it and ends that timer pass. + /// void RunTimers (); /// @@ -50,12 +74,23 @@ public interface ITimedEvents SortedList Timeouts { get; } /// - /// Gets the timeout for the specified event. + /// Gets the configured interval for a queued timeout occurrence associated with the specified token. /// /// The token of the event. - /// The for the event, or if the event is not found. + /// + /// The for a queued occurrence, or if no queued occurrence is found. + /// A callback can be actively executing when this method returns ; use + /// to prevent an active repeating callback from rescheduling. + /// TimeSpan? GetTimeout (object token); /// Stops and removes all timed events. + /// + /// Removes all queued timeout occurrences and prevents callbacks that are active when this method is called from + /// rescheduling. It does not interrupt callbacks already executing. Timeouts added after the cancellation takes + /// effect are unaffected, including a reused instance. A concurrent + /// or call can be ordered before or after the + /// cancellation. + /// void StopAll (); } diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index d5fc6bed04..fa92b643da 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Runtime.ExceptionServices; namespace Terminal.Gui.App; @@ -41,11 +40,11 @@ namespace Terminal.Gui.App; public class TimedEvents : ITimedEvents { internal SortedList _timeouts = new (); - private readonly List _activeTimeoutOccurrences = []; + private readonly Dictionary _activeTimeoutStates = new (ReferenceEqualityComparer.Instance); private readonly object _runTimersLockToken = new (); private readonly object _timeoutsLockToken = new (); private readonly ITimeProvider? _timeProvider; - private int _runTimersPending; + private long _stopAllEpoch; /// /// Initializes a new instance of with the default system time provider. @@ -106,63 +105,21 @@ private long GetTimestampTicks () /// public void RunTimers () { - Interlocked.Exchange (ref _runTimersPending, 1); - List? errors = null; - - while (true) + // A monitor is reentrant, so nested runs on this thread remain supported. A competing caller returns while the + // active runner drains the due timeouts. + if (!Monitor.TryEnter (_runTimersLockToken)) { - // A monitor is reentrant, so nested runs on this thread remain supported. A competing thread returns while - // the active runner drains the due timeouts. - if (!Monitor.TryEnter (_runTimersLockToken)) - { - ThrowErrors (errors); - - return; - } - - try - { - Interlocked.Exchange (ref _runTimersPending, 0); - - try - { - RunTimersImpl (); - } - catch (Exception ex) - { - errors ??= []; - errors.Add (ExceptionDispatchInfo.Capture (ex)); - } - } - finally - { - Monitor.Exit (_runTimersLockToken); - } - - if (Interlocked.Exchange (ref _runTimersPending, 0) == 0) - { - ThrowErrors (errors); - - return; - } + return; } - } - private static void ThrowErrors (List? errors) - { - if (errors is null) + try { - return; + RunTimersImpl (); } - - if (errors.Count == 1) + finally { - errors [0].Throw (); - - return; + Monitor.Exit (_runTimersLockToken); } - - throw new AggregateException (errors.ConvertAll (error => error.SourceException)); } /// @@ -177,28 +134,30 @@ public bool Remove (object token) lock (_timeoutsLockToken) { - int idx = _timeouts.IndexOfValue (timeout); + var found = false; - if (idx >= 0) + for (var i = _timeouts.Count - 1; i >= 0; i--) { - _timeouts.RemoveAt (idx); - - return true; - } - - foreach (ActiveTimeoutOccurrence occurrence in _activeTimeoutOccurrences) - { - if (!ReferenceEquals (occurrence.Timeout, timeout) || occurrence.IsCancelled) + if (!ReferenceEquals (_timeouts.Values [i], timeout)) { continue; } - occurrence.IsCancelled = true; + _timeouts.RemoveAt (i); + found = true; + } - return true; + if (_activeTimeoutStates.TryGetValue (timeout, out ActiveTimeoutState state) + && state.StopAllEpoch == _stopAllEpoch + && state.UncancelledActiveCount > 0) + { + state.RemovalGeneration++; + state.UncancelledActiveCount = 0; + _activeTimeoutStates [timeout] = state; + found = true; } - return false; + return found; } } @@ -256,16 +215,22 @@ public bool CheckTimers (out int waitTimeout) /// public TimeSpan? GetTimeout (object token) { - lock (_timeoutsLockToken) + if (token is not Timeout timeout) { - int idx = _timeouts.IndexOfValue ((token as Timeout)!); + return null; + } - if (idx == -1) + lock (_timeoutsLockToken) + { + foreach (Timeout queuedTimeout in _timeouts.Values) { - return null; + if (ReferenceEquals (queuedTimeout, timeout)) + { + return timeout.Span; + } } - return _timeouts.Values [idx].Span; + return null; } } @@ -283,6 +248,9 @@ private void AddTimeout (TimeSpan time, Timeout timeout) private long AddTimeoutCore (TimeSpan time, Timeout timeout) { + // Caller must hold _timeoutsLockToken. + Debug.Assert (Monitor.IsEntered (_timeoutsLockToken)); + long k = GetTimestampTicks () + time.Ticks; // if user wants to run as soon as possible set timer such that it expires right away (no race conditions) @@ -306,6 +274,9 @@ private long AddTimeoutCore (TimeSpan time, Timeout timeout) /// private long NudgeToUniqueKey (long k) { + // Caller must hold _timeoutsLockToken. + Debug.Assert (Monitor.IsEntered (_timeoutsLockToken)); + while (_timeouts.ContainsKey (k)) { k++; @@ -343,12 +314,22 @@ private void RunTimersImpl () // This timeout is due - remove it from the queue Timeout timeoutToExecute = _timeouts.Values [0]; _timeouts.RemoveAt (0); - occurrence = new (timeoutToExecute); - _activeTimeoutOccurrences.Add (occurrence); + _activeTimeoutStates.TryGetValue (timeoutToExecute, out ActiveTimeoutState state); + + if (state.StopAllEpoch != _stopAllEpoch) + { + state.StopAllEpoch = _stopAllEpoch; + state.UncancelledActiveCount = 0; + } + + occurrence = new (timeoutToExecute, _stopAllEpoch, state.RemovalGeneration); + state.ActiveCount++; + state.UncancelledActiveCount++; + _activeTimeoutStates [timeoutToExecute] = state; } // Execute the callback outside the lock - // This allows nested Run() calls to access the timeout queue + // This allows nested RunTimers() calls to access the timeout queue bool repeat = false; try @@ -368,10 +349,34 @@ private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) lock (_timeoutsLockToken) { - bool removed = _activeTimeoutOccurrences.Remove (occurrence); - Debug.Assert (removed); + bool found = _activeTimeoutStates.TryGetValue (occurrence.Timeout, out ActiveTimeoutState state); + Debug.Assert (found); + + if (!found) + { + return; + } + + state.ActiveCount--; + bool canReschedule = occurrence.StopAllEpoch == _stopAllEpoch + && occurrence.RemovalGeneration == state.RemovalGeneration; + + if (canReschedule) + { + state.UncancelledActiveCount--; + Debug.Assert (state.UncancelledActiveCount >= 0); + } + + if (state.ActiveCount == 0) + { + _activeTimeoutStates.Remove (occurrence.Timeout); + } + else + { + _activeTimeoutStates [occurrence.Timeout] = state; + } - if (!repeat || occurrence.IsCancelled) + if (!repeat || !canReschedule) { return; } @@ -388,23 +393,17 @@ public void StopAll () lock (_timeoutsLockToken) { _timeouts.Clear (); - - foreach (ActiveTimeoutOccurrence occurrence in _activeTimeoutOccurrences) - { - occurrence.IsCancelled = true; - } + _stopAllEpoch++; } } - private sealed class ActiveTimeoutOccurrence - { - public ActiveTimeoutOccurrence (Timeout timeout) - { - Timeout = timeout; - } + private readonly record struct ActiveTimeoutOccurrence (Timeout Timeout, long StopAllEpoch, long RemovalGeneration); - public bool IsCancelled { get; set; } - - public Timeout Timeout { get; } + private struct ActiveTimeoutState + { + public int ActiveCount { get; set; } + public long RemovalGeneration { get; set; } + public long StopAllEpoch { get; set; } + public int UncancelledActiveCount { get; set; } } } diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 52df88d8de..b579c7cf42 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -573,7 +573,7 @@ public async Task RunTimers_StopAll_During_Active_Repeating_Timeout_Prevents_Res // CoPilot - GPT-5 [Fact] - public async Task RunTimers_Competing_Caller_Requests_Follow_Up_Run () + public async Task RunTimers_Competing_Caller_Returns_Without_Running_Newly_Due_Timeout () { CancellationToken cancellationToken = TestContext.Current.CancellationToken; long currentTicks = DateTime.UnixEpoch.Ticks; @@ -641,31 +641,24 @@ DateTime GetCurrentTime () Assert.True (dueCheckStartedBeforeTimeout, "The first runner should reach the due-time check."); Assert.True (dueCheckReleased, "The due-time check should be released before its wait times out."); Assert.True (secondRunnerReturned, "The competing RunTimers call should return while the runner is active."); + Assert.Equal (0, callbackCount); + Assert.Single (timedEvents.Timeouts); + + timedEvents.RunTimers (); + Assert.Equal (1, callbackCount); Assert.Empty (timedEvents.Timeouts); } // CoPilot - GPT-5 [Fact] - public async Task RunTimers_Competing_Caller_Requests_Follow_Up_Run_When_Callback_Throws () + public void RunTimers_Callback_Exception_Propagates_And_Ends_Pass () { TimedEvents timedEvents = new (); - CancellationToken cancellationToken = TestContext.Current.CancellationToken; - using ManualResetEventSlim callbackStarted = new (); - using ManualResetEventSlim releaseCallback = new (); InvalidOperationException expectedException = new ("Expected callback failure."); var followUpCallbackCount = 0; - var callbackReleased = false; - - timedEvents.Add ( - TimeSpan.Zero, - () => - { - callbackStarted.Set (); - callbackReleased = releaseCallback.Wait (TimeSpan.FromSeconds (5), cancellationToken); - throw expectedException; - }); + timedEvents.Add (TimeSpan.Zero, () => throw expectedException); timedEvents.Add ( TimeSpan.Zero, () => @@ -675,100 +668,15 @@ public async Task RunTimers_Competing_Caller_Requests_Follow_Up_Run_When_Callbac return false; }); - Task firstRunner = RunOnDedicatedThread (timedEvents.RunTimers); - Task? secondRunner = null; - var callbackStartedBeforeTimeout = false; - var secondRunnerReturned = false; - - try - { - callbackStartedBeforeTimeout = callbackStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + InvalidOperationException actualException = Assert.Throws (timedEvents.RunTimers); - if (callbackStartedBeforeTimeout) - { - secondRunner = RunOnDedicatedThread (timedEvents.RunTimers); - secondRunnerReturned = await CompletesWithinAsync (secondRunner, cancellationToken); - } - } - finally - { - releaseCallback.Set (); - } - - InvalidOperationException actualException = await Assert.ThrowsAsync ( - async () => await firstRunner); - - if (secondRunner is not null) - { - await secondRunner; - } - - Assert.True (callbackStartedBeforeTimeout, "The throwing callback should start."); - Assert.True (callbackReleased, "The throwing callback should be released before its wait times out."); - Assert.True (secondRunnerReturned, "The competing RunTimers call should return while the runner is active."); Assert.Same (expectedException, actualException); - Assert.Equal (1, followUpCallbackCount); - Assert.Empty (timedEvents.Timeouts); - } - - // CoPilot - GPT-5 - [Fact] - public async Task RunTimers_Competing_Caller_Aggregates_Follow_Up_Callback_Exception () - { - TimedEvents timedEvents = new (); - CancellationToken cancellationToken = TestContext.Current.CancellationToken; - using ManualResetEventSlim callbackStarted = new (); - using ManualResetEventSlim releaseCallback = new (); - InvalidOperationException firstException = new ("Expected first callback failure."); - ArgumentException followUpException = new ("Expected follow-up callback failure."); - var callbackReleased = false; - - timedEvents.Add ( - TimeSpan.Zero, - () => - { - callbackStarted.Set (); - callbackReleased = releaseCallback.Wait (TimeSpan.FromSeconds (5), cancellationToken); - - throw firstException; - }); - timedEvents.Add (TimeSpan.Zero, () => throw followUpException); - - Task firstRunner = RunOnDedicatedThread (timedEvents.RunTimers); - Task? secondRunner = null; - var callbackStartedBeforeTimeout = false; - var secondRunnerReturned = false; - - try - { - callbackStartedBeforeTimeout = callbackStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); - - if (callbackStartedBeforeTimeout) - { - secondRunner = RunOnDedicatedThread (timedEvents.RunTimers); - secondRunnerReturned = await CompletesWithinAsync (secondRunner, cancellationToken); - } - } - finally - { - releaseCallback.Set (); - } - - AggregateException actualException = await Assert.ThrowsAsync ( - async () => await firstRunner); + Assert.Equal (0, followUpCallbackCount); + Assert.Single (timedEvents.Timeouts); - if (secondRunner is not null) - { - await secondRunner; - } + timedEvents.RunTimers (); - Assert.True (callbackStartedBeforeTimeout, "The first throwing callback should start."); - Assert.True (callbackReleased, "The first throwing callback should be released before its wait times out."); - Assert.True (secondRunnerReturned, "The competing RunTimers call should request the follow-up pass."); - Assert.Collection ( - actualException.InnerExceptions, - exception => Assert.Same (firstException, exception), - exception => Assert.Same (followUpException, exception)); + Assert.Equal (1, followUpCallbackCount); Assert.Empty (timedEvents.Timeouts); } @@ -908,6 +816,212 @@ public async Task RunTimers_Remove_Cancels_Repeating_Duplicate_Active_Timeout_Oc Assert.Empty (timedEvents.Timeouts); } + // CoPilot - GPT-5 + [Fact] + public void Remove_Cancels_Queued_And_Active_Occurrences_With_Same_Token () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + var callbackCount = 0; + var removed = false; + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.FromSeconds (1) }; + + timeout.Callback = () => + { + callbackCount++; + timedEvents.Add (timeout); + removed = timedEvents.Remove (timeout); + + return true; + }; + + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + + timedEvents.RunTimers (); + + Assert.True (removed); + Assert.Equal (1, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void Remove_Cancels_All_Queued_Occurrences_With_Same_Token () + { + TimedEvents timedEvents = new (); + Terminal.Gui.App.Timeout timeout = new () + { + Span = TimeSpan.FromHours (1), + Callback = () => false + }; + + timedEvents.Add (timeout); + timedEvents.Add (timeout); + + bool removed = timedEvents.Remove (timeout); + + Assert.True (removed); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void GetTimeout_Returns_Null_For_Active_Occurrence_That_Remove_Can_Cancel () + { + TimedEvents timedEvents = new (); + TimeSpan? actualSpan = TimeSpan.MaxValue; + var removed = false; + object? token = null; + + token = timedEvents.Add ( + TimeSpan.Zero, + () => + { + actualSpan = timedEvents.GetTimeout (token!); + removed = timedEvents.Remove (token!); + + return true; + }); + + timedEvents.RunTimers (); + + Assert.Null (actualSpan); + Assert.True (removed); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void Remove_Does_Not_Cancel_Same_Timeout_Added_After_Remove () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + var callbackCount = 0; + var removed = false; + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.FromSeconds (1) }; + + timeout.Callback = () => + { + callbackCount++; + + if (callbackCount > 1) + { + return false; + } + + removed = timedEvents.Remove (timeout); + timedEvents.Add (timeout); + + return true; + }; + + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + Assert.True (removed); + Assert.Single (timedEvents.Timeouts); + + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + Assert.Equal (2, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void Remove_Does_Not_Cancel_Nested_Active_Occurrence_Added_After_Remove () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + var callbackCount = 0; + var removed = false; + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.FromSeconds (1) }; + + timeout.Callback = () => + { + callbackCount++; + + if (callbackCount == 1) + { + removed = timedEvents.Remove (timeout); + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + return true; + } + + return callbackCount == 2; + }; + + timedEvents.Add (timeout); + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + Assert.True (removed); + Assert.Equal (2, callbackCount); + Assert.Single (timedEvents.Timeouts); + + timeProvider.Advance (TimeSpan.FromSeconds (2)); + timedEvents.RunTimers (); + + Assert.Equal (3, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void Remove_Returns_False_When_Active_Occurrence_Is_Already_Cancelled () + { + TimedEvents timedEvents = new (); + var firstRemove = false; + var secondRemove = true; + object? token = null; + + token = timedEvents.Add ( + TimeSpan.Zero, + () => + { + firstRemove = timedEvents.Remove (token!); + secondRemove = timedEvents.Remove (token!); + + return true; + }); + + timedEvents.RunTimers (); + + Assert.True (firstRemove); + Assert.False (secondRemove); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void Remove_Returns_False_When_StopAll_Already_Cancelled_Active_Occurrence () + { + TimedEvents timedEvents = new (); + var removed = true; + object? token = null; + + token = timedEvents.Add ( + TimeSpan.Zero, + () => + { + timedEvents.StopAll (); + removed = timedEvents.Remove (token!); + + return true; + }); + + timedEvents.RunTimers (); + + Assert.False (removed); + Assert.Empty (timedEvents.Timeouts); + } + [Fact] public void HighFrequency_Concurrent_Invocations_No_Lost_Timeouts () { From 6103007e4bf294ed667d684324b76b909f2fda31 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Mon, 31 Aug 2026 21:42:59 -0500 Subject: [PATCH 10/42] Document cancellation state and pin in-flight Remove contract Address review nits on the timer cancellation rework: - Add a deterministic test for the documented contract that Remove returns true for an occurrence that has already been dequeued and is executing, and neither waits for nor interrupts that callback. This was documented on ITimedEvents.Remove but not covered by a test. - Note that ActiveTimeoutState is a mutable struct in a Dictionary, so every mutation must be written back before the queue lock is released. - Document ActiveTimeoutOccurrence and ActiveTimeoutState. - Note that Remove intentionally scans the whole queue, because the same Timeout instance can be queued more than once. - Correct the class-level thread-safety docs. The blanket "Thread-safe for concurrent access" claim was inaccurate: Timeouts returns the live queue rather than a snapshot and is not synchronized. - Replace the orphaned on CheckTimers with real docs. CheckTimers is not declared on ITimedEvents, so it inherited nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Terminal.Gui/App/Timeout/TimedEvents.cs | 53 ++++++++++++++- .../Application/Timeouts/TimedEventsTests.cs | 64 +++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index fa92b643da..6161bc1495 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -7,7 +7,6 @@ namespace Terminal.Gui.App; /// /// Allows scheduling of callbacks to be invoked after a specified delay, with optional repetition. /// Timeouts are stored in a sorted list by their scheduled execution time (high-resolution ticks). -/// Thread-safe for concurrent access. /// /// /// Typical usage: @@ -29,6 +28,16 @@ namespace Terminal.Gui.App; /// /// /// +/// , , , +/// , , , and are +/// safe to call concurrently from any thread. Callbacks are invoked outside the timeout queue lock, so a +/// callback can schedule or cancel timeouts without deadlocking. +/// +/// +/// is the exception: it returns the live queue rather than a snapshot, so reading or +/// mutating it while another thread schedules timeouts is not synchronized. +/// +/// /// By default, uses for high-resolution timing to provide microsecond-level /// precision and eliminate race conditions from timer resolution issues. /// @@ -40,6 +49,9 @@ namespace Terminal.Gui.App; public class TimedEvents : ITimedEvents { internal SortedList _timeouts = new (); + + // ActiveTimeoutState is a mutable struct, so TryGetValue hands back a copy. Every mutation must be written back + // with _activeTimeoutStates [timeout] = state (or the entry removed) before _timeoutsLockToken is released. private readonly Dictionary _activeTimeoutStates = new (ReferenceEqualityComparer.Instance); private readonly object _runTimersLockToken = new (); private readonly object _timeoutsLockToken = new (); @@ -71,6 +83,10 @@ public TimedEvents (ITimeProvider? timeProvider) /// Gets the list of all timeouts sorted by the time ticks. A shorter limit time can be /// added at the end, but it will be called before an earlier addition that has a longer limit time. /// + /// + /// Returns the live queue, not a snapshot, and access to it is not synchronized. A timeout whose callback is + /// currently executing has already been dequeued and is not present. + /// public SortedList Timeouts => _timeouts; /// @@ -136,6 +152,8 @@ public bool Remove (object token) { var found = false; + // The same Timeout instance can be queued more than once, so the whole queue is scanned. Do not replace + // this with an early-exiting lookup such as IndexOfValue. for (var i = _timeouts.Count - 1; i >= 0; i--) { if (!ReferenceEquals (_timeouts.Values [i], timeout)) @@ -180,7 +198,18 @@ public object Add (Timeout timeout) return timeout; } - /// + /// + /// Determines whether any timeout is queued and calculates how long the caller may wait before the earliest one + /// is due. + /// + /// + /// The number of milliseconds until the earliest queued timeout is due, 0 if one is already due, or + /// -1 if no timeout is queued. -1 indicates the caller may wait indefinitely. + /// + /// if at least one timeout is queued; otherwise, . + /// + /// A timeout whose callback is currently executing has been dequeued and is therefore not counted. + /// public bool CheckTimers (out int waitTimeout) { long now = GetTimestampTicks (); @@ -397,13 +426,33 @@ public void StopAll () } } + /// + /// Identifies a single in-flight execution of a , capturing the cancellation state that was + /// current when the occurrence was dequeued. reschedules only if both values still + /// match, which is how and cancel an active occurrence without + /// affecting occurrences created after them. + /// private readonly record struct ActiveTimeoutOccurrence (Timeout Timeout, long StopAllEpoch, long RemovalGeneration); + /// + /// Per- cancellation bookkeeping. Mutable; see the comment on + /// for the write-back requirement. + /// private struct ActiveTimeoutState { + /// Number of occurrences of this timeout that are currently executing. public int ActiveCount { get; set; } + + /// Incremented by to invalidate every occurrence dequeued before it. public long RemovalGeneration { get; set; } + + /// The value that is scoped to. public long StopAllEpoch { get; set; } + + /// + /// Number of active occurrences still matching and , + /// used so can report whether it actually cancelled anything. + /// public int UncancelledActiveCount { get; set; } } } diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index b579c7cf42..56d8938a7c 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -514,6 +514,70 @@ public async Task RunTimers_Remove_Active_Repeating_Timeout_Prevents_Reschedule Assert.Empty (timedEvents.Timeouts); } + // CoPilot - Claude Opus 5 + [Fact] + public async Task Remove_Returns_True_For_Active_Occurrence_And_Does_Not_Interrupt_It () + { + // Pins the documented contract: Remove reports true when the only match is an occurrence that has already been + // dequeued and is executing, and it neither waits for nor interrupts that callback. + TimedEvents timedEvents = new (); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim callbackStarted = new (); + using ManualResetEventSlim releaseCallback = new (); + var callbackReleased = false; + var callbackCompleted = false; + + object timeout = timedEvents.Add ( + TimeSpan.Zero, + () => + { + callbackStarted.Set (); + callbackReleased = releaseCallback.Wait ( + TimeSpan.FromSeconds (5), + cancellationToken); + callbackCompleted = true; + + return false; + }); + + Task runnerTask = RunOnDedicatedThread (timedEvents.RunTimers); + Task? removeTask = null; + var callbackStartedBeforeTimeout = false; + var removeReturnedBeforeCallback = false; + var queueWasEmptyWhenRemoveRan = false; + var callbackStillActiveWhenRemoveReturned = false; + + try + { + callbackStartedBeforeTimeout = callbackStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (callbackStartedBeforeTimeout) + { + // The occurrence was dequeued before the callback ran, so nothing is left for Remove to unqueue. + queueWasEmptyWhenRemoveRan = timedEvents.Timeouts.Count == 0; + removeTask = RunOnDedicatedThread (() => timedEvents.Remove (timeout)); + removeReturnedBeforeCallback = await CompletesWithinAsync (removeTask, cancellationToken); + callbackStillActiveWhenRemoveReturned = !callbackCompleted; + } + } + finally + { + releaseCallback.Set (); + } + + await runnerTask; + bool removed = removeTask is not null && await removeTask; + + Assert.True (callbackStartedBeforeTimeout, "The callback should start."); + Assert.True (queueWasEmptyWhenRemoveRan, "The active occurrence should already be dequeued."); + Assert.True (removeReturnedBeforeCallback, "Remove should return while the callback is active."); + Assert.True (callbackStillActiveWhenRemoveReturned, "Remove should not wait for the active callback to finish."); + Assert.True (removed, "Remove should report true for the cancelled active occurrence."); + Assert.True (callbackReleased, "The callback should be released before its wait times out."); + Assert.True (callbackCompleted, "Remove should not interrupt the active callback."); + Assert.Empty (timedEvents.Timeouts); + } + // CoPilot - GPT-5 [Fact] public async Task RunTimers_StopAll_During_Active_Repeating_Timeout_Prevents_Reschedule () From cabd60d9bf71b9257e50b5aa74af421a54c29ecb Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Tue, 1 Sep 2026 22:07:40 -0500 Subject: [PATCH 11/42] Bound timer passes and harden timeout APIs Prevent zero-delay repeating timeouts from starving the main loop by limiting each pass to the number of callbacks due at its start. Validate Timeout inputs, return a synchronized queue snapshot, and report the collision-adjusted Added timestamp with deterministic regression coverage. --- Terminal.Gui/App/Timeout/ITimedEvents.cs | 13 ++- Terminal.Gui/App/Timeout/TimedEvents.cs | 46 ++++++++-- Terminal.Gui/App/Timeout/TimeoutEventArgs.cs | 4 +- .../Application/Timeouts/TimedEventsTests.cs | 89 +++++++++++++++++++ 4 files changed, 140 insertions(+), 12 deletions(-) diff --git a/Terminal.Gui/App/Timeout/ITimedEvents.cs b/Terminal.Gui/App/Timeout/ITimedEvents.cs index 6deeec096b..5577e4c68a 100644 --- a/Terminal.Gui/App/Timeout/ITimedEvents.cs +++ b/Terminal.Gui/App/Timeout/ITimedEvents.cs @@ -15,6 +15,7 @@ public interface ITimedEvents /// returned value is a /// token that can be used to stop the timeout by calling . /// + /// is . object Add (TimeSpan time, Func callback); /// @@ -23,6 +24,9 @@ public interface ITimedEvents /// cancellation token. Calling with that token cancels all matching occurrences that precede /// the removal operation's synchronization point. /// + /// + /// or its is . + /// object Add (Timeout timeout); /// @@ -63,13 +67,16 @@ public interface ITimedEvents /// /// Timeout callbacks are serialized. A nested call on the active runner thread is supported. A call from a /// competing thread returns without running callbacks; remaining due timeouts are processed by a later successful - /// call. A callback exception propagates directly from the call that executes it and ends that timer pass. + /// call. Each pass executes at most the number of callbacks that were due when it started, so a repeating callback + /// that reschedules as already due is deferred to a later pass rather than starving the main loop. Concurrent queue + /// changes can alter which due occurrences consume that budget. A callback exception propagates directly from the + /// call that executes it and ends that timer pass. /// void RunTimers (); /// - /// Returns the next planned execution time (key - UTC ticks) - /// for each timeout that is not actively executing. + /// Returns a snapshot containing the next planned execution timestamp, in 100-nanosecond units, for each timeout + /// that is not actively executing. Mutating the returned list does not change the scheduled timeouts. /// SortedList Timeouts { get; } diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 6161bc1495..3fd4f883e0 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -34,8 +34,8 @@ namespace Terminal.Gui.App; /// callback can schedule or cancel timeouts without deadlocking. /// /// -/// is the exception: it returns the live queue rather than a snapshot, so reading or -/// mutating it while another thread schedules timeouts is not synchronized. +/// returns a snapshot so the queue can be inspected safely while another thread schedules +/// or cancels timeouts. /// /// /// By default, uses for high-resolution timing to provide microsecond-level @@ -84,10 +84,19 @@ public TimedEvents (ITimeProvider? timeProvider) /// added at the end, but it will be called before an earlier addition that has a longer limit time. /// /// - /// Returns the live queue, not a snapshot, and access to it is not synchronized. A timeout whose callback is - /// currently executing has already been dequeued and is not present. + /// Returns a snapshot. Mutating the returned list does not change the scheduled timeouts. A timeout whose + /// callback is currently executing has already been dequeued and is not present. /// - public SortedList Timeouts => _timeouts; + public SortedList Timeouts + { + get + { + lock (_timeoutsLockToken) + { + return new (_timeouts); + } + } + } /// public event EventHandler? Added; @@ -193,6 +202,9 @@ public object Add (TimeSpan time, Func callback) /// public object Add (Timeout timeout) { + ArgumentNullException.ThrowIfNull (timeout); + ArgumentNullException.ThrowIfNull (timeout.Callback); + AddTimeout (timeout.Span, timeout); return timeout; @@ -290,7 +302,8 @@ private long AddTimeoutCore (TimeSpan time, Timeout timeout) k -= TimeSpan.TicksPerMillisecond; } - _timeouts.Add (NudgeToUniqueKey (k), timeout); + k = NudgeToUniqueKey (k); + _timeouts.Add (k, timeout); return k; } @@ -316,8 +329,23 @@ private long NudgeToUniqueKey (long k) private void RunTimersImpl () { - // Process due timeouts one at a time, without blocking the entire queue - while (true) + int remainingCallbacks; + + lock (_timeoutsLockToken) + { + long passStart = GetTimestampTicks (); + remainingCallbacks = 0; + + while (remainingCallbacks < _timeouts.Count && _timeouts.Keys [remainingCallbacks] <= passStart) + { + remainingCallbacks++; + } + } + + // Bound each pass to the number of callbacks that were due when it started. A repeating timeout with a zero or + // negative span reschedules as already due; without this budget it can run forever in one pass and starve the + // rest of the main loop. Concurrent queue changes can alter which due occurrences consume the budget. + while (remainingCallbacks > 0) { ActiveTimeoutOccurrence occurrence; @@ -357,6 +385,8 @@ private void RunTimersImpl () _activeTimeoutStates [timeoutToExecute] = state; } + remainingCallbacks--; + // Execute the callback outside the lock // This allows nested RunTimers() calls to access the timeout queue bool repeat = false; diff --git a/Terminal.Gui/App/Timeout/TimeoutEventArgs.cs b/Terminal.Gui/App/Timeout/TimeoutEventArgs.cs index 9ad014fa43..14d1525c42 100644 --- a/Terminal.Gui/App/Timeout/TimeoutEventArgs.cs +++ b/Terminal.Gui/App/Timeout/TimeoutEventArgs.cs @@ -12,7 +12,9 @@ public TimeoutEventArgs (Timeout timeout, long ticks) Ticks = ticks; } - /// Gets the in UTC time when the will next execute after. + /// + /// Gets the actual queue key, as a timestamp in 100-nanosecond units, after collision nudging has been applied. + /// public long Ticks { get; } /// Gets the timeout callback handler diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 56d8938a7c..7c2f516131 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -1166,6 +1166,95 @@ public void GetTimestampTicks_Provides_High_Resolution () $"Expected more unique timestamps. Got {uniqueTimestamps} unique out of {timestamps.Count} total"); } + // Claude Opus 5 + [Fact] + public void Add_Timeout_Throws_For_Null_Timeout () + { + TimedEvents timedEvents = new (); + + Assert.Throws (() => timedEvents.Add ((Terminal.Gui.App.Timeout)null!)); + } + + // Claude Opus 5 + [Fact] + public void Add_Timeout_Throws_For_Null_Callback () + { + TimedEvents timedEvents = new (); + Terminal.Gui.App.Timeout timeout = new () { Span = TimeSpan.Zero }; + + Assert.Throws (() => timedEvents.Add (timeout)); + Assert.Empty (timedEvents.Timeouts); + } + + // Claude Opus 5 + [Fact] + public void Added_Reports_Actual_Queue_Key_After_Collision () + { + VirtualTimeProvider timeProvider = new (); + TimedEvents timedEvents = new (timeProvider); + List addedTicks = []; + timedEvents.Added += (_, e) => addedTicks.Add (e.Ticks); + + timedEvents.Add (TimeSpan.Zero, () => false); + timedEvents.Add (TimeSpan.Zero, () => false); + + Assert.Equal (timedEvents.Timeouts.Keys, addedTicks); + Assert.Equal (2, addedTicks.Distinct ().Count ()); + } + + // Claude Opus 5 + [Fact] + public void Timeouts_Returns_Snapshot () + { + TimedEvents timedEvents = new (); + timedEvents.Add (TimeSpan.FromHours (1), () => false); + + SortedList snapshot = timedEvents.Timeouts; + + timedEvents.Add (TimeSpan.FromHours (2), () => false); + snapshot.Clear (); + + Assert.Equal (2, timedEvents.Timeouts.Count); + } + + // Claude Opus 5 + [Fact] + public async Task RunTimers_Defers_Repeating_Zero_Timeout_Until_Next_Pass () + { + TimedEvents timedEvents = new (new VirtualTimeProvider ()); + var callbackCount = 0; + object token = timedEvents.Add ( + TimeSpan.Zero, + () => + { + Interlocked.Increment (ref callbackCount); + + return true; + }); + Task runner = RunOnDedicatedThread (timedEvents.RunTimers); + Task completedTask = await Task.WhenAny ( + runner, + Task.Delay (TimeSpan.FromSeconds (1), TestContext.Current.CancellationToken)); + var returned = ReferenceEquals (completedTask, runner); + int countAfterPass = Volatile.Read (ref callbackCount); + int queuedAfterPass = returned ? timedEvents.Timeouts.Count : 0; + int countAfterNextPass = countAfterPass; + + if (returned) + { + timedEvents.RunTimers (); + countAfterNextPass = Volatile.Read (ref callbackCount); + } + + timedEvents.Remove (token); + await runner.WaitAsync (TimeSpan.FromSeconds (5), TestContext.Current.CancellationToken); + + Assert.True (returned, "RunTimers should return after the callbacks that were due when the pass started."); + Assert.Equal (1, countAfterPass); + Assert.Equal (1, queuedAfterPass); + Assert.Equal (2, countAfterNextPass); + } + [Fact] public void TimeSpan_Zero_Executes_Immediately () { From d5507b1126a1c4068dc3c807b8396d13473a5ca6 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Tue, 1 Sep 2026 22:35:27 -0500 Subject: [PATCH 12/42] Prevent repeaters from consuming peer timer slots Assign each queued timeout occurrence a monotonic identity and limit a timer pass to occurrences that were already queued and due when it began. Add a deterministic zero-delay repeater plus peer regression and correct the AI test markers noted by review. --- Terminal.Gui/App/Timeout/ITimedEvents.cs | 8 +- Terminal.Gui/App/Timeout/TimedEvents.cs | 78 ++++++++++++------- .../Application/Timeouts/TimedEventsTests.cs | 43 ++++++++-- 3 files changed, 91 insertions(+), 38 deletions(-) diff --git a/Terminal.Gui/App/Timeout/ITimedEvents.cs b/Terminal.Gui/App/Timeout/ITimedEvents.cs index 5577e4c68a..9a00fe7c82 100644 --- a/Terminal.Gui/App/Timeout/ITimedEvents.cs +++ b/Terminal.Gui/App/Timeout/ITimedEvents.cs @@ -67,10 +67,10 @@ public interface ITimedEvents /// /// Timeout callbacks are serialized. A nested call on the active runner thread is supported. A call from a /// competing thread returns without running callbacks; remaining due timeouts are processed by a later successful - /// call. Each pass executes at most the number of callbacks that were due when it started, so a repeating callback - /// that reschedules as already due is deferred to a later pass rather than starving the main loop. Concurrent queue - /// changes can alter which due occurrences consume that budget. A callback exception propagates directly from the - /// call that executes it and ends that timer pass. + /// call. Each pass executes only queue occurrences that were due when it started. An occurrence added or rescheduled + /// after the pass starts is deferred to a later pass, preventing an immediately repeating callback from starving + /// already-due peers. A callback exception propagates directly from the call that executes it and ends that timer + /// pass. /// void RunTimers (); diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 3fd4f883e0..781d430377 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -53,9 +53,11 @@ public class TimedEvents : ITimedEvents // ActiveTimeoutState is a mutable struct, so TryGetValue hands back a copy. Every mutation must be written back // with _activeTimeoutStates [timeout] = state (or the entry removed) before _timeoutsLockToken is released. private readonly Dictionary _activeTimeoutStates = new (ReferenceEqualityComparer.Instance); + private readonly Dictionary _queuedTimeoutOccurrenceIds = []; private readonly object _runTimersLockToken = new (); private readonly object _timeoutsLockToken = new (); private readonly ITimeProvider? _timeProvider; + private long _nextTimeoutOccurrenceId; private long _stopAllEpoch; /// @@ -170,6 +172,9 @@ public bool Remove (object token) continue; } + long key = _timeouts.Keys [i]; + bool occurrenceRemoved = _queuedTimeoutOccurrenceIds.Remove (key); + Debug.Assert (occurrenceRemoved); _timeouts.RemoveAt (i); found = true; } @@ -304,6 +309,7 @@ private long AddTimeoutCore (TimeSpan time, Timeout timeout) k = NudgeToUniqueKey (k); _timeouts.Add (k, timeout); + _queuedTimeoutOccurrenceIds.Add (k, _nextTimeoutOccurrenceId++); return k; } @@ -329,48 +335,36 @@ private long NudgeToUniqueKey (long k) private void RunTimersImpl () { - int remainingCallbacks; + long passStart; + long occurrenceIdCutoff; lock (_timeoutsLockToken) { - long passStart = GetTimestampTicks (); - remainingCallbacks = 0; - - while (remainingCallbacks < _timeouts.Count && _timeouts.Keys [remainingCallbacks] <= passStart) - { - remainingCallbacks++; - } + passStart = GetTimestampTicks (); + occurrenceIdCutoff = _nextTimeoutOccurrenceId; } - // Bound each pass to the number of callbacks that were due when it started. A repeating timeout with a zero or - // negative span reschedules as already due; without this budget it can run forever in one pass and starve the - // rest of the main loop. Concurrent queue changes can alter which due occurrences consume the budget. - while (remainingCallbacks > 0) + // Execute only queue occurrences that were due when this pass started. A repeating timeout with a zero or + // negative span reschedules as already due, but receives a new occurrence ID and is deferred to a later pass. + // Without occurrence identity, it can reuse its freed queue key and starve an already-due peer indefinitely. + while (true) { ActiveTimeoutOccurrence occurrence; - // Find the next due timeout lock (_timeoutsLockToken) { - if (_timeouts.Count == 0) - { - return; - } - - // Re-evaluate current time for each iteration - long now = GetTimestampTicks (); + int timeoutIndex = GetNextDueTimeoutIndex (passStart, occurrenceIdCutoff); - // Check if the earliest timeout is due - long scheduledTime = _timeouts.Keys [0]; - - if (scheduledTime > now) + if (timeoutIndex == -1) { return; } - // This timeout is due - remove it from the queue - Timeout timeoutToExecute = _timeouts.Values [0]; - _timeouts.RemoveAt (0); + long scheduledTime = _timeouts.Keys [timeoutIndex]; + Timeout timeoutToExecute = _timeouts.Values [timeoutIndex]; + bool occurrenceRemoved = _queuedTimeoutOccurrenceIds.Remove (scheduledTime); + Debug.Assert (occurrenceRemoved); + _timeouts.RemoveAt (timeoutIndex); _activeTimeoutStates.TryGetValue (timeoutToExecute, out ActiveTimeoutState state); if (state.StopAllEpoch != _stopAllEpoch) @@ -385,8 +379,6 @@ private void RunTimersImpl () _activeTimeoutStates [timeoutToExecute] = state; } - remainingCallbacks--; - // Execute the callback outside the lock // This allows nested RunTimers() calls to access the timeout queue bool repeat = false; @@ -402,6 +394,33 @@ private void RunTimersImpl () } } + private int GetNextDueTimeoutIndex (long passStart, long occurrenceIdCutoff) + { + // Caller must hold _timeoutsLockToken. + Debug.Assert (Monitor.IsEntered (_timeoutsLockToken)); + Debug.Assert (_timeouts.Count == _queuedTimeoutOccurrenceIds.Count); + + for (var i = 0; i < _timeouts.Count; i++) + { + long scheduledTime = _timeouts.Keys [i]; + + if (scheduledTime > passStart) + { + return -1; + } + + bool found = _queuedTimeoutOccurrenceIds.TryGetValue (scheduledTime, out long occurrenceId); + Debug.Assert (found); + + if (found && occurrenceId < occurrenceIdCutoff) + { + return i; + } + } + + return -1; + } + private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) { long k; @@ -452,6 +471,7 @@ public void StopAll () lock (_timeoutsLockToken) { _timeouts.Clear (); + _queuedTimeoutOccurrenceIds.Clear (); _stopAllEpoch++; } } diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 7c2f516131..15ee9be408 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -1166,7 +1166,7 @@ public void GetTimestampTicks_Provides_High_Resolution () $"Expected more unique timestamps. Got {uniqueTimestamps} unique out of {timestamps.Count} total"); } - // Claude Opus 5 + // Claude - Opus 5 [Fact] public void Add_Timeout_Throws_For_Null_Timeout () { @@ -1175,7 +1175,7 @@ public void Add_Timeout_Throws_For_Null_Timeout () Assert.Throws (() => timedEvents.Add ((Terminal.Gui.App.Timeout)null!)); } - // Claude Opus 5 + // Claude - Opus 5 [Fact] public void Add_Timeout_Throws_For_Null_Callback () { @@ -1186,7 +1186,7 @@ public void Add_Timeout_Throws_For_Null_Callback () Assert.Empty (timedEvents.Timeouts); } - // Claude Opus 5 + // Claude - Opus 5 [Fact] public void Added_Reports_Actual_Queue_Key_After_Collision () { @@ -1202,7 +1202,7 @@ public void Added_Reports_Actual_Queue_Key_After_Collision () Assert.Equal (2, addedTicks.Distinct ().Count ()); } - // Claude Opus 5 + // Claude - Opus 5 [Fact] public void Timeouts_Returns_Snapshot () { @@ -1217,7 +1217,7 @@ public void Timeouts_Returns_Snapshot () Assert.Equal (2, timedEvents.Timeouts.Count); } - // Claude Opus 5 + // Claude - Opus 5 [Fact] public async Task RunTimers_Defers_Repeating_Zero_Timeout_Until_Next_Pass () { @@ -1255,6 +1255,39 @@ public async Task RunTimers_Defers_Repeating_Zero_Timeout_Until_Next_Pass () Assert.Equal (2, countAfterNextPass); } + // CoPilot - GPT-5 + [Fact] + public void RunTimers_Repeating_Zero_Timeout_Does_Not_Starve_Due_Peer () + { + TimedEvents timedEvents = new (new VirtualTimeProvider ()); + var repeatingCallbackCount = 0; + var peerCallbackCount = 0; + object repeatingToken = timedEvents.Add ( + TimeSpan.Zero, + () => + { + repeatingCallbackCount++; + + return true; + }); + timedEvents.Add ( + TimeSpan.Zero, + () => + { + peerCallbackCount++; + + return false; + }); + + timedEvents.RunTimers (); + + Assert.Equal (1, repeatingCallbackCount); + Assert.Equal (1, peerCallbackCount); + Assert.Same (repeatingToken, Assert.Single (timedEvents.Timeouts).Value); + + timedEvents.Remove (repeatingToken); + } + [Fact] public void TimeSpan_Zero_Executes_Immediately () { From c8a3ead6774ccd0e465243c9f9e54d92e004044e Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Tue, 1 Sep 2026 23:19:21 -0500 Subject: [PATCH 13/42] Raise Added outside the occurrence-completion finally CompleteTimeout runs from the finally that follows a timeout callback, and it raised Added. That made an Added subscriber the only user-code call site reachable from that finally, so a throwing subscriber could replace an exception raised by the callback itself. The masking was not reachable in practice: a throwing callback leaves repeat false, so CompleteTimeout took its early-return path, which only reads the active-state entry, decrements it, and either removes it or assigns over an existing key. None of that allocates, so none of it could throw. The guard was incidental rather than designed, though, and any future change that let repeat be true on an error path would have turned it into real masking. Return the reschedule result from CompleteTimeout instead and raise Added from the timer loop once the callback has returned normally. Bookkeeping and rescheduling stay in the finally, still under the queue lock, so the ordering Remove depends on is unchanged, and Added is still raised after that lock is released. Document the resulting contract: an Added handler exception ends the timer pass while leaving the timeout scheduled, so a handler that throws on every reschedule ends every subsequent pass. Co-Authored-By: Claude Opus 5 (1M context) --- Terminal.Gui/App/Timeout/ITimedEvents.cs | 11 +++- Terminal.Gui/App/Timeout/TimedEvents.cs | 31 ++++++++--- .../Application/Timeouts/TimedEventsTests.cs | 55 +++++++++++++++++++ 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/Terminal.Gui/App/Timeout/ITimedEvents.cs b/Terminal.Gui/App/Timeout/ITimedEvents.cs index 9a00fe7c82..96c693d005 100644 --- a/Terminal.Gui/App/Timeout/ITimedEvents.cs +++ b/Terminal.Gui/App/Timeout/ITimedEvents.cs @@ -34,8 +34,15 @@ public interface ITimedEvents /// is . /// /// - /// The event is raised after the timeout is added and after the timeout queue lock is released. A concurrent - /// call can execute a due timeout before its handler runs. + /// + /// The event is raised after the timeout is added and after the timeout queue lock is released. A concurrent + /// call can execute a due timeout before its handler runs. + /// + /// + /// A handler exception propagates to whatever caused the timeout to be scheduled. For a repeating timeout that + /// is being rescheduled, that is the call, and it ends that timer pass. The timeout + /// remains scheduled, so a handler that throws on every reschedule ends every subsequent pass as well. + /// /// event EventHandler? Added; diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 781d430377..0a1590342b 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -382,6 +382,8 @@ private void RunTimersImpl () // Execute the callback outside the lock // This allows nested RunTimers() calls to access the timeout queue bool repeat = false; + bool rescheduled; + long rescheduledKey; try { @@ -389,7 +391,14 @@ private void RunTimersImpl () } finally { - CompleteTimeout (occurrence, repeat); + // Bookkeeping and rescheduling only. Added is raised below rather than here so that a + // subscriber cannot throw out of this finally and replace an exception from the callback. + rescheduled = CompleteTimeout (occurrence, repeat, out rescheduledKey); + } + + if (rescheduled) + { + Added?.Invoke (this, new (occurrence.Timeout, rescheduledKey)); } } } @@ -421,9 +430,15 @@ private int GetNextDueTimeoutIndex (long passStart, long occurrenceIdCutoff) return -1; } - private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) + /// + /// Releases the cancellation bookkeeping for a completed occurrence and reschedules it when it repeats and was + /// not cancelled. Called from a , so it must not raise : the caller + /// raises it after the callback has returned normally. + /// + /// if the timeout was rescheduled; otherwise, . + private bool CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat, out long rescheduledKey) { - long k; + rescheduledKey = 0; lock (_timeoutsLockToken) { @@ -432,7 +447,7 @@ private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) if (!found) { - return; + return false; } state.ActiveCount--; @@ -456,13 +471,13 @@ private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) if (!repeat || !canReschedule) { - return; + return false; } - k = AddTimeoutCore (occurrence.Timeout.Span, occurrence.Timeout); - } + rescheduledKey = AddTimeoutCore (occurrence.Timeout.Span, occurrence.Timeout); - Added?.Invoke (this, new (occurrence.Timeout, k)); + return true; + } } /// diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 15ee9be408..6065c9d97c 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -744,6 +744,61 @@ public void RunTimers_Callback_Exception_Propagates_And_Ends_Pass () Assert.Empty (timedEvents.Timeouts); } + // Claude - Opus 5 + [Fact] + public void RunTimers_Added_Handler_Exception_Does_Not_Replace_Callback_Exception () + { + TimedEvents timedEvents = new (); + InvalidOperationException expectedException = new ("Expected callback failure."); + + // A repeating timeout that throws. Added is raised outside the finally that completes the + // occurrence, so a throwing subscriber must not be able to displace the callback's exception. + timedEvents.Add (TimeSpan.FromMilliseconds (1), () => throw expectedException); + timedEvents.Added += (_, _) => throw new NotSupportedException ("Added handler failure."); + + Thread.Sleep (5); + + InvalidOperationException actualException = Assert.Throws (timedEvents.RunTimers); + + Assert.Same (expectedException, actualException); + + // A throwing callback is not rescheduled, so the queue drains. + Assert.Empty (timedEvents.Timeouts); + } + + // Claude - Opus 5 + [Fact] + public void RunTimers_Added_Handler_Exception_During_Reschedule_Leaves_Timeout_Scheduled () + { + TimedEvents timedEvents = new (); + var callbackCount = 0; + + timedEvents.Add ( + TimeSpan.FromMilliseconds (1), + () => + { + Interlocked.Increment (ref callbackCount); + + return true; + }); + + timedEvents.Added += (_, _) => throw new InvalidOperationException ("Added handler failure."); + + Thread.Sleep (5); + + // The reschedule succeeds before Added is raised, so the timeout survives the handler exception + // and later passes keep running it rather than losing it. + Assert.Throws (timedEvents.RunTimers); + Assert.Equal (1, callbackCount); + Assert.Single (timedEvents.Timeouts); + + Thread.Sleep (5); + + Assert.Throws (timedEvents.RunTimers); + Assert.Equal (2, callbackCount); + Assert.Single (timedEvents.Timeouts); + } + // CoPilot - GPT-5 [Fact] public void RunTimers_StopAll_Cancels_Duplicate_Active_Timeout_Occurrences () From 5bf212b5fc072b68ade629fc800b6a91f0794d61 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Tue, 1 Sep 2026 23:36:10 -0500 Subject: [PATCH 14/42] Remove unreachable exception-ordering contract A throwing timeout callback cannot also request rescheduling because the callback result is never assigned. The Added event is therefore unreachable on that path, so the exception-masking regression did not exercise the behavior it claimed. Remove the unsupported regression and restore the simpler occurrence-completion flow. Keep the separate coverage for the reachable case where an Added handler throws after a repeating timeout has been rescheduled. --- Terminal.Gui/App/Timeout/TimedEvents.cs | 31 +++++-------------- .../Application/Timeouts/TimedEventsTests.cs | 22 ------------- 2 files changed, 8 insertions(+), 45 deletions(-) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 0a1590342b..781d430377 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -382,8 +382,6 @@ private void RunTimersImpl () // Execute the callback outside the lock // This allows nested RunTimers() calls to access the timeout queue bool repeat = false; - bool rescheduled; - long rescheduledKey; try { @@ -391,14 +389,7 @@ private void RunTimersImpl () } finally { - // Bookkeeping and rescheduling only. Added is raised below rather than here so that a - // subscriber cannot throw out of this finally and replace an exception from the callback. - rescheduled = CompleteTimeout (occurrence, repeat, out rescheduledKey); - } - - if (rescheduled) - { - Added?.Invoke (this, new (occurrence.Timeout, rescheduledKey)); + CompleteTimeout (occurrence, repeat); } } } @@ -430,15 +421,9 @@ private int GetNextDueTimeoutIndex (long passStart, long occurrenceIdCutoff) return -1; } - /// - /// Releases the cancellation bookkeeping for a completed occurrence and reschedules it when it repeats and was - /// not cancelled. Called from a , so it must not raise : the caller - /// raises it after the callback has returned normally. - /// - /// if the timeout was rescheduled; otherwise, . - private bool CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat, out long rescheduledKey) + private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) { - rescheduledKey = 0; + long k; lock (_timeoutsLockToken) { @@ -447,7 +432,7 @@ private bool CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat, o if (!found) { - return false; + return; } state.ActiveCount--; @@ -471,13 +456,13 @@ private bool CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat, o if (!repeat || !canReschedule) { - return false; + return; } - rescheduledKey = AddTimeoutCore (occurrence.Timeout.Span, occurrence.Timeout); - - return true; + k = AddTimeoutCore (occurrence.Timeout.Span, occurrence.Timeout); } + + Added?.Invoke (this, new (occurrence.Timeout, k)); } /// diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 6065c9d97c..ae33db497a 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -744,28 +744,6 @@ public void RunTimers_Callback_Exception_Propagates_And_Ends_Pass () Assert.Empty (timedEvents.Timeouts); } - // Claude - Opus 5 - [Fact] - public void RunTimers_Added_Handler_Exception_Does_Not_Replace_Callback_Exception () - { - TimedEvents timedEvents = new (); - InvalidOperationException expectedException = new ("Expected callback failure."); - - // A repeating timeout that throws. Added is raised outside the finally that completes the - // occurrence, so a throwing subscriber must not be able to displace the callback's exception. - timedEvents.Add (TimeSpan.FromMilliseconds (1), () => throw expectedException); - timedEvents.Added += (_, _) => throw new NotSupportedException ("Added handler failure."); - - Thread.Sleep (5); - - InvalidOperationException actualException = Assert.Throws (timedEvents.RunTimers); - - Assert.Same (expectedException, actualException); - - // A throwing callback is not rescheduled, so the queue drains. - Assert.Empty (timedEvents.Timeouts); - } - // Claude - Opus 5 [Fact] public void RunTimers_Added_Handler_Exception_During_Reschedule_Leaves_Timeout_Scheduled () From 6a4366171295640ac41b469bb42a4836e3f8c617 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 2 Sep 2026 09:29:55 -0500 Subject: [PATCH 15/42] Document active RemoveTimeout cancellation Align IApplication.RemoveTimeout with the ITimedEvents.Remove contract. A true result can mean cancellation was recorded for an executing callback, which continues running but cannot reschedule. --- Terminal.Gui/App/IApplication.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs index 92dce0bc4e..c195cf3970 100644 --- a/Terminal.Gui/App/IApplication.cs +++ b/Terminal.Gui/App/IApplication.cs @@ -790,11 +790,15 @@ public interface IApplication : IDisposable /// object? AddTimeout (TimeSpan time, Func callback); - /// Removes a previously scheduled timeout. + /// Cancels all timeout occurrences associated with a previously returned token. /// The token returned by . + /// + /// A matching callback that is already executing is not interrupted, but it will not be rescheduled if it returns + /// . + /// /// - /// if the timeout is successfully removed; otherwise, . - /// This method also returns if the timeout is not found. + /// if at least one queued occurrence was removed or a cancellation request was recorded for + /// at least one active occurrence; otherwise, . /// bool RemoveTimeout (object token); From 986c7db33a430ab9afe2fe2640f2c2f9f55d45ec Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 2 Sep 2026 10:04:57 -0500 Subject: [PATCH 16/42] Keep timer extension points outside queue lock Read a repeating Timeout Span and the current time before reentering the timeout queue lock, then revalidate the occurrence generation and StopAll epoch before enqueueing. Preserve active-state cleanup if either extension point throws. Apply the same lock boundary to GetTimeout and all time-provider reads. Add deterministic regressions for blocking and throwing Span overrides plus blocking time providers. --- Terminal.Gui/App/Timeout/TimedEvents.cs | 77 ++++- .../Application/Timeouts/TimedEventsTests.cs | 276 ++++++++++++++++++ 2 files changed, 341 insertions(+), 12 deletions(-) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 781d430377..11482c5994 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -30,8 +30,9 @@ namespace Terminal.Gui.App; /// /// , , , /// , , , and are -/// safe to call concurrently from any thread. Callbacks are invoked outside the timeout queue lock, so a -/// callback can schedule or cancel timeouts without deadlocking. +/// safe to call concurrently from any thread. Timeout callbacks, access, +/// handlers, and time-provider reads occur outside the timeout queue lock, so user code can +/// schedule or cancel timeouts without deadlocking. /// /// /// returns a snapshot so the queue can be inspected safely while another thread schedules @@ -266,38 +267,45 @@ public bool CheckTimers (out int waitTimeout) return null; } + var found = false; + lock (_timeoutsLockToken) { foreach (Timeout queuedTimeout in _timeouts.Values) { - if (ReferenceEquals (queuedTimeout, timeout)) + if (!ReferenceEquals (queuedTimeout, timeout)) { - return timeout.Span; + continue; } - } - return null; + found = true; + + break; + } } + + return found ? timeout.Span : null; } private void AddTimeout (TimeSpan time, Timeout timeout) { + long timestampTicks = GetTimestampTicks (); long k; lock (_timeoutsLockToken) { - k = AddTimeoutCore (time, timeout); + k = AddTimeoutCore (timestampTicks, time, timeout); } Added?.Invoke (this, new (timeout, k)); } - private long AddTimeoutCore (TimeSpan time, Timeout timeout) + private long AddTimeoutCore (long timestampTicks, TimeSpan time, Timeout timeout) { // Caller must hold _timeoutsLockToken. Debug.Assert (Monitor.IsEntered (_timeoutsLockToken)); - long k = GetTimestampTicks () + time.Ticks; + long k = timestampTicks + time.Ticks; // if user wants to run as soon as possible set timer such that it expires right away (no race conditions) if (time == TimeSpan.Zero) @@ -335,15 +343,15 @@ private long NudgeToUniqueKey (long k) private void RunTimersImpl () { - long passStart; long occurrenceIdCutoff; lock (_timeoutsLockToken) { - passStart = GetTimestampTicks (); occurrenceIdCutoff = _nextTimeoutOccurrenceId; } + long passStart = GetTimestampTicks (); + // Execute only queue occurrences that were due when this pass started. A repeating timeout with a zero or // negative span reschedules as already due, but receives a new occurrence ID and is deferred to a later pass. // Without occurrence identity, it can reuse its freed queue key and starve an already-due peer indefinitely. @@ -422,6 +430,51 @@ private int GetNextDueTimeoutIndex (long passStart, long occurrenceIdCutoff) } private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) + { + if (!repeat || !CanReschedule (occurrence)) + { + CompleteTimeoutCore (occurrence, false, default, 0); + + return; + } + + TimeSpan repeatInterval = default; + long timestampTicks = 0; + var rescheduleInputsRead = false; + + try + { + repeatInterval = occurrence.Timeout.Span; + timestampTicks = GetTimestampTicks (); + rescheduleInputsRead = true; + } + finally + { + // Span and the time provider are user-overridable. Read them without the queue lock, then revalidate the + // occurrence in CompleteTimeoutCore before enqueueing it. The finally also releases active-state bookkeeping + // if either read throws. + CompleteTimeoutCore (occurrence, rescheduleInputsRead, repeatInterval, timestampTicks); + } + } + + private bool CanReschedule (ActiveTimeoutOccurrence occurrence) + { + lock (_timeoutsLockToken) + { + bool found = _activeTimeoutStates.TryGetValue (occurrence.Timeout, out ActiveTimeoutState state); + Debug.Assert (found); + + return found + && occurrence.StopAllEpoch == _stopAllEpoch + && occurrence.RemovalGeneration == state.RemovalGeneration; + } + } + + private void CompleteTimeoutCore ( + ActiveTimeoutOccurrence occurrence, + bool repeat, + TimeSpan repeatInterval, + long timestampTicks) { long k; @@ -459,7 +512,7 @@ private void CompleteTimeout (ActiveTimeoutOccurrence occurrence, bool repeat) return; } - k = AddTimeoutCore (occurrence.Timeout.Span, occurrence.Timeout); + k = AddTimeoutCore (timestampTicks, repeatInterval, occurrence.Timeout); } Added?.Invoke (this, new (occurrence.Timeout, k)); diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index ae33db497a..a30d8f1ebe 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -7,6 +7,233 @@ namespace ApplicationTests.TimedEventTests; [Collection("Application Timer Tests")] public class TimedEventsTests { + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_Repeating_Timeout_Span_Does_Not_Block_Remove () + { + TimedEvents timedEvents = new (new VirtualTimeProvider ()); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim spanGetStarted = new (); + using ManualResetEventSlim releaseSpanGet = new (); + BlockingSpanTimeout timeout = new () { Span = TimeSpan.Zero, Callback = () => true }; + timedEvents.Add (timeout); + timeout.BlockNextGet (spanGetStarted, releaseSpanGet, cancellationToken); + Task runnerTask = RunOnDedicatedThread (timedEvents.RunTimers); + Task? removeTask = null; + var spanGetStartedBeforeTimeout = false; + var removeReturnedBeforeSpanGet = false; + + try + { + spanGetStartedBeforeTimeout = spanGetStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (spanGetStartedBeforeTimeout) + { + removeTask = RunOnDedicatedThread (() => timedEvents.Remove (timeout)); + removeReturnedBeforeSpanGet = await CompletesWithinAsync (removeTask, cancellationToken); + } + } + finally + { + releaseSpanGet.Set (); + } + + await runnerTask; + bool removed = removeTask is not null && await removeTask; + + Assert.True (spanGetStartedBeforeTimeout, "The repeat interval should be read after the callback returns true."); + Assert.True (removeReturnedBeforeSpanGet, "Remove should not wait for an overridden Span getter."); + Assert.True (removed); + Assert.True (timeout.BlockedGetReleased, "The Span getter should be released before its wait times out."); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void RunTimers_Throwing_Repeat_Span_Cleans_Active_Occurrence () + { + TimedEvents timedEvents = new (new VirtualTimeProvider ()); + InvalidOperationException expectedException = new ("Expected Span failure."); + BlockingSpanTimeout timeout = new () { Span = TimeSpan.Zero, Callback = () => true }; + timedEvents.Add (timeout); + timeout.ThrowOnNextGet (expectedException); + + InvalidOperationException actualException = Assert.Throws (timedEvents.RunTimers); + + Assert.Same (expectedException, actualException); + Assert.False (timedEvents.Remove (timeout)); + Assert.Empty (timedEvents.Timeouts); + + timeout.Callback = () => false; + timedEvents.Add (timeout); + timedEvents.RunTimers (); + + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public async Task GetTimeout_Overridden_Span_Does_Not_Block_Add () + { + TimedEvents timedEvents = new (new VirtualTimeProvider ()); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim spanGetStarted = new (); + using ManualResetEventSlim releaseSpanGet = new (); + BlockingSpanTimeout timeout = new () { Span = TimeSpan.FromHours (1), Callback = () => false }; + timedEvents.Add (timeout); + timeout.BlockNextGet (spanGetStarted, releaseSpanGet, cancellationToken); + Task getTimeoutTask = RunOnDedicatedThread (() => timedEvents.GetTimeout (timeout)); + Task? addTask = null; + var spanGetStartedBeforeTimeout = false; + var addReturnedBeforeSpanGet = false; + + try + { + spanGetStartedBeforeTimeout = spanGetStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (spanGetStartedBeforeTimeout) + { + addTask = RunOnDedicatedThread (() => timedEvents.Add (TimeSpan.FromHours (2), () => false)); + addReturnedBeforeSpanGet = await CompletesWithinAsync (addTask, cancellationToken); + } + } + finally + { + releaseSpanGet.Set (); + } + + TimeSpan? span = await getTimeoutTask; + + if (addTask is not null) + { + await addTask; + } + + Assert.True (spanGetStartedBeforeTimeout, "GetTimeout should read Span for the queued timeout."); + Assert.True (addReturnedBeforeSpanGet, "Add should not wait for an overridden Span getter."); + Assert.True (timeout.BlockedGetReleased, "The Span getter should be released before its wait times out."); + Assert.Equal (TimeSpan.FromHours (1), span); + Assert.Equal (2, timedEvents.Timeouts.Count); + timedEvents.StopAll (); + } + + // CoPilot - GPT-5 + [Fact] + public async Task Add_TimeProvider_Does_Not_Block_Remove () + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim timeReadStarted = new (); + using ManualResetEventSlim releaseTimeRead = new (); + var timeReadCount = 0; + var timeReadReleased = false; + + DateTime GetCurrentTime () + { + if (Interlocked.Increment (ref timeReadCount) == 2) + { + timeReadStarted.Set (); + timeReadReleased = releaseTimeRead.Wait (TimeSpan.FromSeconds (5), cancellationToken); + } + + return DateTime.UnixEpoch; + } + + TimedEvents timedEvents = new (new FuncTimeProvider (GetCurrentTime)); + object firstTimeout = timedEvents.Add (TimeSpan.FromHours (1), () => false); + Task addTask = RunOnDedicatedThread ( + () => timedEvents.Add (TimeSpan.FromHours (2), () => false)); + Task? removeTask = null; + var timeReadStartedBeforeTimeout = false; + var removeReturnedBeforeTimeRead = false; + + try + { + timeReadStartedBeforeTimeout = timeReadStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (timeReadStartedBeforeTimeout) + { + removeTask = RunOnDedicatedThread (() => timedEvents.Remove (firstTimeout)); + removeReturnedBeforeTimeRead = await CompletesWithinAsync (removeTask, cancellationToken); + } + } + finally + { + releaseTimeRead.Set (); + } + + await addTask; + bool removed = removeTask is not null && await removeTask; + + Assert.True (timeReadStartedBeforeTimeout, "The second Add should read the time provider."); + Assert.True (removeReturnedBeforeTimeRead, "Remove should not wait for a time-provider read."); + Assert.True (removed); + Assert.True (timeReadReleased, "The time-provider read should be released before its wait times out."); + Assert.Single (timedEvents.Timeouts); + timedEvents.StopAll (); + } + + // CoPilot - GPT-5 + [Fact] + public async Task RunTimers_TimeProvider_Does_Not_Block_Remove () + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim timeReadStarted = new (); + using ManualResetEventSlim releaseTimeRead = new (); + var timeReadCount = 0; + var timeReadReleased = false; + var callbackCount = 0; + + DateTime GetCurrentTime () + { + if (Interlocked.Increment (ref timeReadCount) == 2) + { + timeReadStarted.Set (); + timeReadReleased = releaseTimeRead.Wait (TimeSpan.FromSeconds (5), cancellationToken); + } + + return DateTime.UnixEpoch; + } + + TimedEvents timedEvents = new (new FuncTimeProvider (GetCurrentTime)); + object timeout = timedEvents.Add ( + TimeSpan.Zero, + () => + { + callbackCount++; + + return false; + }); + Task runnerTask = RunOnDedicatedThread (timedEvents.RunTimers); + Task? removeTask = null; + var timeReadStartedBeforeTimeout = false; + var removeReturnedBeforeTimeRead = false; + + try + { + timeReadStartedBeforeTimeout = timeReadStarted.Wait (TimeSpan.FromSeconds (5), cancellationToken); + + if (timeReadStartedBeforeTimeout) + { + removeTask = RunOnDedicatedThread (() => timedEvents.Remove (timeout)); + removeReturnedBeforeTimeRead = await CompletesWithinAsync (removeTask, cancellationToken); + } + } + finally + { + releaseTimeRead.Set (); + } + + await runnerTask; + bool removed = removeTask is not null && await removeTask; + + Assert.True (timeReadStartedBeforeTimeout, "RunTimers should read the time provider."); + Assert.True (removeReturnedBeforeTimeRead, "Remove should not wait for a time-provider read."); + Assert.True (removed); + Assert.True (timeReadReleased, "The time-provider read should be released before its wait times out."); + Assert.Equal (0, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + // CoPilot - GPT-5 [Fact] public async Task RunTimers_Callback_Does_Not_Block_Add_From_Other_Thread () @@ -1430,4 +1657,53 @@ private static Task RunOnDedicatedThread (Func action TaskScheduler.Default); private static bool WaitForCleanup (ManualResetEventSlim waitHandle) => waitHandle.Wait (TimeSpan.FromSeconds (5)); + + private sealed class BlockingSpanTimeout : Terminal.Gui.App.Timeout + { + private CancellationToken _cancellationToken; + private ManualResetEventSlim? _getStarted; + private Exception? _nextException; + private ManualResetEventSlim? _releaseGet; + private TimeSpan _span; + + public bool BlockedGetReleased { get; private set; } + + public override TimeSpan Span + { + get + { + Exception? exception = Interlocked.Exchange (ref _nextException, null); + + if (exception is not null) + { + throw exception; + } + + ManualResetEventSlim? getStarted = Interlocked.Exchange (ref _getStarted, null); + + if (getStarted is null) + { + return _span; + } + + getStarted.Set (); + BlockedGetReleased = _releaseGet!.Wait (TimeSpan.FromSeconds (5), _cancellationToken); + + return _span; + } + set => _span = value; + } + + public void BlockNextGet ( + ManualResetEventSlim getStarted, + ManualResetEventSlim releaseGet, + CancellationToken cancellationToken) + { + _releaseGet = releaseGet; + _cancellationToken = cancellationToken; + Interlocked.Exchange (ref _getStarted, getStarted); + } + + public void ThrowOnNextGet (Exception exception) { Interlocked.Exchange (ref _nextException, exception); } + } } From 3f969104de4508414734f7c28ab8bf477e3c181b Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 2 Sep 2026 13:24:44 -0500 Subject: [PATCH 17/42] Test cross-thread invoke during nested timer run --- .../Timeouts/NestedRunTimeoutTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/NestedRunTimeoutTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/NestedRunTimeoutTests.cs index ac493ab74f..c74a3d4b03 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/NestedRunTimeoutTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/NestedRunTimeoutTests.cs @@ -8,6 +8,85 @@ namespace ApplicationTests.Timeout; [Collection("Application Tests")] public class NestedRunTimeoutTests (ITestOutputHelper output) { + // CoPilot - GPT-5 + [Fact] + public async Task CrossThread_Invoke_Executes_During_Nested_Run_Started_From_Timeout () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using ManualResetEventSlim nestedRunStarted = new (); + Window mainWindow = new () { Title = "Main Window" }; + Dialog dialog = new () { Title = "Nested Dialog", Buttons = [new () { Text = "Ok" }] }; + Task? invokeTask = null; + Exception? invokeError = null; + var invokedDuringNestedRun = false; + var nestedRunCompleted = false; + var safetyTimeoutFired = false; + + object safetyTimeout = app.AddTimeout ( + TimeSpan.FromSeconds (5), + () => + { + safetyTimeoutFired = true; + app.RequestStop (dialog); + + return false; + })!; + + app.AddTimeout ( + TimeSpan.Zero, + () => + { + invokeTask = Task.Factory.StartNew ( + () => + { + try + { + nestedRunStarted.Wait (cancellationToken); + app.Invoke ( + () => + { + invokedDuringNestedRun = app.TopRunnableView == dialog; + app.RequestStop (dialog); + }); + } + catch (Exception ex) + { + invokeError = ex; + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + nestedRunStarted.Set (); + app.Run (dialog); + nestedRunCompleted = true; + app.RemoveTimeout (safetyTimeout); + app.RequestStop (mainWindow); + + return false; + }); + + try + { + app.Run (mainWindow); + Assert.NotNull (invokeTask); + await invokeTask.WaitAsync (TimeSpan.FromSeconds (5), cancellationToken); + } + finally + { + dialog.Dispose (); + mainWindow.Dispose (); + } + + Assert.Null (invokeError); + Assert.False (safetyTimeoutFired, "The cross-thread Invoke should close the dialog before the safety timeout."); + Assert.True (invokedDuringNestedRun, "Invoke should execute while the nested dialog is the top runnable."); + Assert.True (nestedRunCompleted); + } + [Fact] public void Multiple_Timeouts_Fire_In_Correct_Order_With_Nested_Run () { From ddb2be4fe03610a8f1d31609481b4746cf7bf9e8 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 2 Sep 2026 16:49:05 -0500 Subject: [PATCH 18/42] Skip time reads for empty timer passes --- Terminal.Gui/App/Timeout/TimedEvents.cs | 5 +++++ .../Application/Timeouts/TimedEventsTests.cs | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Terminal.Gui/App/Timeout/TimedEvents.cs b/Terminal.Gui/App/Timeout/TimedEvents.cs index 11482c5994..9dd48b2491 100644 --- a/Terminal.Gui/App/Timeout/TimedEvents.cs +++ b/Terminal.Gui/App/Timeout/TimedEvents.cs @@ -347,6 +347,11 @@ private void RunTimersImpl () lock (_timeoutsLockToken) { + if (_timeouts.Count == 0) + { + return; + } + occurrenceIdCutoff = _nextTimeoutOccurrenceId; } diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index a30d8f1ebe..8be533b0a5 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -234,6 +234,26 @@ DateTime GetCurrentTime () Assert.Empty (timedEvents.Timeouts); } + // CoPilot - GPT-5 + [Fact] + public void RunTimers_Empty_Queue_Does_Not_Read_TimeProvider () + { + var timeReadCount = 0; + + DateTime GetCurrentTime () + { + timeReadCount++; + + return DateTime.UnixEpoch; + } + + TimedEvents timedEvents = new (new FuncTimeProvider (GetCurrentTime)); + + timedEvents.RunTimers (); + + Assert.Equal (0, timeReadCount); + } + // CoPilot - GPT-5 [Fact] public async Task RunTimers_Callback_Does_Not_Block_Add_From_Other_Thread () From 8de9e59f78083efde6335495ae478b106a1fd92b Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 2 Sep 2026 18:05:13 -0500 Subject: [PATCH 19/42] Prevent timer regressions from hanging tests --- .../Application/Timeouts/TimedEventsTests.cs | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 8be533b0a5..ec9a8c41bc 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -1503,13 +1503,14 @@ public async Task RunTimers_Defers_Repeating_Zero_Timeout_Until_Next_Pass () { TimedEvents timedEvents = new (new VirtualTimeProvider ()); var callbackCount = 0; + var stopRepeating = 0; object token = timedEvents.Add ( TimeSpan.Zero, () => { Interlocked.Increment (ref callbackCount); - return true; + return Volatile.Read (ref stopRepeating) == 0; }); Task runner = RunOnDedicatedThread (timedEvents.RunTimers); Task completedTask = await Task.WhenAny ( @@ -1526,8 +1527,9 @@ public async Task RunTimers_Defers_Repeating_Zero_Timeout_Until_Next_Pass () countAfterNextPass = Volatile.Read (ref callbackCount); } - timedEvents.Remove (token); + Volatile.Write (ref stopRepeating, 1); await runner.WaitAsync (TimeSpan.FromSeconds (5), TestContext.Current.CancellationToken); + timedEvents.Remove (token); Assert.True (returned, "RunTimers should return after the callbacks that were due when the pass started."); Assert.Equal (1, countAfterPass); @@ -1537,35 +1539,47 @@ public async Task RunTimers_Defers_Repeating_Zero_Timeout_Until_Next_Pass () // CoPilot - GPT-5 [Fact] - public void RunTimers_Repeating_Zero_Timeout_Does_Not_Starve_Due_Peer () + public async Task RunTimers_Repeating_Zero_Timeout_Does_Not_Starve_Due_Peer () { TimedEvents timedEvents = new (new VirtualTimeProvider ()); var repeatingCallbackCount = 0; var peerCallbackCount = 0; + var stopRepeating = 0; object repeatingToken = timedEvents.Add ( TimeSpan.Zero, () => { - repeatingCallbackCount++; + Interlocked.Increment (ref repeatingCallbackCount); - return true; + return Volatile.Read (ref stopRepeating) == 0; }); timedEvents.Add ( TimeSpan.Zero, () => { - peerCallbackCount++; + Interlocked.Increment (ref peerCallbackCount); return false; }); - timedEvents.RunTimers (); - - Assert.Equal (1, repeatingCallbackCount); - Assert.Equal (1, peerCallbackCount); - Assert.Same (repeatingToken, Assert.Single (timedEvents.Timeouts).Value); + Task runner = RunOnDedicatedThread (timedEvents.RunTimers); + Task completedTask = await Task.WhenAny ( + runner, + Task.Delay (TimeSpan.FromSeconds (1), TestContext.Current.CancellationToken)); + var returned = ReferenceEquals (completedTask, runner); + int repeatingCountAfterPass = Volatile.Read (ref repeatingCallbackCount); + int peerCountAfterPass = Volatile.Read (ref peerCallbackCount); + SortedList queuedAfterPass = returned ? timedEvents.Timeouts : []; + Terminal.Gui.App.Timeout? queuedTimeoutAfterPass = queuedAfterPass.Count == 1 ? queuedAfterPass.Values [0] : null; + Volatile.Write (ref stopRepeating, 1); + await runner.WaitAsync (TimeSpan.FromSeconds (5), TestContext.Current.CancellationToken); timedEvents.Remove (repeatingToken); + + Assert.True (returned, "RunTimers should return after the callbacks that were due when the pass started."); + Assert.Equal (1, repeatingCountAfterPass); + Assert.Equal (1, peerCountAfterPass); + Assert.Same (repeatingToken, queuedTimeoutAfterPass); } [Fact] From a093d36e49b40aeb54f693dfe412c27bf64fcd2e Mon Sep 17 00:00:00 2001 From: GH CLI COREK Date: Fri, 4 Sep 2026 17:18:29 +0200 Subject: [PATCH 20/42] Fix nested paste focus after application begin --- Terminal.Gui/Views/Runnable/Runnable.cs | 2 +- .../ViewBase/ViewPasteTests.cs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/Runnable/Runnable.cs b/Terminal.Gui/Views/Runnable/Runnable.cs index 2e899902f1..d0fcf272e2 100644 --- a/Terminal.Gui/Views/Runnable/Runnable.cs +++ b/Terminal.Gui/Views/Runnable/Runnable.cs @@ -177,7 +177,7 @@ public void RaiseIsModalChangedEvent (bool newIsModal) // Set focus to self if becoming modal SetFocus (); - App?.Navigation?.SetFocused (Focused); + App?.Navigation?.SetFocused (MostFocused); } else { diff --git a/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs b/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs index 6981f2493d..2f9277ed8c 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs @@ -244,6 +244,28 @@ public void EmptyPayload_DoesNotRaisePastingOrPasted () Assert.False (anyFired); } + // CoPilot - GPT-5 + [Fact] + public void ApplicationPaste_BeginWithNestedFocusableView_DispatchesToMostFocusedView () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + View wrapper = new () { CanFocus = true }; + TextField field = new () { Text = string.Empty }; + wrapper.Add (field); + runnable.Add (wrapper); + + app.Begin (runnable); + + Assert.Same (field, runnable.MostFocused); + Assert.Same (field, app.Navigation!.GetFocused ()); + + bool handled = app.RaisePasteEvent ("hello"); + + Assert.True (handled); + Assert.Equal ("hello", field.Text); + } + [Fact] public void ApplicationPaste_Handled_DoesNotDispatchToFocusedView () { From 663f9622065868ece18fd5e6880b937cdd8e5dfe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:44:56 +0000 Subject: [PATCH 21/42] Bump Microsoft.Extensions.Configuration and Microsoft.Extensions.Configuration.Binder Bumps Microsoft.Extensions.Configuration from 10.0.11 to 10.0.12 Bumps Microsoft.Extensions.Configuration.Binder from 10.0.11 to 10.0.12 --- updated-dependencies: - dependency-name: Microsoft.Extensions.Configuration dependency-version: 10.0.12 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Microsoft.Extensions.Configuration.Binder dependency-version: 10.0.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b329997c77..37bb7d2124 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,8 +14,8 @@ - - + + From 3d551b227066b9f341bbfd48619ea698296f1a50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:45:20 +0000 Subject: [PATCH 22/42] Bump Microsoft.Extensions.Options from 10.0.11 to 10.0.12 --- updated-dependencies: - dependency-name: Microsoft.Extensions.Options dependency-version: 10.0.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b329997c77..c0a4d80a8d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ - + From 5404c03812b3c70ade4fd646195403671d785b76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:46:10 +0000 Subject: [PATCH 23/42] Bump Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.0 --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b329997c77..83e917618e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + From baaed25185dcf5c6d3b65d0cbcdd82ef9ed0897f Mon Sep 17 00:00:00 2001 From: BDisp Date: Sun, 13 Sep 2026 12:59:55 +0100 Subject: [PATCH 24/42] Fixes #5675. MenuBar overrides DefaultBorderStyle --- .../Scenarios/CustomMenuBarBorderStyle.cs | 35 +++++++++++++++++++ Terminal.Gui/Views/Menu/MenuBar.cs | 6 ---- 2 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs diff --git a/Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs b/Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs new file mode 100644 index 0000000000..4677a3bf3c --- /dev/null +++ b/Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs @@ -0,0 +1,35 @@ +#nullable enable + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("CustomMenuBarBorderStyle", "Custom MenuBar BorderStyle")] +[ScenarioCategory ("Controls")] +public sealed class CustomMenuBarBorderStyle : Scenario +{ + public override void Main () + { + TuiConfigurationBuilder configurationBuilder = new ("test"); + string previousTheme = ThemeManager.GetCurrentThemeName (); + + using IApplication app = Application.Create (); + configurationBuilder.ThemeManager.SwitchTheme ("test"); + + app.Init (); + + using Window window = new (); + + MenuBar menu = new ([ + new MenuBarItem ("_Test", + [ + new MenuItem (commandText: "_default", action: () => configurationBuilder.ThemeManager.SwitchTheme ("default")), + new MenuItem (commandText: "t_est", action: () => configurationBuilder.ThemeManager.SwitchTheme ("test")), + ]), + ]); + + window.Add (menu); + + app.Run (window); + + configurationBuilder.ThemeManager.SwitchTheme (previousTheme); + } +} diff --git a/Terminal.Gui/Views/Menu/MenuBar.cs b/Terminal.Gui/Views/Menu/MenuBar.cs index 4aa7474319..9a9a689164 100644 --- a/Terminal.Gui/Views/Menu/MenuBar.cs +++ b/Terminal.Gui/Views/Menu/MenuBar.cs @@ -462,12 +462,6 @@ public override void EndInit () { base.EndInit (); - if (Border is { }) - { - Border.Thickness = new Thickness (0); - Border.LineStyle = LineStyle.None; - } - // TODO: This needs to be done whenever a menuitem in any MenuBarItem changes foreach (View sv in SubViews) { From 71db96d472fea254cf6c0cc943c47245e9373786 Mon Sep 17 00:00:00 2001 From: BDisp Date: Sun, 13 Sep 2026 13:15:00 +0100 Subject: [PATCH 25/42] Delete Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs Deleted unnecessary file --- .../Scenarios/CustomMenuBarBorderStyle.cs | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs diff --git a/Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs b/Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs deleted file mode 100644 index 4677a3bf3c..0000000000 --- a/Examples/UICatalog/Scenarios/CustomMenuBarBorderStyle.cs +++ /dev/null @@ -1,35 +0,0 @@ -#nullable enable - -namespace UICatalog.Scenarios; - -[ScenarioMetadata ("CustomMenuBarBorderStyle", "Custom MenuBar BorderStyle")] -[ScenarioCategory ("Controls")] -public sealed class CustomMenuBarBorderStyle : Scenario -{ - public override void Main () - { - TuiConfigurationBuilder configurationBuilder = new ("test"); - string previousTheme = ThemeManager.GetCurrentThemeName (); - - using IApplication app = Application.Create (); - configurationBuilder.ThemeManager.SwitchTheme ("test"); - - app.Init (); - - using Window window = new (); - - MenuBar menu = new ([ - new MenuBarItem ("_Test", - [ - new MenuItem (commandText: "_default", action: () => configurationBuilder.ThemeManager.SwitchTheme ("default")), - new MenuItem (commandText: "t_est", action: () => configurationBuilder.ThemeManager.SwitchTheme ("test")), - ]), - ]); - - window.Add (menu); - - app.Run (window); - - configurationBuilder.ThemeManager.SwitchTheme (previousTheme); - } -} From 65099602566960394ae4b331d517492401a12a2f Mon Sep 17 00:00:00 2001 From: BDisp Date: Sun, 13 Sep 2026 13:13:47 +0100 Subject: [PATCH 26/42] Add the correct unit test file --- .../Configuration/MenuBorderStyleTests.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Tests/UnitTestsParallelizable/Configuration/MenuBorderStyleTests.cs diff --git a/Tests/UnitTestsParallelizable/Configuration/MenuBorderStyleTests.cs b/Tests/UnitTestsParallelizable/Configuration/MenuBorderStyleTests.cs new file mode 100644 index 0000000000..2740669d49 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Configuration/MenuBorderStyleTests.cs @@ -0,0 +1,42 @@ +// Codex - GPT-5 +using Terminal.Gui.Configuration; + +namespace ConfigurationTests; + +[Collection ("StaticSettingsTests")] +public class MenuBorderStyleTests +{ + [Fact] + public void MenuBar_EndInit_PreservesThemeDefaultBorderStyle () + { + using SettingsFacadeSnapshot snapshot = new (); + string previousTheme = ThemeManager.GetCurrentThemeName (); + TuiConfigurationBuilder tuiBuilder = new (); + + try + { + tuiBuilder.RuntimeConfig = """ + { + "Theme": "Test", + "Themes": { + "Test": { + "MenuBar": { + "DefaultBorderStyle": "Rounded" + } + } + } + } + """; + tuiBuilder.ApplyToStaticFacades (); + + using MenuBar menuBar = new (); + menuBar.EndInit (); + + Assert.Equal (LineStyle.Rounded, menuBar.BorderStyle); + } + finally + { + tuiBuilder.ThemeManager.SwitchTheme (previousTheme); + } + } +} From 555858b4191736ae3074fbda7ee90f27dcd45e97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:48:11 +0000 Subject: [PATCH 27/42] Bump xunit.v3 from 4.0.0 to 4.0.1 --- updated-dependencies: - dependency-name: xunit.v3 dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b329997c77..bdd9b903dd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,7 @@ - + From f3658ca6398a946b7b82ccfd73d6c3ff16f110d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:45:01 +0000 Subject: [PATCH 28/42] Bump Microsoft.Extensions.Configuration.Json from 10.0.11 to 10.0.12 --- updated-dependencies: - dependency-name: Microsoft.Extensions.Configuration.Json dependency-version: 10.0.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dc450c05c9..a3d1c25658 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - + From 3cc1ac8af145475e98f2cd103df251e53b1c36bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:28:20 -0600 Subject: [PATCH 29/42] Bump Microsoft.NET.Test.Sdk from 18.10.0 to 18.10.1 (#5682) --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a3d1c25658..704d8839d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + From 22ef38a6da70c00dd4e6ec16f7cb7f72dd07cfc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:25:12 -0600 Subject: [PATCH 30/42] Bump Markdig from 1.3.2 to 1.4.0 (#5685) --- updated-dependencies: - dependency-name: Markdig dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 704d8839d4..5e9fd72bfd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,7 +37,7 @@ - + From 40215daab644b793b9818adaacef0a790560079c Mon Sep 17 00:00:00 2001 From: YourRobotOverlord <47068588+YourRobotOverlord@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:27:34 -0600 Subject: [PATCH 31/42] fix(listview): sync navigator on source changes (#5684) Refs #5683 --- Terminal.Gui/Views/ListView/ListView.cs | 1 + .../Views/ListViewTests.cs | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/Terminal.Gui/Views/ListView/ListView.cs b/Terminal.Gui/Views/ListView/ListView.cs index d518433369..03b63789a5 100644 --- a/Terminal.Gui/Views/ListView/ListView.cs +++ b/Terminal.Gui/Views/ListView/ListView.cs @@ -214,6 +214,7 @@ public IListDataSource? Source private void SourceOnCollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { SetContentSize (new Size (EffectiveMaxItemLength, Source?.Count ?? Viewport.Height)); + KeystrokeNavigator?.Collection = Source?.ToList (); if (Source is { Count: > 0 } && SelectedItem.HasValue && SelectedItem > Source.Count - 1) { diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index b35445e1e5..c083ad490c 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -638,6 +638,37 @@ public void KeystrokeNavigator_ReassignAfterSource_SyncsCollection () Assert.Equal (5, lv.SelectedItem); // "candle" } + // Codex - GPT-5.6 + [Fact] + public void KeystrokeNavigator_CollectionChanged_SnapshotSource_SyncsAfterRemoval () + { + ObservableCollection source = ["Alpha", "Bravo", "Zulu"]; + using ListView listView = new () { Source = new SnapshotListDataSource (source), SelectedItem = 0 }; + + source.RemoveAt (0); + source.RemoveAt (0); + + Assert.Single (listView.KeystrokeNavigator!.Collection); + Assert.Equal ("Zulu", listView.KeystrokeNavigator.Collection [0]); + Assert.True (listView.NewKeyDownEvent (Key.Z)); + Assert.Equal (0, listView.SelectedItem); + } + + // Codex - GPT-5.6 + [Fact] + public void KeystrokeNavigator_CollectionChanged_SnapshotSource_SyncsAfterMove () + { + ObservableCollection source = ["Alpha", "Bravo", "Zulu"]; + using ListView listView = new () { Source = new SnapshotListDataSource (source), SelectedItem = 0 }; + + source.Move (2, 0); + + Assert.Equal ("Zulu", listView.KeystrokeNavigator!.Collection [0]); + Assert.True (listView.NewKeyDownEvent (Key.Z)); + Assert.Equal (0, listView.SelectedItem); + Assert.Equal ("Zulu", listView.Source!.ToList () [listView.SelectedItem.Value]); + } + [Fact] public void ListViewCollectionNavigatorMatcher_OverrideMatching () { @@ -901,6 +932,18 @@ public void Render (ListView container, bool selected, int item, int col, int li public void Dispose () => throw new NotImplementedException (); } + private sealed class SnapshotListDataSource : ListWrapper, IListDataSource + { + private readonly ObservableCollection _source; + + public SnapshotListDataSource (ObservableCollection source) : base (source) + { + _source = source; + } + + IList IListDataSource.ToList () => _source.ToList (); + } + [Fact] public void KeyBindings_Command () { From ff43d040fc16edc0a58ae807e6ced13f6f7b8dfa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:29:24 -0600 Subject: [PATCH 32/42] Bump Xunit.Combinatorial from 2.1.41 to 2.1.43 (#5686) --- updated-dependencies: - dependency-name: Xunit.Combinatorial dependency-version: 2.1.43 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5e9fd72bfd..cca6721b9d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,7 +27,7 @@ - + From 33c72a12cdef225dc9290c85e0b928acf6e19fc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:30:00 -0600 Subject: [PATCH 33/42] Bump Moq from 4.20.72 to 4.21.0 (#5688) --- updated-dependencies: - dependency-name: Moq dependency-version: 4.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index cca6721b9d..5eed0b384a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -49,7 +49,7 @@ - + From 12489dfe92a5fd6514da37c4d1ca3c80a070e28c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:30:15 -0600 Subject: [PATCH 34/42] Bump TestableIO.System.IO.Abstractions.TestingHelpers from 22.2.0 to 22.3.0 (#5693) --- updated-dependencies: - dependency-name: TestableIO.System.IO.Abstractions.TestingHelpers dependency-version: 22.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5eed0b384a..9388129c61 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,7 +24,7 @@ - + From 02236a1041fc6bc900caba786821817b2e5f300c Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 30 Sep 2026 00:09:06 -0500 Subject: [PATCH 35/42] Fixes #5695. Harden UTF-8 output rendering --- .github/workflows/perf-gate.yml | 48 ++- Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 195 ++++----- .../Drivers/DotNetDriver/NetOutput.cs | 49 ++- Terminal.Gui/Drivers/Output/IOutput.cs | 4 +- Terminal.Gui/Drivers/Output/OutputBase.cs | 233 ++++++++--- Terminal.Gui/Drivers/Output/Utf8Buffer.cs | 120 +++--- .../Drivers/UnixHelpers/UnixIOHelper.cs | 42 +- .../Drivers/WindowsDriver/WindowsOutput.cs | 195 +++------ .../OutputBuffer/OutputWriteBenchmark.cs | 43 +- .../OutputBuffer/UnixRawOutputBenchmark.cs | 114 ++++++ .../Output/OutputPathRegressionTests.cs | 374 ++++++++++++++++++ .../Drivers/Output/Utf8BufferTests.cs | 40 ++ .../Drivers/UnixIOHelperTests.cs | 90 ++++- 13 files changed, 1174 insertions(+), 373 deletions(-) create mode 100644 Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs create mode 100644 Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml index 5fee72ef2b..e4d9bb76d5 100644 --- a/.github/workflows/perf-gate.yml +++ b/.github/workflows/perf-gate.yml @@ -97,7 +97,7 @@ jobs: --configuration Release \ --no-build \ -- \ - --filter '*Scroll*' '*Config*' '*Scheme*' '*Theme*' \ + --filter '*Scroll*' '*Config*' '*Scheme*' '*Theme*' '*OutputWriteBenchmark*' '*UnixRawOutputBenchmark*' \ --job short \ --exporters json \ --artifacts ./BenchmarkResults @@ -210,3 +210,49 @@ jobs: path: BenchmarkResults/ if-no-files-found: ignore retention-days: 30 + + output-benchmark-pr: + name: Output Benchmarks (Linux, ShortRun) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + timeout-minutes: 20 + env: + DisableRealDriverIO: "1" + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.x + dotnet-quality: 'ga' + + - name: Restore dependencies + run: dotnet restore + + - name: Run output benchmarks + run: | + dotnet run \ + --project Tests/Benchmarks \ + --configuration Release \ + -p:GeneratePackageOnBuild=false \ + --no-restore \ + -- \ + --filter '*OutputWriteBenchmark*' '*UnixRawOutputBenchmark*' \ + --job short \ + --inProcess \ + --exporters json \ + --artifacts ./BenchmarkResults + + - name: Upload output benchmark results + if: always() + uses: actions/upload-artifact@v7 + with: + name: output-benchmark-results-${{ github.sha }} + path: BenchmarkResults/ + if-no-files-found: ignore + retention-days: 14 diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 16acfbb76f..13df30f31b 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -36,8 +36,11 @@ namespace Terminal.Gui.Drivers; /// public class AnsiOutput : OutputBase, IOutput { + internal delegate bool UnixWriter (ReadOnlySpan output); + // Tracks which underlying platform APIs are in use private readonly AnsiPlatform _platform; + private readonly UnixWriter? _unixWriter; private Size _consoleSize; private IOutputBuffer? _lastBuffer; @@ -172,6 +175,13 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) } } + // Exercises UnixRaw framing without opening a terminal or changing process-wide stdout. + internal AnsiOutput (UnixWriter unixWriter) : this () + { + _platform = AnsiPlatform.UnixRaw; + _unixWriter = unixWriter; + } + /// public void Suspend () => UnixTerminalHelper.Suspend (this); @@ -212,74 +222,30 @@ public Size GetSize () /// protected override void Write (StringBuilder output) { - // In batch mode, route through the byte-span path to merge with deferred cursor moves. - if (_batchMode && _pendingCursorMoves.Length > 0) + if (_platform == AnsiPlatform.Degraded && !_batchMode) { - byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ()); - Write (utf8); - + base.Write (output); return; } - base.Write (output); - - try - { - switch (_platform) - { - case AnsiPlatform.WindowsVT: - _windowsVTOutput!.Write (output); - - break; - - case AnsiPlatform.UnixRaw: - byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ()); - UnixIOHelper.TryWriteStdout (utf8); - - return; - - case AnsiPlatform.Degraded: - default: - break; - } - } - catch (Exception) - { - // ignore for unit tests - } + Write (Encoding.UTF8.GetBytes (output.ToString ())); } + /// + protected override void WriteEncodedString (string output) => Write (output.AsSpan ()); + /// public void Write (ReadOnlySpan text) { - StringBuilder capturedOutput = new (); - capturedOutput.Append (text); - base.Write (capturedOutput); - - try - { - switch (_platform) - { - case AnsiPlatform.WindowsVT: - _windowsVTOutput!.Write (capturedOutput); - - break; - - case AnsiPlatform.UnixRaw: - byte [] utf8 = Encoding.UTF8.GetBytes (text.ToArray ()); - UnixIOHelper.TryWriteStdout (utf8); - - return; - - case AnsiPlatform.Degraded: - default: - break; - } - } - catch (Exception) + if (_platform == AnsiPlatform.Degraded && !_batchMode) { - // ignore for unit tests + CaptureText (text); + return; } + + byte [] encoded = new byte [Encoding.UTF8.GetByteCount (text)]; + Encoding.UTF8.GetBytes (text, encoded); + Write (encoded); } /// @@ -287,21 +253,32 @@ public override void Write (IOutputBuffer buffer) { _lastBuffer = buffer; _batchMode = true; + _frameBuffer.Clear (); try { base.Write (buffer); - } - finally - { - _batchMode = false; - // Flush any remaining deferred cursor moves if (_pendingCursorMoves.Length > 0) { - Write (_pendingCursorMoves.AsSpan ()); + _frameBuffer.Append (_pendingCursorMoves); _pendingCursorMoves.Clear (); } + + _batchMode = false; + + if (_frameBuffer.Length > 0) + { + Write (_frameBuffer.AsSpan ()); + } + } + finally + { + _batchMode = false; + _pendingCursorMoves.Clear (); + _pendingCursorMoves.TrimExcess (); + _frameBuffer.Clear (); + _frameBuffer.TrimExcess (); } } @@ -314,82 +291,69 @@ protected override void Write (ReadOnlySpan output) { try { - switch (_platform) + if (_batchMode) { - case AnsiPlatform.WindowsVT: - if (_batchMode && _pendingCursorMoves.Length > 0) - { - _pendingCursorMoves.AppendBytes (output); - base.Write (_pendingCursorMoves.AsSpan ()); - _windowsVTOutput!.Write (_pendingCursorMoves.AsSpan ()); - _pendingCursorMoves.Clear (); - } - else - { - base.Write (output); - _windowsVTOutput!.Write (output); - } - - break; + if (_pendingCursorMoves.Length > 0) + { + _frameBuffer.Append (_pendingCursorMoves); + _pendingCursorMoves.Clear (); + } - case AnsiPlatform.UnixRaw: - if (_batchMode && _pendingCursorMoves.Length > 0) - { - _pendingCursorMoves.AppendBytes (output); - base.Write (_pendingCursorMoves.AsSpan ()); - WriteUnix (_pendingCursorMoves.AsSpan ()); - _pendingCursorMoves.Clear (); - } - else + // Send large images directly after earlier frame content. Staging + // them would make the frame buffer retain multi-megabyte capacity. + if (output.Length > Utf8Buffer.RetainedCapacityLimit) + { + if (_frameBuffer.Length > 0) { - base.Write (output); - WriteUnix (output); + WriteDirect (_frameBuffer.AsSpan ()); + _frameBuffer.Clear (); } + WriteDirect (output); return; + } - case AnsiPlatform.Degraded: - default: - base.Write (output); - break; + _frameBuffer.AppendBytes (output); + return; } + + WriteDirect (output); } - catch (Exception) + catch (IOException ex) { - // ignore for unit tests + Logging.Error ($"Error writing ANSI output: {ex.Message}"); + _pendingCursorMoves.Clear (); + throw; } } - private Cursor _currentCursor; - - /// - /// Copies into a reusable backing array and writes to stdout, - /// avoiding allocation on every call. - /// - private void WriteUnix (ReadOnlySpan output) + private void WriteDirect (ReadOnlySpan output) { - int len = output.Length; - - if (len == 0) + switch (_platform) { - return; - } + case AnsiPlatform.WindowsVT: + _windowsVTOutput!.Write (output); + break; - if (_unixWriteBuffer is null || _unixWriteBuffer.Length < len) - { - _unixWriteBuffer = new byte [len]; + case AnsiPlatform.UnixRaw: + if (!(_unixWriter?.Invoke (output) ?? UnixIOHelper.TryWriteStdout (output))) + { + throw new IOException ("Failed to write ANSI output to the terminal."); + } + break; } - output.CopyTo (_unixWriteBuffer); - UnixIOHelper.TryWriteStdout (_unixWriteBuffer, len); + CaptureUtf8 (output); } + private Cursor _currentCursor; + // PERF: Batch mode — defer cursor-move sequences to merge with cell content, reducing WriteFile P/Invoke count private readonly Utf8Buffer _pendingCursorMoves = new (); + private readonly Utf8Buffer _frameBuffer = new (); private bool _batchMode; - // PERF: Reusable backing array for Unix writes — avoids ToArray() allocation on every Write call. - private byte []? _unixWriteBuffer; + internal int RetainedWriteBufferBytes => _pendingCursorMoves.Capacity + _frameBuffer.Capacity; /// public Cursor GetCursor () => _currentCursor; @@ -448,6 +412,7 @@ protected override bool SetCursorPositionImpl (int col, int row) { // PERF: Defer cursor-move to _pendingCursorMoves (Utf8Buffer); merges with subsequent cell content // into a single WriteFile P/Invoke instead of one per cursor move. + _pendingCursorMoves.Clear (); _pendingCursorMoves.AppendAscii (EscSeqUtils.CSI_SetCursorPosition (row + 1 + inlineRowOffset, col + 1)); } else diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index 7a3ba79c8f..3fa2c0e81a 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -9,6 +9,7 @@ namespace Terminal.Gui.Drivers; public class NetOutput : OutputBase, IOutput { private readonly bool _isWinPlatform; + private char []? _utf16DecodeBuffer; /// /// Creates a new instance of the class. @@ -110,10 +111,54 @@ protected override void Write (StringBuilder output) } } + /// + protected override void WriteEncodedString (string output) + { + CaptureText (output.AsSpan ()); + + if (!IsAttachedToTerminal) + { + return; + } + + try + { + Console.Out.Write (output.AsSpan ()); + } + catch (IOException) + { + // Not connected to a terminal; do nothing. + } + } + /// protected override void Write (ReadOnlySpan output) { - base.Write (output); + if (output.IsEmpty) + { + return; + } + + int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + char [] decoded; + + if (maxCharCount > Utf8Buffer.RetainedCapacityLimit) + { + decoded = new char [maxCharCount]; + } + else + { + if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) + { + _utf16DecodeBuffer = new char [maxCharCount]; + } + + decoded = _utf16DecodeBuffer; + } + + int charCount = Encoding.UTF8.GetChars (output, decoded); + ReadOnlySpan text = decoded.AsSpan (0, charCount); + CaptureText (text); if (!IsAttachedToTerminal) { @@ -122,7 +167,7 @@ protected override void Write (ReadOnlySpan output) try { - Console.Out.Write (Encoding.UTF8.GetString (output)); + Console.Out.Write (text); } catch (IOException) { diff --git a/Terminal.Gui/Drivers/Output/IOutput.cs b/Terminal.Gui/Drivers/Output/IOutput.cs index 305690df50..69beb5c2d1 100644 --- a/Terminal.Gui/Drivers/Output/IOutput.cs +++ b/Terminal.Gui/Drivers/Output/IOutput.cs @@ -76,7 +76,9 @@ public interface IOutput : IDisposable /// /// Gets a string containing the ANSI escape sequences and content most recently written - /// to the terminal via + /// to the terminal via . Terminal-attached + /// implementations capture only after + /// is enabled; headless outputs capture by default. /// string GetLastOutput (); diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 5774009442..e827d82e6b 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -10,7 +10,15 @@ public abstract class OutputBase /// /// Initializes a new instance of the class and detects whether the output is attached to a real terminal device. /// - protected OutputBase () => IsAttachedToTerminal = Driver.IsAttachedToTerminal (out _, out _); + protected OutputBase () + { + IsAttachedToTerminal = Driver.IsAttachedToTerminal (out _, out _); + CaptureOutput = !IsAttachedToTerminal; + Action attributeWriter = AppendOrWriteAttribute; + _usesLegacyAttributeWriter = attributeWriter.Method.DeclaringType != typeof (OutputBase); + } + + private readonly bool _usesLegacyAttributeWriter; /// /// Gets whether this output instance is attached to a real terminal device. @@ -76,9 +84,14 @@ public bool IsLegacyConsole private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; + private readonly ConcurrentBag _utf8Buffers = []; + private int _retainedBufferCount; - // Reusable buffer for UTF-8 → UTF-16 decode in Write(ReadOnlySpan). - private char []? _utf16DecodeBuffer; + /// + /// Enables capture for . Headless outputs capture by default + /// for tests; terminal-attached outputs must opt in to avoid decoding every write. + /// + public bool CaptureOutput { get; set; } // Kitty image ids placed on the previous Write, keyed by RasterImageCommand.Id. A single image // can occupy more than one placement when its visible region is fragmented by clipping (e.g. a @@ -118,8 +131,9 @@ public bool IsLegacyConsole /// public virtual void Write (IOutputBuffer buffer) { + using Utf8BufferLease lease = new (this); + Utf8Buffer outputBuffer = lease.Buffer; _clearLastOutputPending = true; - Utf8Buffer outputBuffer = new (); var top = 0; var left = 0; int rows = buffer.Rows; @@ -315,11 +329,47 @@ public virtual void Write (IOutputBuffer buffer) } SetCursorPositionImpl (s.ScreenPosition.X, s.ScreenPosition.Y); - Write (new StringBuilder (s.SixelData)); + WriteEncodedString (s.SixelData); s.IsDirty = false; } } + private readonly struct Utf8BufferLease : IDisposable + { + private readonly OutputBase _owner; + + public Utf8BufferLease (OutputBase owner) + { + _owner = owner; + + if (owner._utf8Buffers.TryTake (out Utf8Buffer? reusable)) + { + Interlocked.Decrement (ref owner._retainedBufferCount); + Buffer = reusable; + } + else + { + Buffer = new (); + } + } + + public Utf8Buffer Buffer { get; } + + public void Dispose () + { + Buffer.Clear (); + Buffer.TrimExcess (); + + if (Interlocked.Increment (ref _owner._retainedBufferCount) == 1) + { + _owner._utf8Buffers.Add (Buffer); + return; + } + + Interlocked.Decrement (ref _owner._retainedBufferCount); + } + } + /// public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString (); @@ -338,8 +388,54 @@ public virtual void Write (IOutputBuffer buffer) /// /// /// - protected virtual void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) + protected virtual void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle) + { + if (attr.Foreground == Color.None) + { + EscSeqUtils.CSI_AppendResetForegroundColor (output); + } + else if (Force16Colors) + { + output.Append (EscSeqUtils.CSI_SetForegroundColor (attr.Foreground.GetAnsiColorCode ())); + } + else + { + EscSeqUtils.CSI_AppendForegroundColorRGB (output, attr.Foreground.R, attr.Foreground.G, attr.Foreground.B); + } + + if (attr.Background == Color.None) + { + EscSeqUtils.CSI_AppendResetBackgroundColor (output); + } + else if (Force16Colors) + { + output.Append (EscSeqUtils.CSI_SetBackgroundColor (attr.Background.GetAnsiColorCode ())); + } + else + { + EscSeqUtils.CSI_AppendBackgroundColorRGB (output, attr.Background.R, attr.Background.G, attr.Background.B); + } + + EscSeqUtils.CSI_AppendTextStyleChange (output, redrawTextStyle, attr.Style); + } + + // Keep the pre-#5650 protected StringBuilder hook for downstream subclasses. Only + // subclasses that override it take the conversion path; built-in renderers use UTF-8. + internal virtual void AppendOrWriteAttributeUtf8 (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) { + if (_usesLegacyAttributeWriter) + { + StringBuilder legacyAttributeBuffer = new (); + AppendOrWriteAttribute (legacyAttributeBuffer, attr, redrawTextStyle); + + foreach (ReadOnlyMemory chunk in legacyAttributeBuffer.GetChunks ()) + { + output.Append (chunk.Span); + } + + return; + } + if (attr.Foreground == Color.None) { output.AppendAscii (EscSeqUtils.CSI); @@ -405,6 +501,11 @@ protected virtual void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr /// protected virtual void Write (StringBuilder output) { + if (!CaptureOutput) + { + return; + } + if (_clearLastOutputPending) { _lastOutputStringBuilder.Clear (); @@ -421,23 +522,43 @@ protected virtual void Write (StringBuilder output) /// UTF-8 encoded bytes to write. protected virtual void Write (ReadOnlySpan output) { - if (_clearLastOutputPending) + // A subclass built before #5650 may only override Write(StringBuilder). + // Route bytes through that virtual sink so rendered cells cannot disappear. + Write (new StringBuilder (Encoding.UTF8.GetString (output))); + } + + /// + /// Sends an already constructed raster or control sequence. The default preserves + /// the pre-#5650 StringBuilder sink for downstream output implementations. + /// + protected virtual void WriteEncodedString (string output) => Write (new StringBuilder (output)); + + /// Captures a UTF-8 write when output capture is enabled. + protected void CaptureUtf8 (ReadOnlySpan output) + { + if (!CaptureOutput) { - _lastOutputStringBuilder.Clear (); - _clearLastOutputPending = false; + return; } - // Decode UTF-8 to UTF-16 for GetLastOutput() using a reusable buffer. - // Allocates only when the buffer must grow; stable-state zero-allocation. - int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + CaptureText (Encoding.UTF8.GetString (output)); + } - if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) + /// Captures decoded output when output capture is enabled. + protected void CaptureText (ReadOnlySpan output) + { + if (!CaptureOutput) + { + return; + } + + if (_clearLastOutputPending) { - _utf16DecodeBuffer = new char [maxCharCount]; + _lastOutputStringBuilder.Clear (); + _clearLastOutputPending = false; } - int charCount = Encoding.UTF8.GetChars (output, _utf16DecodeBuffer); - _lastOutputStringBuilder.Append (_utf16DecodeBuffer, 0, charCount); + _lastOutputStringBuilder.Append (output); } /// @@ -465,9 +586,6 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, var redrawTextStyle = TextStyle.None; string? lastUrl = null; - // Use Utf8Buffer for cell rendering, then decode to StringBuilder at the end - Utf8Buffer utf8Output = new (); - for (int row = startRow; row < endRow; row++) { for (int col = startCol; col < endCol; col++) @@ -484,12 +602,12 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, { if (lastUrl is { }) { - utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); + output.Append (EscSeqUtils.OSC_EndHyperlink ()); } if (!string.IsNullOrEmpty (cellUrl)) { - utf8Output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); } lastUrl = cellUrl; @@ -497,13 +615,13 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, Cell cell = buffer.Contents! [row, col]; int outputWidth = -1; - AppendCellAnsi (cell, utf8Output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); + AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); } // Close any open hyperlink at end of row if (lastUrl is { }) { - utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); + output.Append (EscSeqUtils.OSC_EndHyperlink ()); lastUrl = null; } @@ -514,12 +632,10 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, // ONLCR tty discipline, so a '\n' row break still recreates the screen correctly. if (addNewlines) { - utf8Output.AppendByte ((byte)'\n'); + output.Append ('\n'); } } - // Decode Utf8Buffer back to StringBuilder for ToAnsi() callers - output.Append (Encoding.UTF8.GetString (utf8Output.AsSpan ())); } /// @@ -533,6 +649,36 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, /// The current column, updated for wide characters. /// The current output width, updated for wide characters. protected void AppendCellAnsi (Cell cell, + StringBuilder output, + ref Attribute? lastAttr, + ref TextStyle redrawTextStyle, + int maxCol, + ref int currentCol, + ref int outputWidth) + { + Attribute? attribute = cell.Attribute; + + if (attribute.HasValue && attribute.Value != lastAttr) + { + lastAttr = attribute.Value; + AppendOrWriteAttribute (output, attribute.Value, redrawTextStyle); + redrawTextStyle = attribute.Value.Style; + } + + string grapheme = cell.Grapheme; + output.Append (grapheme); + outputWidth++; + + if (grapheme.GetColumns () <= 1 || currentCol + 1 >= maxCol) + { + return; + } + + currentCol++; + outputWidth++; + } + + private void AppendCellAnsi (Cell cell, Utf8Buffer output, ref Attribute? lastAttr, ref TextStyle redrawTextStyle, @@ -546,7 +692,7 @@ protected void AppendCellAnsi (Cell cell, if (attribute.HasValue && attribute.Value != lastAttr) { lastAttr = attribute.Value; - AppendOrWriteAttribute (output, attribute.Value, redrawTextStyle); + AppendOrWriteAttributeUtf8 (output, attribute.Value, redrawTextStyle); redrawTextStyle = attribute.Value.Style; } @@ -649,9 +795,6 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer var redrawTextStyle = TextStyle.None; string? lastUrl = null; - // Use Utf8Buffer for cell rendering, then decode to StringBuilder at the end - Utf8Buffer utf8Output = new (); - for (int row = startRow; row < endRow; row++) { for (int col = startCol; col < endCol; col++) @@ -660,14 +803,14 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer { if (lastUrl is { }) { - utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); + output.Append (EscSeqUtils.OSC_EndHyperlink ()); lastUrl = null; } continue; } - utf8Output.Append (EscSeqUtils.CSI_SetCursorPosition (row + 1, col + 1)); + output.Append (EscSeqUtils.CSI_SetCursorPosition (row + 1, col + 1)); lastAttr = null; redrawTextStyle = TextStyle.None; @@ -686,12 +829,12 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer { if (lastUrl is { }) { - utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); + output.Append (EscSeqUtils.OSC_EndHyperlink ()); } if (!string.IsNullOrEmpty (cellUrl)) { - utf8Output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); } lastUrl = cellUrl; @@ -699,19 +842,17 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer Cell cell = buffer.Contents! [row, col]; int outputWidth = -1; - AppendCellAnsi (cell, utf8Output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); + AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); } } if (lastUrl is { }) { - utf8Output.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); + output.Append (EscSeqUtils.OSC_EndHyperlink ()); lastUrl = null; } } - // Decode Utf8Buffer back to StringBuilder for ToAnsi() callers - output.Append (Encoding.UTF8.GetString (utf8Output.AsSpan ())); } private void ClearKittyRasterBlankCells (IOutputBuffer buffer) @@ -864,7 +1005,7 @@ private void EmitVanishedKittyDeletes (IOutputBuffer buffer) if (_kittyCurrentImageId.TryGetValue (id, out int residentImageId)) { - Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacements (residentImageId))); + WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (residentImageId)); } vanishedSourceCrop.Add (id); @@ -883,7 +1024,7 @@ private void DeleteKittyPlacements (List imageIds) { foreach (int imageId in imageIds) { - Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacements (imageId))); + WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (imageId)); } } @@ -950,7 +1091,7 @@ private void RenderRasterImages (IOutputBuffer buffer, bool renderAfterText) int imageId = trackKitty ? GetKittyImageId (command.Id!, rectIndex) : 0; SetCursorPositionImpl (visibleCells.X, visibleCells.Y); - Write (new StringBuilder (GetRasterImageData (command, visibleCells, pixels, imageId))); + WriteEncodedString (GetRasterImageData (command, visibleCells, pixels, imageId)); if (trackKitty) { @@ -997,7 +1138,7 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source // Double-buffer: transmit the new pixels to the OTHER image id, so the image currently on // screen stays intact while the new one loads; the new placement is then drawn on top below. imageId = previousImageId == idA ? idB : idA; - Write (new StringBuilder (new KittyGraphicsEncoder ().EncodeTransmit (command.Pixels!, imageId))); + WriteEncodedString (new KittyGraphicsEncoder ().EncodeTransmit (command.Pixels!, imageId)); _kittyTransmittedImage [command.Id!] = command.Pixels!; _kittyTransmittedVersion [command.Id!] = command.SourceVersion; _kittyCurrentImageId [command.Id!] = imageId; @@ -1012,14 +1153,14 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source int placementId = index + 1; SetCursorPositionImpl (visibleCells.X, visibleCells.Y); - Write (new StringBuilder (KittyGraphicsEncoder.EncodePut (imageId, + WriteEncodedString (KittyGraphicsEncoder.EncodePut (imageId, placementId, srcSub.X, srcSub.Y, srcSub.Width, srcSub.Height, visibleCells.Width, - visibleCells.Height))); + visibleCells.Height)); placementIds.Add (placementId); index++; } @@ -1028,7 +1169,7 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source // pixels were visible right up to this point, so the swap never blanks. if (pixelsChanged && previousImageId != 0 && previousImageId != imageId) { - Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacements (previousImageId))); + WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (previousImageId)); } // Fewer fragments than last frame (e.g. a clip hole closed): delete the now-unused placements so @@ -1039,7 +1180,7 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source { if (!placementIds.Contains (pid)) { - Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacement (imageId, pid))); + WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacement (imageId, pid)); } } } diff --git a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs index 677110a5af..fc1cbcf8bf 100644 --- a/Terminal.Gui/Drivers/Output/Utf8Buffer.cs +++ b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs @@ -6,25 +6,38 @@ namespace Terminal.Gui.Drivers; /// PERF: UTF-8 byte buffer — replaces StringBuilder in the output hot path. /// ANSI sequences (CSI/SGR/OSC) are pure ASCII and appended via fast path (no encoding). /// Unicode graphemes are UTF-8 encoded on append. -/// Zero allocation in stable state (internal buffer grows as needed). +/// Reuses its backing array at stable sizes; other rendering work can still allocate. /// -public sealed class Utf8Buffer +internal sealed class Utf8Buffer { + internal const int RetainedCapacityLimit = 1024 * 1024; + private byte [] _buffer = new byte [256]; private int _length; /// Gets the number of bytes written. public int Length => _length; + internal int Capacity => _buffer.Length; + /// Resets the buffer for reuse without releasing the backing array. public void Clear () => _length = 0; + /// Releases an unusually large backing array after a frame is sent. + public void TrimExcess () + { + if (_length == 0 && _buffer.Length > RetainedCapacityLimit) + { + _buffer = new byte [256]; + } + } + /// Gets a read-only span over the written bytes. public ReadOnlySpan AsSpan () => _buffer.AsSpan (0, _length); /// - /// Appends an ASCII string as raw bytes. Caller must ensure is pure ASCII. - /// No UTF-8 encoding is performed — each char is truncated to byte directly. + /// Appends ASCII as raw bytes. Non-ASCII input falls back to UTF-8 encoding + /// instead of truncating characters. /// public void AppendAscii (string text) { @@ -34,19 +47,39 @@ public void AppendAscii (string text) return; } - EnsureCapacity (len); + if (len <= 32) + { + EnsureCapacity (len); + + for (int index = 0; index < len; index++) + { + char current = text [index]; - for (int i = 0; i < len; i++) + if (current >= 0x80) + { + Append (text.AsSpan ()); + return; + } + + _buffer [_length + index] = (byte)current; + } + + _length += len; + return; + } + + if (!Ascii.IsValid (text)) { - _buffer [_length + i] = (byte)text [i]; + Append (text.AsSpan ()); + return; } - _length += len; + AppendKnownAscii (text); } /// /// Appends a string, auto-detecting ASCII vs Unicode. - /// ASCII strings use a fast truncation path; Unicode strings are UTF-8 encoded. + /// ASCII strings use a direct byte path; Unicode strings are UTF-8 encoded. /// public void Append (string text) { @@ -56,26 +89,13 @@ public void Append (string text) return; } - // Fast check: if all chars < 0x80, use ASCII path (no encoding) - bool allAscii = true; - - for (int i = 0; i < len; i++) - { - if (text [i] >= 0x80) - { - allAscii = false; - - break; - } - } - - if (allAscii) + if (len == 1 && text [0] < 0x80) { - AppendAscii (text); + AppendByte ((byte)text [0]); return; } - Append (text.AsSpan ()); + AppendAscii (text); } /// @@ -108,37 +128,32 @@ public void AppendByte (byte b) /// public void AppendInt (int value) { - if (value == 0) - { - AppendByte ((byte)'0'); - return; - } + uint magnitude = value < 0 ? (uint)-(long)value : (uint)value; + uint remaining = magnitude; + int digits = 1; - if (value < 0) + while (remaining >= 10) { - AppendByte ((byte)'-'); - value = -value; + remaining /= 10; + digits++; } - int temp = value; - int digitCount = 0; + int width = digits + (value < 0 ? 1 : 0); + EnsureCapacity (width); + int position = _length + width; - while (temp > 0) + do { - digitCount++; - temp /= 10; - } + _buffer [--position] = (byte)('0' + magnitude % 10); + magnitude /= 10; + } while (magnitude > 0); - EnsureCapacity (digitCount); - int start = _length + digitCount; - - while (value > 0) + if (value < 0) { - _buffer [--start] = (byte)('0' + value % 10); - value /= 10; + _buffer [--position] = (byte)'-'; } - _length += digitCount; + _length += width; } /// @@ -173,14 +188,13 @@ public void AppendBytes (ReadOnlySpan bytes) private void EnsureCapacity (int additional) { - if (_length + additional <= _buffer.Length) + long required = (long)_length + additional; + + if (required <= _buffer.Length) { return; } - // Guard against int overflow: if required size exceeds Array.MaxLength, throw instead of hanging. - long required = (long)_length + additional; - if (required > Array.MaxLength) { throw new OutOfMemoryException ($"Utf8Buffer capacity {required} exceeds Array.MaxLength."); @@ -201,4 +215,10 @@ private void EnsureCapacity (int additional) Array.Resize (ref _buffer, (int)newSize); } + + private void AppendKnownAscii (ReadOnlySpan text) + { + EnsureCapacity (text.Length); + _length += Encoding.ASCII.GetBytes (text, _buffer.AsSpan (_length)); + } } diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 27b26bc9f8..5acda59e23 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -100,6 +100,9 @@ public enum Condition : short [DllImport ("libc", SetLastError = true)] public static extern int write (int fd, byte [] buf, int count); + [DllImport ("libc", EntryPoint = "write", SetLastError = true)] + private static extern nint WriteSpanNative (int fd, ref byte buffer, nuint count); + /// /// Flush (discard) data in the terminal input or output queue. /// @@ -305,7 +308,7 @@ public static bool TryWriteStdout (byte [] buffer) return false; } - return TryWriteAll (fd, buffer, buffer.Length, write); + return TryWriteAll (fd, buffer, WriteSpan); } catch { @@ -337,7 +340,7 @@ public static bool TryWriteStdout (byte [] buffer, int count) return false; } - return TryWriteAll (fd, buffer, count, write); + return TryWriteAll (fd, buffer.AsSpan (0, count), WriteSpan); } catch { @@ -345,29 +348,46 @@ public static bool TryWriteStdout (byte [] buffer, int count) } } - internal static bool TryWriteAll (int fd, byte [] buffer, int count, Func writeFunc) + /// Writes a span without copying a reusable caller buffer. + public static bool TryWriteStdout (ReadOnlySpan buffer) + { + try + { + int fd = TerminalDevice.OutputFd; + + return fd >= 0 && TryWriteAll (fd, buffer, WriteSpan); + } + catch + { + return false; + } + } + + internal delegate nint WriteBytes (int fd, ReadOnlySpan buffer); + + internal static bool TryWriteAll (int fd, ReadOnlySpan buffer, WriteBytes writeFunc) { int offset = 0; - int remaining = count; - while (remaining > 0) + while (offset < buffer.Length) { - // P/Invoke always writes from index 0, so slice when offset > 0. - byte [] slice = offset == 0 ? buffer : buffer [offset..]; - int written = writeFunc (fd, slice, remaining); + ReadOnlySpan remaining = buffer [offset..]; + nint written = writeFunc (fd, remaining); - if (written <= 0) + if (written <= 0 || written > remaining.Length) { return false; } - offset += written; - remaining -= written; + offset += (int)written; } return true; } + internal static nint WriteSpan (int fd, ReadOnlySpan buffer) => + WriteSpanNative (fd, ref MemoryMarshal.GetReference (buffer), (nuint)buffer.Length); + /// /// Writes a UTF-8 string to stdout. /// diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs index 6916a3242a..614075ff52 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -14,6 +14,25 @@ private static partial bool WriteConsole (nint hConsoleOutput, out uint lpNumberOfCharsWritten, nint lpReserved); + internal static void ValidateConsoleWriteResult (bool succeeded, uint written, uint expected, int error) + { + if (!succeeded) + { + throw new Win32Exception (error); + } + + if (written != expected) + { + throw new IOException ($"WriteConsoleW wrote {written} of {expected} characters."); + } + } + + private static void WriteConsoleChecked (nint handle, ReadOnlySpan text) + { + bool succeeded = WriteConsole (handle, text, (uint)text.Length, out uint written, nint.Zero); + ValidateConsoleWriteResult (succeeded, written, (uint)text.Length, Marshal.GetLastWin32Error ()); + } + [LibraryImport ("kernel32.dll", SetLastError = true)] private static partial nint GetStdHandle (int nStdHandle); @@ -259,20 +278,12 @@ private void CreateScreenBuffer () public void Write (ReadOnlySpan str) { - if (!IsAttachedToTerminal) - { - return; - } - - if (!OperatingSystem.IsWindows ()) + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows () || str.IsEmpty) { return; } - if (!WriteConsole (!IsLegacyConsole ? _outputHandle : _screenBuffer, str, (uint)str.Length, out uint _, nint.Zero)) - { - // Don't throw in unit tests - } + WriteConsoleChecked (!IsLegacyConsole ? _outputHandle : _screenBuffer, str); } public Size ResizeBuffer (Size size) @@ -340,167 +351,80 @@ private void SetConsoleOutputWindow (WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX } } - public override void Write (IOutputBuffer outputBuffer) + /// + protected override void Write (StringBuilder output) { - _everythingStringBuilder.Clear (); - - // for 16 color mode we will write to a backing buffer, then flip it to the active one at the end to avoid jitter. - _consoleBuffer = 0; - - if (Force16Colors) - { - _consoleBuffer = !IsLegacyConsole ? _outputHandle : _screenBuffer; - } - else + if (output.Length == 0) { - _consoleBuffer = _outputHandle; + return; } - try - { - base.Write (outputBuffer); - - if (!IsAttachedToTerminal) - { - return; - } - - ReadOnlySpan span = _everythingStringBuilder.ToString ().AsSpan (); // still allocates the string - - bool result = WriteConsole (_consoleBuffer, span, (uint)span.Length, out _, nint.Zero); - - if (result) - { - return; - } - int err = Marshal.GetLastWin32Error (); - - if (err == 1) - { - Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); - - return; - } + base.Write (output); - if (err != 0) - { - throw new Win32Exception (err); - } - } - catch (DllNotFoundException) + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows ()) { - // Running unit tests or in an environment where writing is not possible. + return; } - catch (Exception e) - { - Logging.Error ($"Error: {e.Message} in {nameof (WindowsOutput)}"); - if (OperatingSystem.IsWindows ()) - { - throw; - } - } + nint handle = !IsLegacyConsole ? _outputHandle : _screenBuffer; + WriteConsoleChecked (handle, output.ToString ().AsSpan ()); } /// - protected override void Write (StringBuilder output) + protected override void Write (ReadOnlySpan output) { - if (output.Length == 0) - { - return; - } - - base.Write (output); - - if (!IsAttachedToTerminal) + if (output.IsEmpty) { return; } - var str = output.ToString (); + // Decode once for both the optional capture and WriteConsoleW. + int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + char [] decoded; - if (Force16Colors && IsLegacyConsole) + if (maxCharCount > Utf8Buffer.RetainedCapacityLimit) { - char [] a = str.ToCharArray (); - WriteConsole (_screenBuffer, a, (uint)a.Length, out _, nint.Zero); + decoded = new char [maxCharCount]; } else { - try - { - ReadOnlySpan span = str.AsSpan (); // still allocates the string - - bool result = WriteConsole (_outputHandle, span, (uint)span.Length, out _, nint.Zero); - - if (result) - { - return; - } - int err = Marshal.GetLastWin32Error (); - - if (err == 1) - { - Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); - - return; - } - - if (err != 0) - { - throw new Win32Exception (err); - } - } - catch (DllNotFoundException) + if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) { - // Running unit tests or in an environment where writing is not possible. + _utf16DecodeBuffer = new char [maxCharCount]; } - catch (Exception e) - { - Logging.Error ($"Error: {e.Message} in {nameof (WindowsOutput)}"); - if (OperatingSystem.IsWindows ()) - { - throw; - } - } + decoded = _utf16DecodeBuffer; } - } - /// - protected override void Write (ReadOnlySpan output) - { - base.Write (output); + int charCount = Encoding.UTF8.GetChars (output, decoded); + ReadOnlySpan charSpan = decoded.AsSpan (0, charCount); + CaptureText (charSpan); - if (!IsAttachedToTerminal || output.IsEmpty) + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows ()) { return; } - if (!OperatingSystem.IsWindows ()) - { - return; - } + nint handle = !IsLegacyConsole ? _outputHandle : _screenBuffer; + WriteConsoleChecked (handle, charSpan); + } - // Decode UTF-8 to UTF-16 using a reusable buffer, then write via WriteConsole. - int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + /// + protected override void WriteEncodedString (string output) + { + CaptureText (output.AsSpan ()); - if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows () || output.Length == 0) { - _utf16DecodeBuffer = new char [maxCharCount]; + return; } - int charCount = Encoding.UTF8.GetChars (output, _utf16DecodeBuffer); - ReadOnlySpan charSpan = _utf16DecodeBuffer.AsSpan (0, charCount); nint handle = !IsLegacyConsole ? _outputHandle : _screenBuffer; - - if (!WriteConsole (handle, charSpan, (uint)charCount, out _, nint.Zero)) - { - // Don't throw in unit tests - } + WriteConsoleChecked (handle, output.AsSpan ()); } /// - protected override void AppendOrWriteAttribute (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) + internal override void AppendOrWriteAttributeUtf8 (Utf8Buffer output, Attribute attr, TextStyle redrawTextStyle) { if (Force16Colors && IsLegacyConsole) { @@ -508,11 +432,14 @@ protected override void AppendOrWriteAttribute (Utf8Buffer output, Attribute att Write (output.AsSpan ()); output.Clear (); var as16ColorInt = (ushort)((int)attr.Foreground.GetClosestNamedColor16 () | ((int)attr.Background.GetClosestNamedColor16 () << 4)); - SetConsoleTextAttribute (_screenBuffer, as16ColorInt); + if (IsAttachedToTerminal && OperatingSystem.IsWindows ()) + { + SetConsoleTextAttribute (_screenBuffer, as16ColorInt); + } } else { - base.AppendOrWriteAttribute (output, attr, redrawTextStyle); + base.AppendOrWriteAttributeUtf8 (output, attr, redrawTextStyle); } } @@ -633,8 +560,6 @@ public void SetSize (int width, int height) } private bool _isDisposed; - private nint _consoleBuffer; - private readonly StringBuilder _everythingStringBuilder = new (); /// public void Dispose () diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs index 3665f9ab3b..42a1767d16 100644 --- a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs @@ -18,6 +18,7 @@ namespace Terminal.Gui.Benchmarks.ConsoleDrivers.OutputBuffer; /// /// [MemoryDiagnoser] +[InvocationCount (1)] [BenchmarkCategory ("Output", "Latency")] public class OutputWriteBenchmark { @@ -38,9 +39,15 @@ public void Setup () { _fullBuffer = CreateBuffer (); _sparseBuffer = CreateBuffer (); - _output = new (); + _output = new () { CaptureOutput = false }; _output.Write (_fullBuffer); + + if (_output.Writes == 0 || _output.PayloadBytesWritten == 0) + { + throw new InvalidOperationException ("The benchmark output sink did not receive the frame."); + } + _output.Write (_sparseBuffer); } @@ -72,7 +79,12 @@ public long FullFrame () { _output.Write (_fullBuffer); - return _output.CharactersWritten; + if (_output.Writes == 0 || _output.PayloadBytesWritten == 0) + { + throw new InvalidOperationException ("The full-frame benchmark did not flush rendered cells."); + } + + return _output.BytesWritten; } /// @@ -83,7 +95,12 @@ public long SparseBorderFrame () { _output.Write (_sparseBuffer); - return _output.CharactersWritten; + if (_output.Writes == 0 || _output.PayloadBytesWritten == 0) + { + throw new InvalidOperationException ("The sparse-frame benchmark did not flush rendered cells."); + } + + return _output.BytesWritten; } /// @@ -198,7 +215,9 @@ private static void MarkSparseBorderFrame (OutputBufferImpl buffer) private sealed class MeasuringOutput : OutputBase { - public long CharactersWritten { get; private set; } + public long BytesWritten { get; private set; } + + public long PayloadBytesWritten { get; private set; } public int CursorMoves { get; private set; } @@ -206,7 +225,8 @@ private sealed class MeasuringOutput : OutputBase public void ResetCounters () { - CharactersWritten = 0; + BytesWritten = 0; + PayloadBytesWritten = 0; CursorMoves = 0; Writes = 0; } @@ -218,7 +238,7 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo StringBuilder sequence = new (); EscSeqUtils.CSI_AppendCursorPosition (sequence, screenPositionY + 1, screenPositionX + 1); string cursorSequence = sequence.ToString (); - CharactersWritten += cursorSequence.Length; + BytesWritten += cursorSequence.Length; return true; } @@ -226,7 +246,16 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo protected override void Write (StringBuilder output) { Writes++; - CharactersWritten += output.Length; + int bytes = Encoding.UTF8.GetByteCount (output.ToString ()); + PayloadBytesWritten += bytes; + BytesWritten += bytes; + } + + protected override void Write (ReadOnlySpan output) + { + Writes++; + PayloadBytesWritten += output.Length; + BytesWritten += output.Length; } } } diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs new file mode 100644 index 0000000000..c2e68ea047 --- /dev/null +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs @@ -0,0 +1,114 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Drivers; + +namespace Terminal.Gui.Benchmarks.ConsoleDrivers.OutputBuffer; + +/// Measures the complete UnixRaw frame writer without terminal I/O. +[MemoryDiagnoser] +[InvocationCount (1)] +[BenchmarkCategory ("Output", "Latency")] +public class UnixRawOutputBenchmark +{ + private OutputBufferImpl _small = null!; + private OutputBufferImpl _large = null!; + private AnsiOutput _output = null!; + private long _bytes; + private int _writes; + + /// Builds and warms the two rendered frame sizes. + [GlobalSetup] + public void Setup () + { + _small = CreateBuffer (80, 25); + _large = CreateBuffer (240, 70); + _output = new (bytes => + { + _writes++; + _bytes += bytes.Length; + return true; + }) { CaptureOutput = false }; + + _output.Write (_small); + + if (_writes == 0 || _bytes == 0) + { + throw new InvalidOperationException ("The UnixRaw benchmark did not write the warmup frame."); + } + + _output.Write (_large); + } + + /// Marks the 80×25 frame dirty before each measurement. + [IterationSetup (Target = nameof (SmallFrame))] + public void PrepareSmall () + { + MarkDirty (_small); + _writes = 0; + _bytes = 0; + } + + /// Marks the 240×70 frame dirty before each measurement. + [IterationSetup (Target = nameof (LargeFrame))] + public void PrepareLarge () + { + MarkDirty (_large); + _writes = 0; + _bytes = 0; + } + + /// Flushes a fully dirty 80×25 frame. + [Benchmark] + public long SmallFrame () + { + _output.Write (_small); + + if (_writes != 1) + { + throw new InvalidOperationException ($"Expected one UnixRaw write, got {_writes}."); + } + + return _bytes; + } + + /// Flushes a fully dirty 240×70 frame. + [Benchmark] + public long LargeFrame () + { + _output.Write (_large); + + if (_writes != 1) + { + throw new InvalidOperationException ($"Expected one UnixRaw write, got {_writes}."); + } + + return _bytes; + } + + private static OutputBufferImpl CreateBuffer (int cols, int rows) + { + OutputBufferImpl buffer = new (); + buffer.SetSize (cols, rows); + string text = new ('A', cols); + + for (int row = 0; row < rows; row++) + { + buffer.Move (0, row); + buffer.AddStr (text); + } + + return buffer; + } + + private static void MarkDirty (OutputBufferImpl buffer) + { + for (int row = 0; row < buffer.Rows; row++) + { + buffer.DirtyLines [row] = true; + + for (int col = 0; col < buffer.Cols; col++) + { + buffer.Contents! [row, col].IsDirty = true; + } + } + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs new file mode 100644 index 0000000000..60bc14724b --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs @@ -0,0 +1,374 @@ +using System.ComponentModel; +using System.Text; + +namespace DriverTests.Output; + +/// Regression tests for the UTF-8 frame writer and legacy output hooks. +[Collection ("Driver Tests")] +public class OutputPathRegressionTests +{ + // Codex - GPT-6 + [Fact] + public void LegacyStringWriter_ReceivesRenderedCells () + { + LegacyStringWriter output = new (); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("Ω"); + + output.Write (buffer); + + Assert.Contains ("Ω", output.Written.ToString ()); + } + + // Codex - GPT-6 + [Fact] + public void LegacyAttributeHook_IsCalledByUtf8Renderer () + { + LegacyAttributeWriter output = new (); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.CurrentAttribute = new (new Color (1, 2, 3), new Color (4, 5, 6)); + buffer.AddStr ("X"); + + output.Write (buffer); + + Assert.Contains ("X", output.Written.ToString ()); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_SingleRow_MatchesStringEncoderByteForByte () + { + List writes = []; + AnsiOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (3, 1); + buffer.CurrentAttribute = new Terminal.Gui.Drawing.Attribute (new Color (1, 2, 3), new Color (4, 5, 6)) + { + Style = TextStyle.Bold + }; + buffer.AddStr ("A"); + buffer.CurrentUrl = "https://example.org/路径"; + buffer.AddStr ("é"); + buffer.CurrentUrl = null; + buffer.AddStr ("Z"); + + output.Write (buffer); + + byte [] expected = Encoding.UTF8.GetBytes (EscSeqUtils.CSI_SetCursorPosition (1, 1) + + output.ToAnsi (buffer).TrimEnd ('\n')); + Assert.Single (writes); + Assert.Equal (expected, writes [0]); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_TwoRows_AreWrittenAsOneOrderedFrame () + { + List writes = []; + AnsiOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (2, 2); + buffer.Move (0, 0); + buffer.AddStr ("AB"); + buffer.Move (0, 1); + buffer.AddStr ("CD"); + + output.Write (buffer); + + Assert.Single (writes); + string actual = Encoding.UTF8.GetString (writes [0]); + int firstMove = actual.IndexOf (EscSeqUtils.CSI_SetCursorPosition (1, 1), StringComparison.Ordinal); + int firstText = actual.IndexOf ("AB", StringComparison.Ordinal); + Assert.True (firstMove >= 0 && firstText > firstMove); + Assert.Contains (EscSeqUtils.CSI_SetCursorPosition (2, 1) + "CD", actual); + Assert.Equal (actual, output.GetLastOutput ()); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_OnlyLastDeferredCursorMoveIsSent () + { + List writes = []; + AnsiOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (4, 1); + output.Write (buffer); + writes.Clear (); + buffer.Contents! [0, 2].IsDirty = true; + buffer.DirtyLines [0] = true; + + output.Write (buffer); + + Assert.Single (writes); + string actual = Encoding.UTF8.GetString (writes [0]); + Assert.StartsWith (EscSeqUtils.CSI_SetCursorPosition (1, 3), actual); + Assert.DoesNotContain (EscSeqUtils.CSI_SetCursorPosition (1, 1), actual); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_CharWriteDuringFrame_FollowsDeferredCursorMove () + { + List writes = []; + InjectingCursorOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + output.Write (buffer); + + Assert.Single (writes); + string actual = Encoding.UTF8.GetString (writes [0]); + int cursor = actual.IndexOf (EscSeqUtils.CSI_SetCursorPosition (1, 1), StringComparison.Ordinal); + int injected = actual.IndexOf ('M'); + int cell = actual.LastIndexOf ('X'); + Assert.True (cursor >= 0 && injected > cursor && cell > injected); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_KittyHyperlinkAndSixel_KeepTheirOrder () + { + List writes = []; + AnsiOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + return true; + }) { UseKittyGraphics = true }; + OutputBufferImpl buffer = new (); + buffer.SetSize (3, 1); + buffer.Clip = new Region (new Rectangle (0, 0, 3, 1)); + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = new Color [1, 1] { { new (255, 0, 0) } }, + DestinationCells = new Rectangle (2, 0, 1, 1) + }); + buffer.CurrentUrl = "https://example.org/路径"; + buffer.AddStr ("é"); + output.GetSixels ().Enqueue (new SixelToRender + { + SixelData = "\u001bPq#0~\u001b\\", + ScreenPosition = new (0, 0) + }); + + output.Write (buffer); + + string actual = Encoding.UTF8.GetString (writes.SelectMany (bytes => bytes).ToArray ()); + int kitty = actual.IndexOf ("\u001b_G", StringComparison.Ordinal); + int hyperlink = actual.IndexOf ("https://example.org/路径", StringComparison.Ordinal); + int sixel = actual.IndexOf ("\u001bPq", StringComparison.Ordinal); + Assert.True (kitty >= 0 && hyperlink > kitty && sixel > hyperlink); + Assert.Equal (actual, output.GetLastOutput ()); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_FailedFrame_DiscardsPendingCursorMoves () + { + List writes = []; + bool fail = true; + AnsiOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + + if (!fail) + { + return true; + } + + fail = false; + return false; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + Assert.Throws (() => output.Write (buffer)); + output.Write ("Z".AsSpan ()); + + Assert.Equal (2, writes.Count); + Assert.Equal ("Z", Encoding.UTF8.GetString (writes [1])); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_RenderException_DoesNotSendDeferredCursorMove () + { + List writes = []; + ThrowingCursorOutput output = new (bytes => + { + writes.Add (bytes.ToArray ()); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + Assert.Throws (() => output.Write (buffer)); + Assert.Empty (writes); + + output.Write ("Z".AsSpan ()); + Assert.Single (writes); + Assert.Equal ("Z", Encoding.UTF8.GetString (writes [0])); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_UnexpectedWriterException_IsNotHidden () + { + AnsiOutput output = new (_ => throw new InvalidOperationException ("writer bug")); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + Assert.Throws (() => output.Write (buffer)); + } + + // Codex - GPT-6 + [Theory] + [InlineData (false, 0, 1, 5)] + [InlineData (true, 0, 1, 0)] + public void WindowsWriteResult_RejectsFailureAndPartialWrite (bool succeeded, + uint written, + uint expected, + int error) + { + if (!succeeded) + { + Assert.Throws (() => WindowsOutput.ValidateConsoleWriteResult (succeeded, written, expected, error)); + return; + } + + Assert.Throws (() => WindowsOutput.ValidateConsoleWriteResult (succeeded, written, expected, error)); + } + + // Codex - GPT-6 + [Fact] + public void NetAndWindowsByteSinks_CaptureUnicodeWithoutAStringWrite () + { + byte [] encoded = Encoding.UTF8.GetBytes ("Aé中😀"); + ExposedNetOutput net = new () { CaptureOutput = true }; + ExposedWindowsOutput windows = new () { CaptureOutput = true }; + + net.WriteBytes (encoded); + windows.WriteBytes (encoded); + + Assert.Equal ("Aé中😀", net.GetLastOutput ()); + Assert.Equal ("Aé中😀", windows.GetLastOutput ()); + } + + // Codex - GPT-6 + [Fact] + public void UnixRaw_LargeSixel_DoesNotRetainLargeWriteBuffers () + { + int bytesWritten = 0; + AnsiOutput output = new (bytes => + { + bytesWritten += bytes.Length; + return true; + }) { CaptureOutput = false }; + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + output.GetSixels ().Enqueue (new SixelToRender + { + SixelData = new string ('q', 4 * 1024 * 1024), + ScreenPosition = new (0, 0) + }); + + output.Write (buffer); + buffer.Move (0, 0); + buffer.AddStr ("Y"); + output.Write (buffer); + + Assert.True (bytesWritten >= 4 * 1024 * 1024); + Assert.InRange (output.RetainedWriteBufferBytes, 0, Utf8Buffer.RetainedCapacityLimit * 2); + Assert.Equal (string.Empty, output.GetLastOutput ()); + } + + private class LegacyStringWriter : OutputBase + { + public StringBuilder Written { get; } = new (); + + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) => true; + + protected override void Write (StringBuilder output) => Written.Append (output); + } + + private sealed class LegacyAttributeWriter : LegacyStringWriter + { + protected override void AppendOrWriteAttribute (StringBuilder output, + Terminal.Gui.Drawing.Attribute attr, + TextStyle redrawTextStyle) => output.Append (""); + } + + private sealed class InjectingCursorOutput : AnsiOutput + { + private bool _injected; + + public InjectingCursorOutput (UnixWriter writer) : base (writer) { } + + protected override bool SetCursorPositionImpl (int col, int row) + { + bool moved = base.SetCursorPositionImpl (col, row); + + if (_injected) + { + return moved; + } + + _injected = true; + Write ("M".AsSpan ()); + return moved; + } + } + + private sealed class ThrowingCursorOutput : AnsiOutput + { + private bool _throw = true; + + public ThrowingCursorOutput (UnixWriter writer) : base (writer) { } + + protected override bool SetCursorPositionImpl (int col, int row) + { + bool moved = base.SetCursorPositionImpl (col, row); + + if (_throw) + { + _throw = false; + throw new InvalidOperationException ("render failure"); + } + + return moved; + } + } + + private sealed class ExposedNetOutput : NetOutput + { + public void WriteBytes (ReadOnlySpan bytes) => Write (bytes); + } + + private sealed class ExposedWindowsOutput : WindowsOutput + { + public void WriteBytes (ReadOnlySpan bytes) => Write (bytes); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs index 409f662627..7a113bab85 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text; namespace DriverTests.Output; @@ -27,6 +28,17 @@ public void AppendAscii_PureAscii_StoresRawBytes () Assert.Equal ("Hello", Encoding.UTF8.GetString (buffer.AsSpan ())); } + // Codex - GPT-6 + [Fact] + public void AppendAscii_NonAscii_FallsBackToUtf8 () + { + Utf8Buffer buffer = new (); + + buffer.AppendAscii ("é中"); + + Assert.Equal ("é中", Encoding.UTF8.GetString (buffer.AsSpan ())); + } + [Fact] public void AppendAscii_CsiEscapeSequence_StoresExactBytes () { @@ -156,6 +168,9 @@ public void AppendInt_Negative_AppendsMinusAndDigits () [InlineData (999)] [InlineData (1000)] [InlineData (int.MaxValue)] + [InlineData (int.MinValue)] + [InlineData (-1)] + [InlineData (-1000)] public void AppendInt_VariousValues_MatchesToString (int value) { Utf8Buffer buffer = new (); @@ -272,6 +287,31 @@ public void Capacity_GrowsForMultibyteContent () Assert.Equal (large, Encoding.UTF8.GetString (buffer.AsSpan ())); } + // Codex - GPT-6 + [Fact] + public void TrimExcess_ReleasesLargeBackingArrayAfterClear () + { + Utf8Buffer buffer = new (); + buffer.Append (new string ('x', Utf8Buffer.RetainedCapacityLimit + 1)); + Assert.True (buffer.Capacity > Utf8Buffer.RetainedCapacityLimit); + + buffer.Clear (); + buffer.TrimExcess (); + + Assert.Equal (256, buffer.Capacity); + } + + // Codex - GPT-6 + [Fact] + public void EnsureCapacity_ChecksOverflowBeforeArrayAccess () + { + Utf8Buffer buffer = new (); + FieldInfo lengthField = typeof (Utf8Buffer).GetField ("_length", BindingFlags.Instance | BindingFlags.NonPublic)!; + lengthField.SetValue (buffer, int.MaxValue); + + Assert.Throws (() => buffer.AppendByte ((byte)'X')); + } + [Fact] public void MultipleAppends_AccumulateCorrectly () { diff --git a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs index 4a808acd69..6771475538 100644 --- a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs @@ -23,8 +23,7 @@ public void TryWriteAll_ReturnsFalse_WhenWriteReturnsZero () bool result = UnixIOHelper.TryWriteAll (1, payload, - payload.Length, - (_, _, _) => + (_, _) => { calls++; @@ -44,10 +43,9 @@ public void TryWriteAll_RetriesUntilAllBytesWritten () bool result = UnixIOHelper.TryWriteAll (1, payload, - payload.Length, - (_, buffer, count) => + (_, buffer) => { - counts.Add (count); + counts.Add (buffer.Length); firstBytes.Add (buffer [0]); return 1; @@ -58,6 +56,88 @@ public void TryWriteAll_RetriesUntilAllBytesWritten () Assert.Equal ([0x41, 0x42, 0x43], firstBytes); } + // Codex - GPT-6 + [Fact] + public void TryWriteAll_PartialWriteOfSmallSpan_DoesNotCopyLargeBackingArray () + { + byte [] backing = new byte [1_000_000]; + backing [0] = (byte)'A'; + backing [1] = (byte)'B'; + int calls = 0; + int firstCount = 0; + int secondCount = 0; + byte secondByte = 0; + UnixIOHelper.WriteBytes writer = (_, remaining) => + { + if (calls == 0) + { + firstCount = remaining.Length; + } + else + { + secondCount = remaining.Length; + secondByte = remaining [0]; + } + + calls++; + return 1; + }; + + UnixIOHelper.TryWriteAll (1, backing.AsSpan (0, 2), writer); + calls = 0; + long before = GC.GetAllocatedBytesForCurrentThread (); + bool result = UnixIOHelper.TryWriteAll (1, backing.AsSpan (0, 2), writer); + long allocated = GC.GetAllocatedBytesForCurrentThread () - before; + + Assert.True (result); + Assert.Equal (2, calls); + Assert.Equal (2, firstCount); + Assert.Equal (1, secondCount); + Assert.Equal ((byte)'B', secondByte); + Assert.InRange (allocated, 0, 1024); + } + + // Codex - GPT-6 + [Fact] + public void TryWriteAll_RejectsWriterThatClaimsMoreThanRemaining () + { + byte [] payload = [0x41]; + + Assert.False (UnixIOHelper.TryWriteAll (1, payload, (_, _) => 2)); + } + + // Codex - GPT-6 + [Fact] + public void TryWriteAll_NativeSpanWrite_UsesOnlyRequestedBytes () + { + if (OperatingSystem.IsWindows ()) + { + return; + } + + int [] pipeFds = new int [2]; + Assert.Equal (0, pipe (pipeFds)); + + try + { + byte [] backing = new byte [1_000_000]; + backing [0] = (byte)'A'; + backing [1] = (byte)'B'; + backing [2] = (byte)'C'; + + Assert.True (UnixIOHelper.TryWriteAll (pipeFds [1], backing.AsSpan (0, 2), UnixIOHelper.WriteSpan)); + + byte [] received = new byte [2]; + Assert.Equal (2, UnixIOHelper.read (pipeFds [0], received, received.Length)); + Assert.Equal ([(byte)'A', (byte)'B'], received); + } + finally + { + close (pipeFds [0]); + close (pipeFds [1]); + } + } + [Fact] // Copilot public void IsInputAvailable_ReturnsTrue_WhenPollReportsReadableData () From d06b8934fe1495c6c6058226248e536e9ee4a93e Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 30 Sep 2026 17:40:23 -0500 Subject: [PATCH 36/42] Preserve legacy output hooks and recover failed frames --- Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 77 ++- .../Drivers/DotNetDriver/NetOutput.cs | 17 +- Terminal.Gui/Drivers/Output/OutputBase.cs | 130 ++++- .../Drivers/Output/OutputDirtyState.cs | 75 +++ .../WindowsHelpers/WindowsVTOutputHelper.cs | 88 ++- .../Output/OutputReviewRegressionTests.cs | 510 ++++++++++++++++++ 6 files changed, 803 insertions(+), 94 deletions(-) create mode 100644 Terminal.Gui/Drivers/Output/OutputDirtyState.cs create mode 100644 Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 13df30f31b..c4ae6c6742 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -228,11 +228,20 @@ protected override void Write (StringBuilder output) return; } - Write (Encoding.UTF8.GetBytes (output.ToString ())); + WriteUtf8 (Encoding.UTF8.GetBytes (output.ToString ())); } /// - protected override void WriteEncodedString (string output) => Write (output.AsSpan ()); + protected override void WriteEncodedString (string output) + { + if (HasCustomStringWriter (typeof (AnsiOutput))) + { + Write (new StringBuilder (output)); + return; + } + + Write (output.AsSpan ()); + } /// public void Write (ReadOnlySpan text) @@ -252,25 +261,13 @@ public void Write (ReadOnlySpan text) public override void Write (IOutputBuffer buffer) { _lastBuffer = buffer; - _batchMode = true; + // An out-of-band legacy attribute hook must take effect between physical writes. + _batchMode = !UsesLegacyAttributeWriter && !HasCustomStringWriter (typeof (AnsiOutput)); _frameBuffer.Clear (); try { base.Write (buffer); - - if (_pendingCursorMoves.Length > 0) - { - _frameBuffer.Append (_pendingCursorMoves); - _pendingCursorMoves.Clear (); - } - - _batchMode = false; - - if (_frameBuffer.Length > 0) - { - Write (_frameBuffer.AsSpan ()); - } } finally { @@ -282,25 +279,64 @@ public override void Write (IOutputBuffer buffer) } } + internal override void FlushFrame () + { + if (_pendingCursorMoves.Length > 0) + { + _frameBuffer.Append (_pendingCursorMoves); + _pendingCursorMoves.Clear (); + } + + _batchMode = false; + + if (_frameBuffer.Length > 0) + { + Write (_frameBuffer.AsSpan ()); + } + } + /// /// PERF: Write pre-encoded UTF-8 bytes directly to the terminal. /// In batch mode, merges deferred cursor moves with cell content into one WriteFile. /// This is the hot-path write — zero string/byte[] allocation, zero encoding. /// protected override void Write (ReadOnlySpan output) + { + if (HasCustomStringWriter (typeof (AnsiOutput))) + { + Write (new StringBuilder (Encoding.UTF8.GetString (output))); + return; + } + + WriteUtf8 (output); + } + + private void WriteUtf8 (ReadOnlySpan output) { try { if (_batchMode) { + if (_frameBuffer.Length + (long)_pendingCursorMoves.Length > Utf8Buffer.RetainedCapacityLimit) + { + WriteDirect (_frameBuffer.AsSpan ()); + _frameBuffer.Clear (); + } + if (_pendingCursorMoves.Length > 0) { _frameBuffer.Append (_pendingCursorMoves); _pendingCursorMoves.Clear (); } - // Send large images directly after earlier frame content. Staging - // them would make the frame buffer retain multi-megabyte capacity. + // Bound cumulative staging too: many small images can exceed the limit. + if (_frameBuffer.Length > Utf8Buffer.RetainedCapacityLimit - Math.Min (output.Length, Utf8Buffer.RetainedCapacityLimit)) + { + WriteDirect (_frameBuffer.AsSpan ()); + _frameBuffer.Clear (); + } + + // Large images bypass staging after all preceding content. if (output.Length > Utf8Buffer.RetainedCapacityLimit) { if (_frameBuffer.Length > 0) @@ -392,11 +428,6 @@ public void SetCursor (Cursor cursor) /// protected override bool SetCursorPositionImpl (int col, int row) { - if (_currentCursor.Position is { } && _currentCursor.Position.Value.X == col && _currentCursor.Position.Value.Y == row) - { - return false; - } - if (_platform == AnsiPlatform.Degraded) { return true; diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index 3fa2c0e81a..5f0f395ea2 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -114,6 +114,12 @@ protected override void Write (StringBuilder output) /// protected override void WriteEncodedString (string output) { + if (HasCustomStringWriter (typeof (NetOutput))) + { + Write (new StringBuilder (output)); + return; + } + CaptureText (output.AsSpan ()); if (!IsAttachedToTerminal) @@ -134,6 +140,12 @@ protected override void WriteEncodedString (string output) /// protected override void Write (ReadOnlySpan output) { + if (HasCustomStringWriter (typeof (NetOutput))) + { + Write (new StringBuilder (Encoding.UTF8.GetString (output))); + return; + } + if (output.IsEmpty) { return; @@ -214,11 +226,6 @@ public void SetCursor (Cursor cursor) /// protected override bool SetCursorPositionImpl (int col, int row) { - if (_currentCursor.Position is { } && _currentCursor.Position.Value.X == col && _currentCursor.Position.Value.Y == row) - { - return false; - } - if (_isWinPlatform) { try diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index e827d82e6b..5817bb7515 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -16,9 +16,18 @@ protected OutputBase () CaptureOutput = !IsAttachedToTerminal; Action attributeWriter = AppendOrWriteAttribute; _usesLegacyAttributeWriter = attributeWriter.Method.DeclaringType != typeof (OutputBase); + Action stringWriter = Write; + _stringWriterType = stringWriter.Method.DeclaringType; } private readonly bool _usesLegacyAttributeWriter; + private readonly Type? _stringWriterType; + + /// Gets whether a derived class overrides a built-in StringBuilder sink. + protected bool HasCustomStringWriter (Type builtInType) => _stringWriterType != builtInType; + + /// Gets whether a derived output uses the original StringBuilder attribute hook. + protected bool UsesLegacyAttributeWriter => _usesLegacyAttributeWriter; /// /// Gets whether this output instance is attached to a real terminal device. @@ -129,11 +138,86 @@ public bool IsLegacyConsole /// and finally renders queued sixel images. Cursor visibility is managed by /// ApplicationMainLoop.SetCursor(). /// + /// + /// To update output from background work, dispatch the update to the application's main loop. + /// Writes and mutations of the buffer must be serialized on that thread. A failed built-in frame + /// write restores consumed dirty flags and invalidates raster state so the buffer can be retried. + /// public virtual void Write (IOutputBuffer buffer) + { + OutputDirtyState dirtyState = new (buffer, ref _dirtyChanges); + _clearLastOutputPending = true; + _kittyImagesTouchedThisFrame.Clear (); + + try + { + if (_recoverOutputState && !IsLegacyConsole) + { + // A short native write can leave a control string open and terminal state uncertain. + WriteEncodedString ("\u001b\\" + EscSeqUtils.OSC_EndHyperlink () + EscSeqUtils.CSI_ResetAttributes); + _redrawTextStyle = TextStyle.None; + + foreach (int imageId in _kittyDeletesToRetry) + { + DeleteKittyImage (imageId); + } + } + + WriteFrame (buffer, ref dirtyState); + FlushFrame (); + _recoverOutputState = false; + _kittyDeletesToRetry.Clear (); + } + catch + { + dirtyState.Restore (); + RecoverRasterState (buffer); + _recoverOutputState = true; + throw; + } + } + + // Batched outputs must flush inside the dirty-state transaction. + internal virtual void FlushFrame () { } + + private bool _recoverOutputState; + private int []? _dirtyChanges; + private readonly HashSet _kittyImagesTouchedThisFrame = []; + private readonly HashSet _kittyDeletesToRetry = []; + + private void RecoverRasterState (IOutputBuffer buffer) + { + _kittyDeletesToRetry.UnionWith (_kittyImagesTouchedThisFrame); + + foreach (List imageIds in _placedKittyImageIds.Values) + { + _kittyDeletesToRetry.UnionWith (imageIds); + } + + _kittyDeletesToRetry.UnionWith (_kittyCurrentImageId.Values); + _placedKittyImageIds.Clear (); + _placedKittyGeometry.Clear (); + _kittyTransmittedImage.Clear (); + _kittyTransmittedVersion.Clear (); + _kittyPlacementIds.Clear (); + _kittyCurrentImageId.Clear (); + + foreach (RasterImageCommand command in buffer.GetRasterImages ()) + { + command.IsDirty = true; + command.NeedsTransparentCellClear = true; + } + + foreach (SixelToRender sixel in GetSixels ()) + { + sixel.IsDirty = true; + } + } + + private void WriteFrame (IOutputBuffer buffer, ref OutputDirtyState dirtyState) { using Utf8BufferLease lease = new (this); Utf8Buffer outputBuffer = lease.Buffer; - _clearLastOutputPending = true; var top = 0; var left = 0; int rows = buffer.Rows; @@ -149,7 +233,7 @@ public virtual void Write (IOutputBuffer buffer) if (!IsLegacyConsole && UseKittyGraphics) { EmitVanishedKittyDeletes (buffer); - ClearKittyRasterBlankCells (buffer); + ClearKittyRasterBlankCells (buffer, ref dirtyState); } // Raster images must be written before dirty cells so later text draws above them. @@ -205,7 +289,7 @@ public virtual void Write (IOutputBuffer buffer) { if (skipRasterBlank) { - buffer.Contents [row, col].IsDirty = false; + dirtyState.ClearCell (row, col); } if (outputWidth > 0) @@ -255,14 +339,14 @@ public virtual void Write (IOutputBuffer buffer) // Append dirty cell as ANSI and mark clean int cellCol = col; Cell cell = buffer.Contents [row, col]; - buffer.Contents [row, col].IsDirty = false; + dirtyState.ClearCell (row, col); AppendCellAnsi (cell, outputBuffer, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); if (col != cellCol) { // Was a wide grapheme so mark clean next cell // See https://github.com/tui-cs/Terminal.Gui/issues/4466 - buffer.Contents [row, col].IsDirty = false; + dirtyState.ClearCell (row, col); } } @@ -283,7 +367,7 @@ public virtual void Write (IOutputBuffer buffer) // Every dirty cell in this row has now been consumed. Drawing operations will // set this flag again when they modify the row in a later frame. - buffer.DirtyLines [row] = false; + dirtyState.ClearLine (row); // Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink // may still be open in the terminal because it was started in a prior batch flushed by @@ -425,13 +509,12 @@ internal virtual void AppendOrWriteAttributeUtf8 (Utf8Buffer output, Attribute a { if (_usesLegacyAttributeWriter) { - StringBuilder legacyAttributeBuffer = new (); + // The hook can flush accumulated text before an out-of-band color change. + StringBuilder legacyAttributeBuffer = new (Encoding.UTF8.GetString (output.AsSpan ())); + output.Clear (); AppendOrWriteAttribute (legacyAttributeBuffer, attr, redrawTextStyle); - - foreach (ReadOnlyMemory chunk in legacyAttributeBuffer.GetChunks ()) - { - output.Append (chunk.Span); - } + // Encode once: StringBuilder chunks can split a UTF-16 surrogate pair. + output.Append (legacyAttributeBuffer.ToString ()); return; } @@ -855,7 +938,7 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer } - private void ClearKittyRasterBlankCells (IOutputBuffer buffer) + private void ClearKittyRasterBlankCells (IOutputBuffer buffer, ref OutputDirtyState dirtyState) { if (buffer.Contents is null) { @@ -887,7 +970,7 @@ private void ClearKittyRasterBlankCells (IOutputBuffer buffer) } WriteTransparentBlankCell (); - buffer.Contents [row, col].IsDirty = false; + dirtyState.ClearCell (row, col); } } } @@ -1005,7 +1088,7 @@ private void EmitVanishedKittyDeletes (IOutputBuffer buffer) if (_kittyCurrentImageId.TryGetValue (id, out int residentImageId)) { - WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (residentImageId)); + DeleteKittyImage (residentImageId); } vanishedSourceCrop.Add (id); @@ -1024,10 +1107,16 @@ private void DeleteKittyPlacements (List imageIds) { foreach (int imageId in imageIds) { - WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (imageId)); + DeleteKittyImage (imageId); } } + private void DeleteKittyImage (int imageId) + { + _kittyImagesTouchedThisFrame.Add (imageId); + WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (imageId)); + } + // Derives the Kitty image id for the rectIndex-th visible fragment of a command. Fragment 0 uses // the command's base id (matching ImageView's pre-encoded payload); later fragments get distinct // ids so their data does not overwrite earlier fragments under a shared id. @@ -1091,6 +1180,11 @@ private void RenderRasterImages (IOutputBuffer buffer, bool renderAfterText) int imageId = trackKitty ? GetKittyImageId (command.Id!, rectIndex) : 0; SetCursorPositionImpl (visibleCells.X, visibleCells.Y); + if (trackKitty) + { + _kittyImagesTouchedThisFrame.Add (imageId); + } + WriteEncodedString (GetRasterImageData (command, visibleCells, pixels, imageId)); if (trackKitty) @@ -1138,6 +1232,7 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source // Double-buffer: transmit the new pixels to the OTHER image id, so the image currently on // screen stays intact while the new one loads; the new placement is then drawn on top below. imageId = previousImageId == idA ? idB : idA; + _kittyImagesTouchedThisFrame.Add (imageId); WriteEncodedString (new KittyGraphicsEncoder ().EncodeTransmit (command.Pixels!, imageId)); _kittyTransmittedImage [command.Id!] = command.Pixels!; _kittyTransmittedVersion [command.Id!] = command.SourceVersion; @@ -1169,7 +1264,7 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source // pixels were visible right up to this point, so the swap never blanks. if (pixelsChanged && previousImageId != 0 && previousImageId != imageId) { - WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacements (previousImageId)); + DeleteKittyImage (previousImageId); } // Fewer fragments than last frame (e.g. a clip hole closed): delete the now-unused placements so @@ -1180,6 +1275,7 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source { if (!placementIds.Contains (pid)) { + _kittyImagesTouchedThisFrame.Add (imageId); WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacement (imageId, pid)); } } diff --git a/Terminal.Gui/Drivers/Output/OutputDirtyState.cs b/Terminal.Gui/Drivers/Output/OutputDirtyState.cs new file mode 100644 index 0000000000..93f7cef6c5 --- /dev/null +++ b/Terminal.Gui/Drivers/Output/OutputDirtyState.cs @@ -0,0 +1,75 @@ +namespace Terminal.Gui.Drivers; + +// Journals cleared flags so a failed frame can be retried without dirtying untouched inline cells. +// Output buffers are owned by the UI thread for the duration of a frame. +internal struct OutputDirtyState +{ + private readonly Cell [,]? _contents; + private readonly bool [] _dirtyLines; + private readonly int [] _changes; + private readonly int _stride; + private int _count; + + public OutputDirtyState (IOutputBuffer buffer, ref int []? reusableChanges) + { + _contents = buffer.Contents; + _dirtyLines = buffer.DirtyLines; + _stride = checked (buffer.Cols + 1); + int length = checked (buffer.Rows * _stride); + _count = 0; + + if (length > Utf8Buffer.RetainedCapacityLimit / sizeof (int)) + { + _changes = new int [length]; + return; + } + + if (reusableChanges is null || reusableChanges.Length < length) + { + reusableChanges = new int [length]; + } + + _changes = reusableChanges; + } + + public void ClearCell (int row, int col) + { + if (!_contents! [row, col].IsDirty) + { + return; + } + + _changes [_count] = row * _stride + col; + _count++; + _contents [row, col].IsDirty = false; + } + + public void ClearLine (int row) + { + if (!_dirtyLines [row]) + { + return; + } + + _changes [_count] = row * _stride + _stride - 1; + _count++; + _dirtyLines [row] = false; + } + + public readonly void Restore () + { + for (int index = 0; index < _count; index++) + { + int row = _changes [index] / _stride; + int col = _changes [index] % _stride; + + if (col == _stride - 1) + { + _dirtyLines [row] = true; + continue; + } + + _contents! [row, col].IsDirty = true; + } + } +} diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs index c9ef4b426f..0421fbfdcb 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Runtime.InteropServices; namespace Terminal.Gui.Drivers; @@ -14,7 +15,7 @@ namespace Terminal.Gui.Drivers; /// Console APIs. Ensure ENABLE_PROCESSED_OUTPUT is set when using this flag /// /// -internal sealed class WindowsVTOutputHelper : IDisposable +internal sealed partial class WindowsVTOutputHelper : IDisposable { #region P/Invoke Declarations @@ -30,8 +31,13 @@ internal sealed class WindowsVTOutputHelper : IDisposable [DllImport ("kernel32.dll")] private static extern uint GetLastError (); - [DllImport ("kernel32.dll")] - public static extern bool WriteFile (nint hConsoleHandle, byte [] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, nint lpOverlapped); + [LibraryImport ("kernel32.dll", SetLastError = true)] + [return: MarshalAs (UnmanagedType.Bool)] + private static partial bool WriteFile (nint hConsoleHandle, + ReadOnlySpan lpBuffer, + uint nNumberOfBytesToWrite, + out uint lpNumberOfBytesWritten, + nint lpOverlapped); [DllImport ("kernel32.dll", SetLastError = true)] [return: MarshalAs (UnmanagedType.Bool)] @@ -42,10 +48,10 @@ internal sealed class WindowsVTOutputHelper : IDisposable private uint _originalConsoleMode; private bool _disposed; - // PERF: Reusable buffers to avoid per-write allocations (string + byte[]). - // Grows as needed; stable-state zero-allocation writes. - private char []? _charBuffer; - private byte []? _byteBuffer; + internal delegate bool WriteFileDelegate (ReadOnlySpan bytes, out uint written, out int error); + private readonly WriteFileDelegate? _writer; + + internal WindowsVTOutputHelper (WriteFileDelegate? writer = null) => _writer = writer; /// /// Gets whether VTS output mode was successfully enabled. @@ -172,55 +178,39 @@ public void Dispose () _disposed = true; } - public void Write (StringBuilder output) - { - int charLen = output.Length; - if (charLen == 0) return; - - // Ensure char buffer is large enough - if (_charBuffer is null || _charBuffer.Length < charLen) - { - _charBuffer = new char [charLen]; - } - - // Copy StringBuilder contents to reusable char[] — avoids allocating a string - output.CopyTo (0, _charBuffer, 0, charLen); + public void Write (StringBuilder output) => Write (Encoding.UTF8.GetBytes (output.ToString ())); - // Calculate UTF-8 byte count - int byteLen = Encoding.UTF8.GetByteCount (_charBuffer, 0, charLen); - - // Ensure byte buffer is large enough - if (_byteBuffer is null || _byteBuffer.Length < byteLen) - { - _byteBuffer = new byte [byteLen]; - } - - // Encode to reusable byte[] — avoids allocating a new byte[] each call - Encoding.UTF8.GetBytes (_charBuffer, 0, charLen, _byteBuffer, 0); - - WriteFile (OutputHandle, _byteBuffer, (uint)byteLen, out _, nint.Zero); - } - - /// - /// PERF: Writes pre-encoded UTF-8 bytes directly to the console via WriteFile. - /// Zero conversion — bytes are written as-is. - /// + /// Writes UTF-8 directly, retrying short writes without copying their tail. public void Write (ReadOnlySpan utf8) { - if (utf8.IsEmpty) + while (!utf8.IsEmpty) { - return; - } + uint written; + int error; + bool succeeded; - // Ensure byte buffer is large enough - if (_byteBuffer is null || _byteBuffer.Length < utf8.Length) - { - _byteBuffer = new byte [utf8.Length]; - } + if (_writer is { }) + { + succeeded = _writer (utf8, out written, out error); + } + else + { + succeeded = WriteFile (OutputHandle, utf8, (uint)utf8.Length, out written, nint.Zero); + error = Marshal.GetLastWin32Error (); + } - utf8.CopyTo (_byteBuffer); + if (!succeeded) + { + throw new Win32Exception (error); + } - WriteFile (OutputHandle, _byteBuffer, (uint)utf8.Length, out _, nint.Zero); + if (written == 0 || written > utf8.Length) + { + throw new IOException ($"WriteFile reported {written} of {utf8.Length} bytes."); + } + + utf8 = utf8 [(int)written..]; + } } /// diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs new file mode 100644 index 0000000000..98ae66f4b5 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs @@ -0,0 +1,510 @@ +using System.ComponentModel; +using System.Text; + +namespace DriverTests.Output; + +/// Behavioral regressions found during the follow-up output review. +[Collection ("Driver Tests")] +public class OutputReviewRegressionTests +{ + // Codex - GPT-6 + [Fact] + public void LegacyAttributeHook_CanFlushPreviouslyBufferedText () + { + FlushingAttributeOutput output = new (); + OutputBufferImpl buffer = new (); + buffer.SetSize (2, 1); + buffer.CurrentAttribute = new (new Color (1, 0, 0), Color.Black); + buffer.AddStr ("A"); + buffer.CurrentAttribute = new (new Color (2, 0, 0), Color.Black); + buffer.AddStr ("B"); + + output.Write (buffer); + + Assert.Equal (["attribute:1", "text:A", "attribute:2", "text:B"], output.Events); + } + + // Codex - GPT-6 + [Fact] + public void LegacyAttributeHook_SurrogatePairAcrossChunks_IsPreserved () + { + SplitSurrogateOutput output = new (); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + output.Write (buffer); + + Assert.Equal (new string ('a', 15) + "😀X", output.Written.ToString ()); + } + + // Codex - GPT-6 + [Fact] + public void FailedUnixFrame_CanBeRetriedWithoutRedrawingBuffer () + { + bool fail = true; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + fail = false; + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + Assert.Throws (() => output.Write (buffer)); + Assert.True (buffer.DirtyLines [0]); + Assert.True (buffer.Contents! [0, 0].IsDirty); + + output.Write (buffer); + + Assert.Single (writes); + Assert.Contains ("X", writes [0]); + Assert.False (buffer.DirtyLines [0]); + } + + // Codex - GPT-6 + [Fact] + public void ConcreteLegacyStringSinks_ReceiveTextAndImages () + { + LegacyAnsiOutput ansi = new (); + LegacyNetOutput net = new (); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("Ω"); + SixelToRender sixel = new () { SixelData = "IMAGE", ScreenPosition = new (0, 0) }; + ansi.GetSixels ().Enqueue (sixel); + ansi.Write (buffer); + buffer.Move (0, 0); + buffer.AddStr ("Ω"); + sixel.IsDirty = true; + net.GetSixels ().Enqueue (sixel); + net.Write (buffer); + + Assert.Contains ("Ω", ansi.Written.ToString ()); + Assert.Contains ("IMAGE", ansi.Written.ToString ()); + Assert.Contains ("Ω", net.Written.ToString ()); + Assert.Contains ("IMAGE", net.Written.ToString ()); + } + + // Codex - GPT-6 + [Theory] + [InlineData (false)] + [InlineData (true)] + public void FailedFrame_RetriesRasterAndSixel (bool sourceCrop) + { + bool fail = true; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + fail = false; + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }) { UseKittyGraphics = true }; + OutputBufferImpl buffer = new (); + buffer.SetSize (2, 1); + RasterImageCommand image = new () + { + Id = "retry-image", + Pixels = new Color [1, 1] { { new (255, 0, 0) } }, + DestinationCells = new (1, 0, 1, 1), + SourceRect = sourceCrop ? new Rectangle (0, 0, 1, 1) : null + }; + buffer.AddRasterImage (image); + SixelToRender sixel = new () { SixelData = "\u001bPq#0~\u001b\\", ScreenPosition = new (0, 0) }; + output.GetSixels ().Enqueue (sixel); + + Assert.Throws (() => output.Write (buffer)); + Assert.True (image.IsDirty); + Assert.True (sixel.IsDirty); + output.Write (buffer); + + string actual = string.Concat (writes); + Assert.Contains (sourceCrop ? "a=t" : "a=T", actual); + Assert.Contains (sixel.SixelData, actual); + Assert.Contains ("a=d", actual); + Assert.False (image.IsDirty); + Assert.False (sixel.IsDirty); + } + + // Codex - GPT-6 + [Theory] + [InlineData (false)] + [InlineData (true)] + public void FailedFrame_RetriesVanishedKittyDeletion (bool sourceCrop) + { + bool fail = false; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + fail = false; + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }) { UseKittyGraphics = true }; + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddRasterImage (new RasterImageCommand + { + Id = "vanished-image", + Pixels = new Color [1, 1] { { new (255, 0, 0) } }, + DestinationCells = new (0, 0, 1, 1), + SourceRect = sourceCrop ? new Rectangle (0, 0, 1, 1) : null + }); + output.Write (buffer); + writes.Clear (); + buffer.RemoveRasterImage ("vanished-image"); + fail = true; + + Assert.Throws (() => output.Write (buffer)); + output.Write (buffer); + + Assert.Contains ("a=d", string.Concat (writes)); + } + + // Codex - GPT-6 + [Fact] + public void FailedFrame_PreservesInlineCleanCellsAndWideGrapheme () + { + bool fail = true; + AnsiOutput output = new (_ => !fail); + OutputBufferImpl buffer = new () { InlineMode = true }; + buffer.SetSize (4, 3); + buffer.Move (1, 1); + buffer.AddStr ("中"); + bool [,] dirty = new bool [3, 4]; + for (int row = 0; row < 3; row++) + { + for (int col = 0; col < 4; col++) + { + dirty [row, col] = buffer.Contents! [row, col].IsDirty; + } + } + + Assert.Throws (() => output.Write (buffer)); + for (int row = 0; row < 3; row++) + { + for (int col = 0; col < 4; col++) + { + Assert.Equal (dirty [row, col], buffer.Contents! [row, col].IsDirty); + } + } + + Assert.False (buffer.DirtyLines [0]); + Assert.True (buffer.DirtyLines [1]); + Assert.False (buffer.DirtyLines [2]); + fail = false; + output.Write (buffer); + Assert.Contains ("中", output.GetLastOutput ()); + } + + // Codex - GPT-6 + [Fact] + public void FrameAtVisibleCursorPosition_StillRenders () + { + List writes = []; + AnsiOutput output = new (bytes => + { + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }); + output.SetCursor (new Cursor { Position = new (0, 0) }); + writes.Clear (); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + + output.Write (buffer); + + Assert.Contains ("X", string.Concat (writes)); + Assert.False (buffer.DirtyLines [0]); + } + + // Codex - GPT-6 + [Theory] + [InlineData (128 * 1024, 16)] + [InlineData (1024 * 1024, 3)] + public void ManySixels_BoundStagingAndKeepOrder (int payloadSize, int count) + { + int writes = 0; + int total = 0; + int largest = 0; + AnsiOutput output = new (bytes => + { + writes++; + total += bytes.Length; + largest = Math.Max (largest, bytes.Length); + return true; + }) { CaptureOutput = false }; + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + for (int index = 0; index < count; index++) + { + output.GetSixels ().Enqueue (new SixelToRender + { + SixelData = new string ('q', payloadSize), + ScreenPosition = new (0, 0) + }); + } + + output.Write (buffer); + + Assert.True (writes >= 3); + Assert.InRange (largest, 1, Utf8Buffer.RetainedCapacityLimit); + Assert.True (total > 2 * 1024 * 1024); + Assert.InRange (output.RetainedWriteBufferBytes, 0, Utf8Buffer.RetainedCapacityLimit * 2); + } + + // Codex - GPT-6 + [Fact] + public void WindowsVT_ShortWrites_RetryExactUtf8Tail () + { + List received = []; + WindowsVTOutputHelper helper = new ((ReadOnlySpan bytes, out uint written, out int error) => + { + written = (uint)Math.Min (2, bytes.Length); + error = 0; + received.AddRange (bytes [..(int)written].ToArray ()); + return true; + }); + byte [] expected = Encoding.UTF8.GetBytes ("A😀中Z"); + + helper.Write (expected); + + Assert.Equal (expected, received.ToArray ()); + } + + // Codex - GPT-6 + [Theory] + [InlineData (false, 0)] + [InlineData (true, 0)] + [InlineData (true, 10)] + public void WindowsVT_FailureOrInvalidProgress_Throws (bool succeeded, int count) + { + WindowsVTOutputHelper helper = new ((ReadOnlySpan _, out uint written, out int error) => + { + written = (uint)count; + error = 5; + return succeeded; + }); + + if (!succeeded) + { + Assert.Throws (() => helper.Write (new byte [2])); + return; + } + + Assert.Throws (() => helper.Write (new byte [2])); + } + + // Codex - GPT-6 + [Fact] + public void AnsiLegacyAttributeHook_FlushesBeforePhysicalColorChange () + { + List events = []; + PhysicalAttributeOutput output = new (events); + OutputBufferImpl buffer = new (); + buffer.SetSize (2, 1); + buffer.CurrentAttribute = new (new Color (1, 0, 0), Color.Black); + buffer.AddStr ("A"); + buffer.CurrentAttribute = new (new Color (2, 0, 0), Color.Black); + buffer.AddStr ("B"); + + output.Write (buffer); + + Assert.Equal (["attribute:1", "text:A", "attribute:2", "text:B"], events); + } + + // Codex - GPT-6 + [Fact] + public void FailedStyledLinkedFrame_RetryResetsTerminalStateAndCaptureMatchesBytes () + { + int attempts = 0; + List writes = []; + AnsiOutput output = new (bytes => + { + if (++attempts <= 2) + { + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (2, 1); + buffer.CurrentAttribute = new (Color.Red, Color.Black) { Style = TextStyle.Bold | TextStyle.Italic }; + buffer.CurrentUrl = "https://example.org/路径"; + buffer.AddStr ("éX"); + + Assert.Throws (() => output.Write (buffer)); + Assert.Throws (() => output.Write (buffer)); + output.Write (buffer); + + string actual = Assert.Single (writes); + Assert.StartsWith ("\u001b\\" + EscSeqUtils.OSC_EndHyperlink () + EscSeqUtils.CSI_ResetAttributes, actual); + Assert.Contains ("https://example.org/路径", actual); + Assert.Contains ("éX", actual); + Assert.EndsWith (EscSeqUtils.OSC_EndHyperlink (), actual); + Assert.Equal (actual, output.GetLastOutput ()); + } + + // Codex - GPT-6 + [Fact] + public void WindowsVT_PartialWrite_DoesNotAllocateTailArray () + { + int calls = 0; + WindowsVTOutputHelper helper = new ((ReadOnlySpan _, out uint written, out int error) => + { + calls++; + written = 1; + error = 0; + return true; + }); + byte [] backing = new byte [1024 * 1024]; + helper.Write (backing.AsSpan (0, 2)); + calls = 0; + long before = GC.GetAllocatedBytesForCurrentThread (); + + helper.Write (backing.AsSpan (0, 2)); + + long allocated = GC.GetAllocatedBytesForCurrentThread () - before; + Assert.Equal (2, calls); + Assert.InRange (allocated, 0, 128); + } + + // Codex - GPT-6 + [Fact] + public void StreamingFrame_LateFailure_RetriesTextAndLargeSixel () + { + int attempts = 0; + int textWrites = 0; + int imageWrites = 0; + AnsiOutput output = new (bytes => + { + if (++attempts == 2) + { + return false; + } + + if (bytes.IndexOf ((byte)'X') >= 0) textWrites++; + if (bytes.Length > Utf8Buffer.RetainedCapacityLimit) imageWrites++; + return true; + }) { CaptureOutput = false }; + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + SixelToRender sixel = new () + { + SixelData = new string ('q', 2 * Utf8Buffer.RetainedCapacityLimit), + ScreenPosition = new (0, 0) + }; + output.GetSixels ().Enqueue (sixel); + + Assert.Throws (() => output.Write (buffer)); + Assert.Equal (1, textWrites); + Assert.True (buffer.Contents! [0, 0].IsDirty); + Assert.True (sixel.IsDirty); + output.Write (buffer); + + Assert.Equal (2, textWrites); + Assert.Equal (1, imageWrites); + Assert.False (buffer.DirtyLines [0]); + Assert.False (sixel.IsDirty); + } + + private sealed class PhysicalAttributeOutput : AnsiOutput + { + private readonly List _events; + + public PhysicalAttributeOutput (List events) : base (bytes => + { + string text = Encoding.UTF8.GetString (bytes); + if (text.Contains ('A')) events.Add ("text:A"); + if (text.Contains ('B')) events.Add ("text:B"); + return true; + }) => _events = events; + + protected override void AppendOrWriteAttribute (StringBuilder output, + Terminal.Gui.Drawing.Attribute attr, + TextStyle redrawTextStyle) + { + Write (output); + output.Clear (); + _events.Add ($"attribute:{attr.Foreground.R}"); + } + } + + private sealed class LegacyAnsiOutput : AnsiOutput + { + public StringBuilder Written { get; } = new (); + protected override void Write (StringBuilder output) => Written.Append (output); + } + + private sealed class LegacyNetOutput : NetOutput + { + public StringBuilder Written { get; } = new (); + protected override void Write (StringBuilder output) => Written.Append (output); + } + + private class StringOutput : OutputBase + { + public StringBuilder Written { get; } = new (); + + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) => true; + + protected override void Write (StringBuilder output) => Written.Append (output); + } + + private sealed class SplitSurrogateOutput : StringOutput + { + protected override void AppendOrWriteAttribute (StringBuilder output, + Terminal.Gui.Drawing.Attribute attr, + TextStyle redrawTextStyle) + { + output.Append ('a', 15); + output.Append ('\ud83d'); + output.Append ('\ude00'); + } + } + + private sealed class FlushingAttributeOutput : OutputBase + { + public List Events { get; } = []; + + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) => true; + + protected override void Write (StringBuilder output) + { + if (output.Length > 0) + { + Events.Add ($"text:{output}"); + } + } + + protected override void AppendOrWriteAttribute (StringBuilder output, + Terminal.Gui.Drawing.Attribute attr, + TextStyle redrawTextStyle) + { + Write (output); + output.Clear (); + Events.Add ($"attribute:{attr.Foreground.R}"); + } + } +} From 7f85fc0cac9edab0ea83015c21df283e430e1d7d Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 30 Sep 2026 18:20:03 -0500 Subject: [PATCH 37/42] Address Claude review: recover output failures and bound encoding allocations --- .github/workflows/perf-gate.yml | 46 -- Terminal.Gui/App/ApplicationImpl.Screen.cs | 7 + Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 133 +++--- .../Drivers/DotNetDriver/NetOutput.cs | 6 +- Terminal.Gui/Drivers/DriverImpl.cs | 51 +- Terminal.Gui/Drivers/Output/OutputBase.cs | 120 +++-- .../Drivers/UnixHelpers/UnixIOHelper.cs | 23 +- .../Drivers/WindowsDriver/WindowsOutput.cs | 90 ++-- .../WindowsHelpers/WindowsVTOutputHelper.cs | 5 + .../OutputBuffer/OutputWriteBenchmark.cs | 5 +- .../OutputBuffer/UnixRawOutputBenchmark.cs | 34 +- .../Drivers/Output/OutputClaudeReviewTests.cs | 438 ++++++++++++++++++ .../Output/OutputPathRegressionTests.cs | 26 +- .../Output/OutputReviewRegressionTests.cs | 41 +- .../Drivers/Output/Utf8BufferTests.cs | 6 +- .../Drivers/UnixIOHelperTests.cs | 6 +- 16 files changed, 800 insertions(+), 237 deletions(-) create mode 100644 Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml index e4d9bb76d5..88b36db335 100644 --- a/.github/workflows/perf-gate.yml +++ b/.github/workflows/perf-gate.yml @@ -210,49 +210,3 @@ jobs: path: BenchmarkResults/ if-no-files-found: ignore retention-days: 30 - - output-benchmark-pr: - name: Output Benchmarks (Linux, ShortRun) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - timeout-minutes: 20 - env: - DisableRealDriverIO: "1" - - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 10.x - dotnet-quality: 'ga' - - - name: Restore dependencies - run: dotnet restore - - - name: Run output benchmarks - run: | - dotnet run \ - --project Tests/Benchmarks \ - --configuration Release \ - -p:GeneratePackageOnBuild=false \ - --no-restore \ - -- \ - --filter '*OutputWriteBenchmark*' '*UnixRawOutputBenchmark*' \ - --job short \ - --inProcess \ - --exporters json \ - --artifacts ./BenchmarkResults - - - name: Upload output benchmark results - if: always() - uses: actions/upload-artifact@v7 - with: - name: output-benchmark-results-${{ github.sha }} - path: BenchmarkResults/ - if-no-files-found: ignore - retention-days: 14 diff --git a/Terminal.Gui/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs index 8b90d9d7c2..e5492195aa 100644 --- a/Terminal.Gui/App/ApplicationImpl.Screen.cs +++ b/Terminal.Gui/App/ApplicationImpl.Screen.cs @@ -309,6 +309,13 @@ public void LayoutAndDraw (bool forceRedraw = false) Driver?.Refresh (); } + // A failed physical frame leaves output dirty after View.Draw has cleared NeedsDraw. + // Retry those cells without forcing a full repaint (which would erase inline content). + if (!neededLayout && !needsDraw && Driver is DriverImpl { NeedsOutputRetry: true } retryDriver) + { + retryDriver.Refresh (); + } + if (neededLayout || needsDraw) { LayoutAndDrawComplete?.Invoke (this, EventArgs.Empty); diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index c4ae6c6742..dcf079ea8f 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -78,13 +78,6 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) try { - // Ensure the console output code page is UTF-8 (65001). The ANSI driver writes - // UTF-8 encoded bytes via WriteFile; without this, a fresh Windows terminal uses - // its default OEM code page (e.g. 437), causing multi-byte UTF-8 characters - // (box-drawing glyphs, etc.) to be misinterpreted as garbled single-byte characters. - // See https://github.com/tui-cs/Terminal.Gui/issues/4848 - Console.OutputEncoding = Encoding.UTF8; - // Check if we have a real console first if (!IsAttachedToTerminal) { @@ -99,6 +92,13 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) // Initialize platform-specific output helpers if (OperatingSystem.IsWindows ()) { + // Ensure the console output code page is UTF-8 (65001). The ANSI driver writes + // UTF-8 encoded bytes via WriteFile; without this, a fresh Windows terminal uses + // its default OEM code page (e.g. 437), causing multi-byte UTF-8 characters + // (box-drawing glyphs, etc.) to be misinterpreted as garbled single-byte characters. + // See https://github.com/tui-cs/Terminal.Gui/issues/4848 + Console.OutputEncoding = Encoding.UTF8; + _windowsVTOutput = new WindowsVTOutputHelper (); if (!_windowsVTOutput.TryEnable ()) @@ -116,25 +116,11 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) } else { - // duplicate the controlling terminal output fd so we don't mess with it. - // When stdout is redirected this is /dev/tty rather than STDOUT_FILENO, - // allowing TUI rendering to appear on the real terminal even when the - // app's stdout participates in a shell pipeline. - int outputFd = TerminalDevice.OutputFd; - - if (outputFd < 0) - { - Trace.Lifecycle (nameof (AnsiOutput), "Init", "Console output stream is not writable. Running in degraded mode."); - - return; - } - - int fdCopy = UnixIOHelper.dup (outputFd); - - if (fdCopy == -1) + // TerminalDevice owns the controlling terminal descriptor. This output neither + // duplicates nor closes it; redirected stdout still renders through /dev/tty. + if (TerminalDevice.OutputFd < 0) { Trace.Lifecycle (nameof (AnsiOutput), "Init", "Console output stream is not writable. Running in degraded mode."); - return; } @@ -171,15 +157,23 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen) catch (Exception ex) { Trace.Lifecycle (nameof (AnsiOutput), "Init", $"Failed to initialize ANSIOutput: {ex.GetType ().Name}: {ex.Message}. Stack trace: {ex.StackTrace}"); + // Initialization may already have switched buffers or enabled input modes. + // Attempt all restores while the native sink is still available. + Dispose (); _platform = AnsiPlatform.Degraded; } } // Exercises UnixRaw framing without opening a terminal or changing process-wide stdout. - internal AnsiOutput (UnixWriter unixWriter) : this () + internal AnsiOutput (UnixWriter unixWriter, AppModel appModel = AppModel.FullScreen) { + AppModel = appModel; _platform = AnsiPlatform.UnixRaw; _unixWriter = unixWriter; + _lastBuffer = new OutputBufferImpl (); + _currentCursor = new Cursor (); + _consoleSize = new Size (80, 25); + _lastBuffer.SetSize (_consoleSize.Width, _consoleSize.Height); } /// @@ -232,11 +226,11 @@ protected override void Write (StringBuilder output) } /// - protected override void WriteEncodedString (string output) + private protected override void WriteEncodedString (string output) { if (HasCustomStringWriter (typeof (AnsiOutput))) { - Write (new StringBuilder (output)); + WriteLegacyString (new StringBuilder (output)); return; } @@ -252,9 +246,22 @@ public void Write (ReadOnlySpan text) return; } - byte [] encoded = new byte [Encoding.UTF8.GetByteCount (text)]; - Encoding.UTF8.GetBytes (text, encoded); - Write (encoded); + // Encode bounded chunks into reusable storage. Never divide a surrogate pair: + // CaptureUtf8 and downstream sinks may decode each physical chunk independently. + while (!text.IsEmpty) + { + int count = Math.Min (text.Length, 16 * 1024); + + if (count < text.Length && char.IsHighSurrogate (text [count - 1]) && char.IsLowSurrogate (text [count])) + { + count--; + } + + _encodedText.Clear (); + _encodedText.Append (text [..count]); + Write (_encodedText.AsSpan ()); + text = text [count..]; + } } /// @@ -304,7 +311,7 @@ protected override void Write (ReadOnlySpan output) { if (HasCustomStringWriter (typeof (AnsiOutput))) { - Write (new StringBuilder (Encoding.UTF8.GetString (output))); + base.Write (output); return; } @@ -387,9 +394,10 @@ private void WriteDirect (ReadOnlySpan output) // PERF: Batch mode — defer cursor-move sequences to merge with cell content, reducing WriteFile P/Invoke count private readonly Utf8Buffer _pendingCursorMoves = new (); private readonly Utf8Buffer _frameBuffer = new (); + private readonly Utf8Buffer _encodedText = new (); private bool _batchMode; - internal int RetainedWriteBufferBytes => _pendingCursorMoves.Capacity + _frameBuffer.Capacity; + internal int RetainedWriteBufferBytes => _pendingCursorMoves.Capacity + _frameBuffer.Capacity + _encodedText.Capacity; /// public Cursor GetCursor () => _currentCursor; @@ -412,17 +420,13 @@ public void SetCursor (Cursor cursor) Write (EscSeqUtils.CSI_ShowCursor); } - } - catch - { - // Ignore any exceptions - } - finally - { SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); - _currentCursor = cursor; } + catch (Exception ex) when (ex is IOException or System.ComponentModel.Win32Exception) + { + Logging.Error ($"Error updating ANSI cursor: {ex.Message}"); + } } /// @@ -493,44 +497,33 @@ public void HandleSizeQueryResponse (string? response) /// public void Dispose () { - try + if (_platform == AnsiPlatform.Degraded) { - if (_platform == AnsiPlatform.Degraded) - { - return; - } + return; + } - // Restore terminal state: disable mouse, reset attributes, show cursor - Write (EscSeqUtils.CSI_DisableBracketedPaste); - Write (EscSeqUtils.CSI_DisableMouseEvents); - Write (EscSeqUtils.CSI_ResetAttributes); + try + { + Restore (EscSeqUtils.CSI_DisableBracketedPaste); + Restore (EscSeqUtils.CSI_DisableMouseEvents); + Restore (EscSeqUtils.CSI_ResetAttributes); if (AppModel == AppModel.Inline) { - // Inline mode: do NOT restore alternate buffer. Move cursor to just - // below the inline region so the shell prompt appears naturally. - // Position to the last row of the inline region (guaranteed to exist), - // then write \n to scroll the terminal if at the bottom edge. Rectangle appScreen = AppScreenGetter?.Invoke () ?? default; - int lastInlineRow = appScreen.Y + appScreen.Height; // 1-indexed last row - Write (EscSeqUtils.CSI_SetCursorPosition (lastInlineRow, 1)); - Write ("\n"); + Restore (EscSeqUtils.CSI_SetCursorPosition (appScreen.Y + appScreen.Height, 1)); + Restore ("\n"); } else { - // FullScreen mode: restore alternate buffer and show cursor - Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + Restore (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); } - Write (EscSeqUtils.CSI_ShowCursor); - } - catch - { - // Ignore errors - we're shutting down + Restore (EscSeqUtils.CSI_ShowCursor); } finally { - Trace.Lifecycle (nameof (AnsiOutput), "Dispose", "Flushing output and releasing resources."); + Trace.Lifecycle (nameof (AnsiOutput), "Dispose", "Releasing output resources."); if (_platform == AnsiPlatform.WindowsVT) { @@ -539,5 +532,17 @@ public void Dispose () _windowsVTOutput?.Dispose (); } + + void Restore (string sequence) + { + try + { + Write (sequence.AsSpan ()); + } + catch (Exception ex) + { + Logging.Error ($"Error restoring ANSI terminal: {ex.Message}"); + } + } } } diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index 5f0f395ea2..4ed7bf55e5 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -112,11 +112,11 @@ protected override void Write (StringBuilder output) } /// - protected override void WriteEncodedString (string output) + private protected override void WriteEncodedString (string output) { if (HasCustomStringWriter (typeof (NetOutput))) { - Write (new StringBuilder (output)); + WriteLegacyString (new StringBuilder (output)); return; } @@ -142,7 +142,7 @@ protected override void Write (ReadOnlySpan output) { if (HasCustomStringWriter (typeof (NetOutput))) { - Write (new StringBuilder (Encoding.UTF8.GetString (output))); + base.Write (output); return; } diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 09cf7a1d78..8a8b09a812 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.ComponentModel; using System.Runtime.InteropServices; using Terminal.Gui.Tracing; @@ -92,20 +93,34 @@ public DriverImpl (IComponentFactory componentFactory, /// public void Refresh () { - // Hide cursor during rendering to prevent flicker - Cursor cursor = _output.GetCursor (); + try + { + // Hide cursor during rendering to prevent flicker. + Cursor cursor = _output.GetCursor (); + + if (cursor.IsVisible) + { + Cursor hiddenCursor = cursor with { Position = null, Style = cursor.Style }; + _output.SetCursor (hiddenCursor); + SetCursorNeedsUpdate (true); + } - if (cursor.IsVisible) + _output.Write (_outputBuffer); + NeedsOutputRetry = false; + } + catch (Exception ex) when (ex is IOException or Win32Exception) { - Cursor hiddenCursor = cursor with { Position = null, Style = cursor.Style }; - _output.SetCursor (hiddenCursor); - SetCursorNeedsUpdate (true); + // OutputBase has restored the exact dirty cells and invalidated graphics state. + // Retry even if no view requests another draw; keep input and dispatch responsive. + NeedsOutputRetry = true; + Logging.Error ($"Error refreshing terminal output: {ex.Message}"); } - _output.Write (_outputBuffer); - // Cursor visibility restored by ApplicationMainLoop to reduce flicker + // Cursor visibility restored by ApplicationMainLoop to reduce flicker. } + internal bool NeedsOutputRetry { get; private set; } + /// public string? GetName () => _componentFactory.GetDriverName (); @@ -449,7 +464,25 @@ public Attribute SetAttribute (Attribute newAttribute) public Attribute GetAttribute () => _outputBuffer.CurrentAttribute; /// - public void WriteRaw (string ansi) => _output.Write (ansi); + public void WriteRaw (string ansi) + { + try + { + _output.Write (ansi); + } + catch (Exception ex) when (ex is IOException or Win32Exception) + { + // Startup queries, terminal titles and inline scrolling use this path outside + // Refresh. A failed query must not terminate the main loop either. + if (_output is OutputBase outputBase) + { + outputBase.InvalidatePhysicalState (_outputBuffer); + } + + NeedsOutputRetry = true; + Logging.Error ($"Error writing raw terminal output: {ex.Message}"); + } + } /// public void SetTerminalTitle (string title, int mode = 0) diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 5817bb7515..3dfa8210e9 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -14,6 +14,9 @@ protected OutputBase () { IsAttachedToTerminal = Driver.IsAttachedToTerminal (out _, out _); CaptureOutput = !IsAttachedToTerminal; + // Bind the virtual methods once: old subclasses can flush before a physical color change + // or provide their entire sink through StringBuilder. Preserve both contracts without + // converting every built-in frame back to text or using reflection on each cell. Action attributeWriter = AppendOrWriteAttribute; _usesLegacyAttributeWriter = attributeWriter.Method.DeclaringType != typeof (OutputBase); Action stringWriter = Write; @@ -23,11 +26,11 @@ protected OutputBase () private readonly bool _usesLegacyAttributeWriter; private readonly Type? _stringWriterType; - /// Gets whether a derived class overrides a built-in StringBuilder sink. - protected bool HasCustomStringWriter (Type builtInType) => _stringWriterType != builtInType; + /// Gets whether a derived StringBuilder sink needs forwarding outside the current compatibility call. + private protected bool HasCustomStringWriter (Type builtInType) => !_forwardingLegacyWrite && _stringWriterType != builtInType; /// Gets whether a derived output uses the original StringBuilder attribute hook. - protected bool UsesLegacyAttributeWriter => _usesLegacyAttributeWriter; + private protected bool UsesLegacyAttributeWriter => _usesLegacyAttributeWriter; /// /// Gets whether this output instance is attached to a real terminal device. @@ -93,14 +96,33 @@ public bool IsLegacyConsole private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; - private readonly ConcurrentBag _utf8Buffers = []; - private int _retainedBufferCount; + private readonly Utf8Buffer _rowBuffer = new (); + private bool _forwardingLegacyWrite; /// /// Enables capture for . Headless outputs capture by default /// for tests; terminal-attached outputs must opt in to avoid decoding every write. + /// To configure capture, use the concrete implementation; + /// does not require this optional diagnostic feature. + /// Disabling capture clears previously captured text and releases its storage. /// - public bool CaptureOutput { get; set; } + public bool CaptureOutput + { + get; + set + { + field = value; + + if (value) + { + return; + } + + _lastOutputStringBuilder.Clear (); + _lastOutputStringBuilder.Capacity = 0; + _clearLastOutputPending = false; + } + } // Kitty image ids placed on the previous Write, keyed by RasterImageCommand.Id. A single image // can occupy more than one placement when its visible region is fragmented by clipping (e.g. a @@ -171,8 +193,7 @@ public virtual void Write (IOutputBuffer buffer) catch { dirtyState.Restore (); - RecoverRasterState (buffer); - _recoverOutputState = true; + InvalidatePhysicalState (buffer); throw; } } @@ -185,6 +206,13 @@ internal virtual void FlushFrame () { } private readonly HashSet _kittyImagesTouchedThisFrame = []; private readonly HashSet _kittyDeletesToRetry = []; + // Raw driver writes can also fail midway through an OSC/DCS control string. + internal void InvalidatePhysicalState (IOutputBuffer buffer) + { + RecoverRasterState (buffer); + _recoverOutputState = true; + } + private void RecoverRasterState (IOutputBuffer buffer) { _kittyDeletesToRetry.UnionWith (_kittyImagesTouchedThisFrame); @@ -216,7 +244,7 @@ private void RecoverRasterState (IOutputBuffer buffer) private void WriteFrame (IOutputBuffer buffer, ref OutputDirtyState dirtyState) { - using Utf8BufferLease lease = new (this); + using Utf8BufferLease lease = new (_rowBuffer); Utf8Buffer outputBuffer = lease.Buffer; var top = 0; var left = 0; @@ -418,39 +446,15 @@ private void WriteFrame (IOutputBuffer buffer, ref OutputDirtyState dirtyState) } } - private readonly struct Utf8BufferLease : IDisposable + // Writes are serialized by the main loop; a finally scope also releases oversized row buffers. + private readonly struct Utf8BufferLease (Utf8Buffer buffer) : IDisposable { - private readonly OutputBase _owner; - - public Utf8BufferLease (OutputBase owner) - { - _owner = owner; - - if (owner._utf8Buffers.TryTake (out Utf8Buffer? reusable)) - { - Interlocked.Decrement (ref owner._retainedBufferCount); - Buffer = reusable; - } - else - { - Buffer = new (); - } - } - - public Utf8Buffer Buffer { get; } + public Utf8Buffer Buffer { get; } = buffer; public void Dispose () { Buffer.Clear (); Buffer.TrimExcess (); - - if (Interlocked.Increment (ref _owner._retainedBufferCount) == 1) - { - _owner._utf8Buffers.Add (Buffer); - return; - } - - Interlocked.Decrement (ref _owner._retainedBufferCount); } } @@ -605,19 +609,51 @@ protected virtual void Write (StringBuilder output) /// UTF-8 encoded bytes to write. protected virtual void Write (ReadOnlySpan output) { - // A subclass built before #5650 may only override Write(StringBuilder). - // Route bytes through that virtual sink so rendered cells cannot disappear. - Write (new StringBuilder (Encoding.UTF8.GetString (output))); + // A legacy StringBuilder override may forward encoded bytes back to this overload. + // Its re-entry reaches the original base capture sink, rather than the same override. + if (_stringWriterType == typeof (OutputBase) || _forwardingLegacyWrite) + { + CaptureUtf8 (output); + return; + } + + WriteLegacyString (new StringBuilder (Encoding.UTF8.GetString (output))); } /// /// Sends an already constructed raster or control sequence. The default preserves /// the pre-#5650 StringBuilder sink for downstream output implementations. /// - protected virtual void WriteEncodedString (string output) => Write (new StringBuilder (output)); + private protected virtual void WriteEncodedString (string output) + { + if (_stringWriterType == typeof (OutputBase) || _forwardingLegacyWrite) + { + CaptureText (output.AsSpan ()); + return; + } + + WriteLegacyString (new StringBuilder (output)); + } + + // Covers both byte fallback and raster strings. Concrete built-in sinks also use this scope + // so a StringBuilder override can forward bytes to their native implementation once. + private protected void WriteLegacyString (StringBuilder output) + { + bool wasForwarding = _forwardingLegacyWrite; + _forwardingLegacyWrite = true; + + try + { + Write (output); + } + finally + { + _forwardingLegacyWrite = wasForwarding; + } + } /// Captures a UTF-8 write when output capture is enabled. - protected void CaptureUtf8 (ReadOnlySpan output) + private protected void CaptureUtf8 (ReadOnlySpan output) { if (!CaptureOutput) { @@ -628,7 +664,7 @@ protected void CaptureUtf8 (ReadOnlySpan output) } /// Captures decoded output when output capture is enabled. - protected void CaptureText (ReadOnlySpan output) + private protected void CaptureText (ReadOnlySpan output) { if (!CaptureOutput) { diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 5acda59e23..90cfaf3b51 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -365,15 +365,36 @@ public static bool TryWriteStdout (ReadOnlySpan buffer) internal delegate nint WriteBytes (int fd, ReadOnlySpan buffer); - internal static bool TryWriteAll (int fd, ReadOnlySpan buffer, WriteBytes writeFunc) + internal static bool TryWriteAll (int fd, ReadOnlySpan buffer, WriteBytes writeFunc, Func? getError = null) { int offset = 0; + int transientFailures = 0; + int wouldBlock = OperatingSystem.IsMacOS () || OperatingSystem.IsFreeBSD () ? 35 : 11; while (offset < buffer.Length) { ReadOnlySpan remaining = buffer [offset..]; nint written = writeFunc (fd, remaining); + if (written == -1) + { + int error = getError?.Invoke () ?? Marshal.GetLastPInvokeError (); + + // Bound the entire call, including intermittent progress, so a slow or broken + // nonblocking terminal cannot monopolize the UI thread. The next frame retries. + if ((error == 4 || error == wouldBlock) && transientFailures++ < 8) + { + if (error == wouldBlock) + { + Thread.Sleep (1); + } + + continue; + } + + return false; + } + if (written <= 0 || written > remaining.Length) { return false; diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs index 614075ff52..bbcf454df4 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -18,19 +18,43 @@ internal static void ValidateConsoleWriteResult (bool succeeded, uint written, u { if (!succeeded) { + if (error == 0) + { + throw new IOException ("WriteConsoleW failed without an error code."); + } + throw new Win32Exception (error); } - if (written != expected) + if (written == 0 || written > expected) { throw new IOException ($"WriteConsoleW wrote {written} of {expected} characters."); } } - private static void WriteConsoleChecked (nint handle, ReadOnlySpan text) + internal delegate bool ConsoleWriter (nint handle, ReadOnlySpan text, out uint written, out int error); + + internal static void WriteConsoleChecked (nint handle, ReadOnlySpan text, ConsoleWriter? writer = null) { - bool succeeded = WriteConsole (handle, text, (uint)text.Length, out uint written, nint.Zero); - ValidateConsoleWriteResult (succeeded, written, (uint)text.Length, Marshal.GetLastWin32Error ()); + while (!text.IsEmpty) + { + bool succeeded; + uint written; + int error; + + if (writer is { }) + { + succeeded = writer (handle, text, out written, out error); + } + else + { + succeeded = WriteConsole (handle, text, (uint)text.Length, out written, nint.Zero); + error = Marshal.GetLastWin32Error (); + } + + ValidateConsoleWriteResult (succeeded, written, (uint)text.Length, error); + text = text [(int)written..]; + } } [LibraryImport ("kernel32.dll", SetLastError = true)] @@ -223,15 +247,12 @@ public void SetCursor (Cursor cursor) Write (EscSeqUtils.CSI_ShowCursor); } } + SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); + _currentCursor = cursor; } catch { - // Ignore any exceptions - } - finally - { - SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); - _currentCursor = cursor; + // Cursor updates are best effort, including the final position write. } } @@ -410,7 +431,7 @@ protected override void Write (ReadOnlySpan output) } /// - protected override void WriteEncodedString (string output) + private protected override void WriteEncodedString (string output) { CaptureText (output.AsSpan ()); @@ -576,35 +597,46 @@ public void Dispose () return; } - if (IsLegacyConsole) + try { - if (_screenBuffer != nint.Zero) + if (IsLegacyConsole) { - CloseHandle (_screenBuffer); + if (_screenBuffer != nint.Zero) + { + CloseHandle (_screenBuffer); + } + + _screenBuffer = nint.Zero; + return; } - _screenBuffer = nint.Zero; - } - else - { if (Environment.GetEnvironmentVariable ("VSAPPIDNAME") is null) { - //Disable alternative screen buffer. - Console.Out.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + Restore (() => Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll)); + Restore (() => Write (EscSeqUtils.CSI_ShowCursor)); + return; + } - //Set cursor key to cursor. - Write (EscSeqUtils.CSI_ShowCursor); + Restore (() => Console.ForegroundColor = _foreground); + Restore (() => Console.BackgroundColor = _background); + Restore (Console.Clear); + } + finally + { + _isDisposed = true; + } + + static void Restore (Action restore) + { + try + { + restore (); } - else + catch (Exception ex) { - // Simulate restoring the color and clearing the screen. - Console.ForegroundColor = _foreground; - Console.BackgroundColor = _background; - Console.Clear (); + Logging.Error ($"Error restoring Windows console: {ex.Message}"); } } - - _isDisposed = true; } /// diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs index 0421fbfdcb..b14e1cf438 100644 --- a/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs @@ -201,6 +201,11 @@ public void Write (ReadOnlySpan utf8) if (!succeeded) { + if (error == 0) + { + throw new IOException ("WriteFile failed without an error code."); + } + throw new Win32Exception (error); } diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs index 42a1767d16..5988a68013 100644 --- a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs @@ -18,7 +18,6 @@ namespace Terminal.Gui.Benchmarks.ConsoleDrivers.OutputBuffer; /// /// [MemoryDiagnoser] -[InvocationCount (1)] [BenchmarkCategory ("Output", "Latency")] public class OutputWriteBenchmark { @@ -54,7 +53,6 @@ public void Setup () /// /// Marks every cell dirty before measuring a full-frame flush. /// - [IterationSetup (Target = nameof (FullFrame))] public void PrepareFullFrame () { MarkFullFrame (_fullBuffer); @@ -64,7 +62,6 @@ public void PrepareFullFrame () /// /// Marks only the first and last cells of every row dirty before measuring a sparse flush. /// - [IterationSetup (Target = nameof (SparseBorderFrame))] public void PrepareSparseBorderFrame () { MarkSparseBorderFrame (_sparseBuffer); @@ -77,6 +74,7 @@ public void PrepareSparseBorderFrame () [Benchmark (Baseline = true)] public long FullFrame () { + PrepareFullFrame (); _output.Write (_fullBuffer); if (_output.Writes == 0 || _output.PayloadBytesWritten == 0) @@ -93,6 +91,7 @@ public long FullFrame () [Benchmark] public long SparseBorderFrame () { + PrepareSparseBorderFrame (); _output.Write (_sparseBuffer); if (_output.Writes == 0 || _output.PayloadBytesWritten == 0) diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs index c2e68ea047..06d32e66c7 100644 --- a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs @@ -1,16 +1,17 @@ using BenchmarkDotNet.Attributes; using Terminal.Gui.Drivers; +using Terminal.Gui.Drawing; namespace Terminal.Gui.Benchmarks.ConsoleDrivers.OutputBuffer; /// Measures the complete UnixRaw frame writer without terminal I/O. [MemoryDiagnoser] -[InvocationCount (1)] [BenchmarkCategory ("Output", "Latency")] public class UnixRawOutputBenchmark { private OutputBufferImpl _small = null!; private OutputBufferImpl _large = null!; + private OutputBufferImpl _colored = null!; private AnsiOutput _output = null!; private long _bytes; private int _writes; @@ -21,6 +22,15 @@ public void Setup () { _small = CreateBuffer (80, 25); _large = CreateBuffer (240, 70); + _colored = CreateBuffer (400, 110); + + for (int row = 0; row < _colored.Rows; row++) + { + for (int col = 0; col < _colored.Cols; col++) + { + _colored.Contents! [row, col].Attribute = new (new Color (col % 256, row % 256, (col + row) % 256), Color.Black); + } + } _output = new (bytes => { _writes++; @@ -36,10 +46,10 @@ public void Setup () } _output.Write (_large); + _output.Write (_colored); } /// Marks the 80×25 frame dirty before each measurement. - [IterationSetup (Target = nameof (SmallFrame))] public void PrepareSmall () { MarkDirty (_small); @@ -48,7 +58,6 @@ public void PrepareSmall () } /// Marks the 240×70 frame dirty before each measurement. - [IterationSetup (Target = nameof (LargeFrame))] public void PrepareLarge () { MarkDirty (_large); @@ -60,6 +69,7 @@ public void PrepareLarge () [Benchmark] public long SmallFrame () { + PrepareSmall (); _output.Write (_small); if (_writes != 1) @@ -74,6 +84,7 @@ public long SmallFrame () [Benchmark] public long LargeFrame () { + PrepareLarge (); _output.Write (_large); if (_writes != 1) @@ -84,6 +95,23 @@ public long LargeFrame () return _bytes; } + /// Flushes a 400×110 frame with a truecolor change at every cell. + [Benchmark] + public long PerCellColorFrame () + { + MarkDirty (_colored); + _writes = 0; + _bytes = 0; + _output.Write (_colored); + + if (_writes < 2 || _bytes <= 1024 * 1024) + { + throw new InvalidOperationException ("The colored frame did not exercise cumulative staging."); + } + + return _bytes; + } + private static OutputBufferImpl CreateBuffer (int cols, int rows) { OutputBufferImpl buffer = new (); diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs new file mode 100644 index 0000000000..00c7eb68c3 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs @@ -0,0 +1,438 @@ +using System.ComponentModel; +using System.Text; + +namespace DriverTests.Output; + +[Collection ("Driver Tests")] +public class OutputClaudeReviewTests +{ + // CoPilot - GPT-6 (generated by Codex) + [Theory] + [InlineData (4)] + [InlineData (11)] + [InlineData (35)] + public void UnixTransientFailure_RetriesExactUnwrittenTail (int error) + { + if (error != 4 && error != (OperatingSystem.IsMacOS () || OperatingSystem.IsFreeBSD () ? 35 : 11)) + { + return; + } + + List tails = []; + int calls = 0; + bool success = UnixIOHelper.TryWriteAll (1, "ABC"u8, (_, bytes) => + { + tails.Add (Encoding.UTF8.GetString (bytes)); + calls++; + return calls == 2 ? -1 : 1; + }, () => error); + + Assert.True (success); + Assert.Equal (["ABC", "BC", "BC", "C"], tails); + } + + // CoPilot - GPT-6 (generated by Codex) + [Theory] + [InlineData (4)] + [InlineData (5)] + public void UnixRepeatedFailure_IsBounded (int error) + { + int calls = 0; + Assert.False (UnixIOHelper.TryWriteAll (1, "A"u8, (_, _) => { calls++; return -1; }, () => error)); + Assert.Equal (error == 4 ? 9 : 1, calls); + } + + // CoPilot - GPT-6 (generated by Codex) + [Theory] + [InlineData (false)] + [InlineData (true)] + public void AnsiDispose_AttemptsEveryRestoreAfterFailure (bool inline) + { + List writes = []; + AnsiOutput output = new (bytes => + { + writes.Add (Encoding.UTF8.GetString (bytes)); + return false; + }, inline ? AppModel.Inline : AppModel.FullScreen) { AppScreenGetter = () => new (0, 3, 10, 2) }; + + output.Dispose (); + + Assert.Contains (EscSeqUtils.CSI_DisableBracketedPaste, writes); + Assert.Contains (EscSeqUtils.CSI_DisableMouseEvents, writes); + Assert.Contains (EscSeqUtils.CSI_ResetAttributes, writes); + Assert.Contains (EscSeqUtils.CSI_ShowCursor, writes); + Assert.Contains (inline ? "\n" : EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll, writes); + Assert.Equal (inline ? 6 : 5, writes.Count); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void AnsiCursor_PositionFailureDoesNotEscapeFinally () + { + int calls = 0; + AnsiOutput output = new (_ => ++calls == 1); + output.SetCursor (new () { Position = new (0, 0) }); + Assert.Equal (2, calls); + } + + // CoPilot - GPT-6 (generated by Codex) + [Theory] + [InlineData (false)] + [InlineData (true)] + public void DriverRefresh_ContainsNativeErrorAndRetriesDirtyCells (bool windowsFailure) + { + bool fail = true; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + if (windowsFailure) + { + throw new Win32Exception (5); + } + + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + using DriverImpl driver = CreateDriver (output, buffer); + + driver.Refresh (); + Assert.True (driver.NeedsOutputRetry); + Assert.True (buffer.Contents! [0, 0].IsDirty); + fail = false; + driver.Refresh (); + Assert.False (driver.NeedsOutputRetry); + Assert.Contains ("X", Assert.Single (writes)); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void DriverRefresh_ProgrammingErrorStillEscapes () + { + AnsiOutput output = new (_ => throw new InvalidOperationException ("sink bug")); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + using DriverImpl driver = CreateDriver (output, buffer); + Assert.Throws (driver.Refresh); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void ApplicationDraw_RetriesPhysicalFailureWithoutAnotherViewDraw () + { + using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI); + IDriver originalDriver = app.Driver!; + bool fail = false; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (10, 2); + using DriverImpl driver = CreateDriver (output, buffer); + app.Driver = driver; + using Runnable runnable = new () { Width = 10, Height = 2 }; + SessionToken owner = app.Begin (runnable)!; + + try + { + app.LayoutAndDraw (); + writes.Clear (); + buffer.Move (0, 0); + buffer.AddStr ("RECOVER"); + fail = true; + driver.Refresh (); + Assert.True (driver.NeedsOutputRetry); + Assert.False (runnable.NeedsDraw); + Assert.False (runnable.SubViewNeedsDraw); + fail = false; + app.LayoutAndDraw (); + Assert.False (driver.NeedsOutputRetry); + Assert.Contains ("RECOVER", Assert.Single (writes)); + } + finally + { + app.End (owner); + app.Driver = originalDriver; + } + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void WindowsConsole_PartialWritesUseOnlyRemainingCharacters () + { + List tails = []; + StringBuilder received = new (); + WindowsOutput.WriteConsoleChecked (0, "A😀中".AsSpan (), (nint _, ReadOnlySpan text, out uint written, out int error) => + { + tails.Add (text.ToString ()); + received.Append (text [0]); + written = 1; + error = 0; + return true; + }); + Assert.Equal ("A😀中", received.ToString ()); + Assert.Equal (["A😀中", "😀中", "\ude00中", "中"], tails); + } + + // CoPilot - GPT-6 (generated by Codex) + [Theory] + [InlineData (false, 0, 0)] + [InlineData (true, 0, 0)] + [InlineData (true, 2, 0)] + public void WindowsConsole_RejectsFailureAndInvalidProgress (bool success, uint count, int nativeError) + { + Assert.Throws (() => WindowsOutput.WriteConsoleChecked (0, "X".AsSpan (), + (nint _, ReadOnlySpan _, out uint written, out int error) => { written = count; error = nativeError; return success; })); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void LegacyEncodingSink_DoesNotRecurseIntoItsOwnOverride () + { + ForwardingLegacyOutput output = new () { CaptureOutput = true }; + OutputBufferImpl buffer = new (); + buffer.SetSize (3, 1); + buffer.AddStr ("Ω中"); + output.Write (buffer); + Assert.Equal (1, output.Calls); + Assert.Contains ("Ω中", output.GetLastOutput ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void DisabledCapture_ClearsPreviousOutput () + { + AnsiOutput output = new (_ => true) { CaptureOutput = true }; + output.Write ("secret".AsSpan ()); + Assert.Equal ("secret", output.GetLastOutput ()); + output.CaptureOutput = false; + Assert.Empty (output.GetLastOutput ()); + output.Write ("ignored".AsSpan ()); + output.CaptureOutput = true; + Assert.Empty (output.GetLastOutput ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void AnsiLargeText_ReusesBoundedEncodingAndPreservesSurrogateBoundaries () + { + string payload = new string ('a', 16383) + "😀" + new string ('中', 1024 * 1024); + StringBuilder received = new (); + bool collect = false; + AnsiOutput output = new (bytes => + { + if (collect) + { + received.Append (Encoding.UTF8.GetString (bytes)); + } + + return true; + }) { CaptureOutput = false }; + output.Write (payload.AsSpan ()); + long before = GC.GetAllocatedBytesForCurrentThread (); + output.Write (payload.AsSpan ()); + long allocated = GC.GetAllocatedBytesForCurrentThread () - before; + collect = true; + output.Write (payload.AsSpan ()); + Assert.Equal (payload, received.ToString ()); + Assert.InRange (allocated, 0, 1024); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void PerCellColoredFrame_StreamsWithoutRepeatedMegabyteAllocations () + { + int writes = 0; + int bytesWritten = 0; + int largestWrite = 0; + AnsiOutput output = new (bytes => + { + writes++; + bytesWritten += bytes.Length; + largestWrite = Math.Max (largestWrite, bytes.Length); + return true; + }) { CaptureOutput = false }; + OutputBufferImpl buffer = new (); + buffer.SetSize (400, 110); + + for (int row = 0; row < buffer.Rows; row++) + { + for (int col = 0; col < buffer.Cols; col++) + { + buffer.Move (col, row); + buffer.CurrentAttribute = new (new Color (col % 256, row % 256, (row + col) % 256), Color.Black); + buffer.AddRune ('A'); + } + } + + output.Write (buffer); + for (int row = 0; row < buffer.Rows; row++) + { + buffer.DirtyLines [row] = true; + for (int col = 0; col < buffer.Cols; col++) + { + buffer.Contents! [row, col].IsDirty = true; + } + } + + writes = 0; + bytesWritten = 0; + largestWrite = 0; + long before = GC.GetAllocatedBytesForCurrentThread (); + output.Write (buffer); + long allocated = GC.GetAllocatedBytesForCurrentThread () - before; + Assert.True (writes >= 2); + Assert.True (bytesWritten > Utf8Buffer.RetainedCapacityLimit); + Assert.InRange (largestWrite, 1, Utf8Buffer.RetainedCapacityLimit); + // GetColumns currently allocates per grapheme (~12 MB for this frame). Allow that + // existing cost, but reject the additional ~4 MB from repeated oversized staging. + Assert.InRange (allocated, 0, 14 * 1024 * 1024); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void InjectedAnsiConstructor_DoesNotReplaceProcessConsoleWriter () + { + TextWriter before = Console.Out; + using AnsiOutput output = new (_ => true); + Assert.Same (before, Console.Out); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void ConcreteLegacyEncodingSinks_ForwardTextAndRasterOnce () + { + int physicalWrites = 0; + ForwardingAnsiOutput ansi = new (_ => { physicalWrites++; return true; }) { CaptureOutput = true }; + ForwardingNetOutput net = new () { CaptureOutput = true }; + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("Ω"); + ansi.GetSixels ().Enqueue (new () { SixelData = "IMAGE", ScreenPosition = new (0, 0) }); + ansi.Write (buffer); + buffer.Move (0, 0); + buffer.AddStr ("Ω"); + net.GetSixels ().Enqueue (new () { SixelData = "IMAGE", ScreenPosition = new (0, 0) }); + net.Write (buffer); + Assert.Equal (2, ansi.Calls); + Assert.Equal (2, physicalWrites); + Assert.Equal (2, net.Calls); + Assert.Contains ("Ω", ansi.GetLastOutput ()); + Assert.Contains ("IMAGE", ansi.GetLastOutput ()); + Assert.Contains ("Ω", net.GetLastOutput ()); + Assert.Contains ("IMAGE", net.GetLastOutput ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void FailedRawDriverWrite_ClosesUncertainControlStringOnNextRefresh () + { + bool fail = true; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) return false; + writes.Add (Encoding.UTF8.GetString (bytes)); + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + using DriverImpl driver = CreateDriver (output, buffer); + driver.WriteRaw ("\u001b]0;TITLE\u001b\\"); + Assert.True (driver.NeedsOutputRetry); + fail = false; + driver.Refresh (); + Assert.StartsWith ("\u001b\\", Assert.Single (writes)); + Assert.False (driver.NeedsOutputRetry); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void WindowsVt_FailureWithoutErrorCodeIsStillAnIoFailure () + { + WindowsVTOutputHelper output = new ((ReadOnlySpan _, out uint written, out int error) => + { + written = 0; + error = 0; + return false; + }); + Assert.Throws (() => output.Write ("X"u8)); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void DefaultByteSink_WithCaptureDisabled_DoesNotBuildDiscardedStrings () + { + CaptureOnlyOutput output = new () { CaptureOutput = false }; + byte [] payload = new byte [1024 * 1024]; + output.WriteBytes (payload); + long before = GC.GetAllocatedBytesForCurrentThread (); + output.WriteBytes (payload); + Assert.InRange (GC.GetAllocatedBytesForCurrentThread () - before, 0, 128); + Assert.Empty (output.GetLastOutput ()); + } + + private static DriverImpl CreateDriver (AnsiOutput output, OutputBufferImpl buffer) => new ( + new AnsiComponentFactory (), new AnsiInputProcessor (null!), buffer, output, + new (new AnsiResponseParser (new SystemTimeProvider ())), new SizeMonitorImpl (output)); + + private sealed class CaptureOnlyOutput : OutputBase + { + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) => true; + public void WriteBytes (ReadOnlySpan bytes) => Write (bytes); + } + + private sealed class ForwardingAnsiOutput (AnsiOutput.UnixWriter writer) : AnsiOutput (writer) + { + public int Calls { get; private set; } + protected override bool SetCursorPositionImpl (int col, int row) => true; + protected override void Write (StringBuilder output) + { + if (++Calls > 4) throw new InvalidOperationException ("recursive fallback"); + Write (Encoding.UTF8.GetBytes (output.ToString ())); + } + } + + private sealed class ForwardingNetOutput : NetOutput + { + public int Calls { get; private set; } + protected override bool SetCursorPositionImpl (int col, int row) => true; + protected override void Write (StringBuilder output) + { + if (++Calls > 4) throw new InvalidOperationException ("recursive fallback"); + Write (Encoding.UTF8.GetBytes (output.ToString ())); + } + } + + private sealed class ForwardingLegacyOutput : OutputBase + { + public int Calls { get; private set; } + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) => true; + protected override void Write (StringBuilder output) + { + Calls++; + if (Calls > 2) + { + throw new InvalidOperationException ("recursive fallback"); + } + + Write (Encoding.UTF8.GetBytes (output.ToString ())); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs index 60bc14724b..896d842b77 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputPathRegressionTests.cs @@ -7,7 +7,7 @@ namespace DriverTests.Output; [Collection ("Driver Tests")] public class OutputPathRegressionTests { - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void LegacyStringWriter_ReceivesRenderedCells () { @@ -21,7 +21,7 @@ public void LegacyStringWriter_ReceivesRenderedCells () Assert.Contains ("Ω", output.Written.ToString ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void LegacyAttributeHook_IsCalledByUtf8Renderer () { @@ -36,7 +36,7 @@ public void LegacyAttributeHook_IsCalledByUtf8Renderer () Assert.Contains ("X", output.Written.ToString ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_SingleRow_MatchesStringEncoderByteForByte () { @@ -66,7 +66,7 @@ public void UnixRaw_SingleRow_MatchesStringEncoderByteForByte () Assert.Equal (expected, writes [0]); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_TwoRows_AreWrittenAsOneOrderedFrame () { @@ -94,7 +94,7 @@ public void UnixRaw_TwoRows_AreWrittenAsOneOrderedFrame () Assert.Equal (actual, output.GetLastOutput ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_OnlyLastDeferredCursorMoveIsSent () { @@ -119,7 +119,7 @@ public void UnixRaw_OnlyLastDeferredCursorMoveIsSent () Assert.DoesNotContain (EscSeqUtils.CSI_SetCursorPosition (1, 1), actual); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_CharWriteDuringFrame_FollowsDeferredCursorMove () { @@ -143,7 +143,7 @@ public void UnixRaw_CharWriteDuringFrame_FollowsDeferredCursorMove () Assert.True (cursor >= 0 && injected > cursor && cell > injected); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_KittyHyperlinkAndSixel_KeepTheirOrder () { @@ -180,7 +180,7 @@ public void UnixRaw_KittyHyperlinkAndSixel_KeepTheirOrder () Assert.Equal (actual, output.GetLastOutput ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_FailedFrame_DiscardsPendingCursorMoves () { @@ -209,7 +209,7 @@ public void UnixRaw_FailedFrame_DiscardsPendingCursorMoves () Assert.Equal ("Z", Encoding.UTF8.GetString (writes [1])); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_RenderException_DoesNotSendDeferredCursorMove () { @@ -231,7 +231,7 @@ public void UnixRaw_RenderException_DoesNotSendDeferredCursorMove () Assert.Equal ("Z", Encoding.UTF8.GetString (writes [0])); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_UnexpectedWriterException_IsNotHidden () { @@ -243,7 +243,7 @@ public void UnixRaw_UnexpectedWriterException_IsNotHidden () Assert.Throws (() => output.Write (buffer)); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Theory] [InlineData (false, 0, 1, 5)] [InlineData (true, 0, 1, 0)] @@ -261,7 +261,7 @@ public void WindowsWriteResult_RejectsFailureAndPartialWrite (bool succeeded, Assert.Throws (() => WindowsOutput.ValidateConsoleWriteResult (succeeded, written, expected, error)); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void NetAndWindowsByteSinks_CaptureUnicodeWithoutAStringWrite () { @@ -276,7 +276,7 @@ public void NetAndWindowsByteSinks_CaptureUnicodeWithoutAStringWrite () Assert.Equal ("Aé中😀", windows.GetLastOutput ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void UnixRaw_LargeSixel_DoesNotRetainLargeWriteBuffers () { diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs index 98ae66f4b5..dad3e6ba01 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs @@ -7,7 +7,7 @@ namespace DriverTests.Output; [Collection ("Driver Tests")] public class OutputReviewRegressionTests { - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void LegacyAttributeHook_CanFlushPreviouslyBufferedText () { @@ -24,7 +24,7 @@ public void LegacyAttributeHook_CanFlushPreviouslyBufferedText () Assert.Equal (["attribute:1", "text:A", "attribute:2", "text:B"], output.Events); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void LegacyAttributeHook_SurrogatePairAcrossChunks_IsPreserved () { @@ -38,7 +38,7 @@ public void LegacyAttributeHook_SurrogatePairAcrossChunks_IsPreserved () Assert.Equal (new string ('a', 15) + "😀X", output.Written.ToString ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void FailedUnixFrame_CanBeRetriedWithoutRedrawingBuffer () { @@ -70,7 +70,7 @@ public void FailedUnixFrame_CanBeRetriedWithoutRedrawingBuffer () Assert.False (buffer.DirtyLines [0]); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void ConcreteLegacyStringSinks_ReceiveTextAndImages () { @@ -94,7 +94,7 @@ public void ConcreteLegacyStringSinks_ReceiveTextAndImages () Assert.Contains ("IMAGE", net.Written.ToString ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Theory] [InlineData (false)] [InlineData (true)] @@ -139,7 +139,7 @@ public void FailedFrame_RetriesRasterAndSixel (bool sourceCrop) Assert.False (sixel.IsDirty); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Theory] [InlineData (false)] [InlineData (true)] @@ -178,7 +178,7 @@ public void FailedFrame_RetriesVanishedKittyDeletion (bool sourceCrop) Assert.Contains ("a=d", string.Concat (writes)); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void FailedFrame_PreservesInlineCleanCellsAndWideGrapheme () { @@ -214,7 +214,7 @@ public void FailedFrame_PreservesInlineCleanCellsAndWideGrapheme () Assert.Contains ("中", output.GetLastOutput ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void FrameAtVisibleCursorPosition_StillRenders () { @@ -236,7 +236,7 @@ public void FrameAtVisibleCursorPosition_StillRenders () Assert.False (buffer.DirtyLines [0]); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Theory] [InlineData (128 * 1024, 16)] [InlineData (1024 * 1024, 3)] @@ -271,7 +271,7 @@ public void ManySixels_BoundStagingAndKeepOrder (int payloadSize, int count) Assert.InRange (output.RetainedWriteBufferBytes, 0, Utf8Buffer.RetainedCapacityLimit * 2); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void WindowsVT_ShortWrites_RetryExactUtf8Tail () { @@ -290,7 +290,7 @@ public void WindowsVT_ShortWrites_RetryExactUtf8Tail () Assert.Equal (expected, received.ToArray ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Theory] [InlineData (false, 0)] [InlineData (true, 0)] @@ -313,7 +313,7 @@ public void WindowsVT_FailureOrInvalidProgress_Throws (bool succeeded, int count Assert.Throws (() => helper.Write (new byte [2])); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void AnsiLegacyAttributeHook_FlushesBeforePhysicalColorChange () { @@ -331,7 +331,7 @@ public void AnsiLegacyAttributeHook_FlushesBeforePhysicalColorChange () Assert.Equal (["attribute:1", "text:A", "attribute:2", "text:B"], events); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void FailedStyledLinkedFrame_RetryResetsTerminalStateAndCaptureMatchesBytes () { @@ -365,7 +365,7 @@ public void FailedStyledLinkedFrame_RetryResetsTerminalStateAndCaptureMatchesByt Assert.Equal (actual, output.GetLastOutput ()); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void WindowsVT_PartialWrite_DoesNotAllocateTailArray () { @@ -389,13 +389,13 @@ public void WindowsVT_PartialWrite_DoesNotAllocateTailArray () Assert.InRange (allocated, 0, 128); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void StreamingFrame_LateFailure_RetriesTextAndLargeSixel () { int attempts = 0; int textWrites = 0; - int imageWrites = 0; + int imageBytes = 0; AnsiOutput output = new (bytes => { if (++attempts == 2) @@ -404,7 +404,10 @@ public void StreamingFrame_LateFailure_RetriesTextAndLargeSixel () } if (bytes.IndexOf ((byte)'X') >= 0) textWrites++; - if (bytes.Length > Utf8Buffer.RetainedCapacityLimit) imageWrites++; + foreach (byte value in bytes) + { + if (value == (byte)'q') imageBytes++; + } return true; }) { CaptureOutput = false }; OutputBufferImpl buffer = new (); @@ -421,10 +424,12 @@ public void StreamingFrame_LateFailure_RetriesTextAndLargeSixel () Assert.Equal (1, textWrites); Assert.True (buffer.Contents! [0, 0].IsDirty); Assert.True (sixel.IsDirty); + int previouslyWrittenImageBytes = imageBytes; + Assert.True (previouslyWrittenImageBytes > 0); output.Write (buffer); Assert.Equal (2, textWrites); - Assert.Equal (1, imageWrites); + Assert.Equal (previouslyWrittenImageBytes + sixel.SixelData.Length, imageBytes); Assert.False (buffer.DirtyLines [0]); Assert.False (sixel.IsDirty); } diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs index 7a113bab85..0ab99841bc 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs @@ -28,7 +28,7 @@ public void AppendAscii_PureAscii_StoresRawBytes () Assert.Equal ("Hello", Encoding.UTF8.GetString (buffer.AsSpan ())); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void AppendAscii_NonAscii_FallsBackToUtf8 () { @@ -287,7 +287,7 @@ public void Capacity_GrowsForMultibyteContent () Assert.Equal (large, Encoding.UTF8.GetString (buffer.AsSpan ())); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void TrimExcess_ReleasesLargeBackingArrayAfterClear () { @@ -301,7 +301,7 @@ public void TrimExcess_ReleasesLargeBackingArrayAfterClear () Assert.Equal (256, buffer.Capacity); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void EnsureCapacity_ChecksOverflowBeforeArrayAccess () { diff --git a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs index 6771475538..572eda4af4 100644 --- a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs @@ -56,7 +56,7 @@ public void TryWriteAll_RetriesUntilAllBytesWritten () Assert.Equal ([0x41, 0x42, 0x43], firstBytes); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void TryWriteAll_PartialWriteOfSmallSpan_DoesNotCopyLargeBackingArray () { @@ -97,7 +97,7 @@ public void TryWriteAll_PartialWriteOfSmallSpan_DoesNotCopyLargeBackingArray () Assert.InRange (allocated, 0, 1024); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void TryWriteAll_RejectsWriterThatClaimsMoreThanRemaining () { @@ -106,7 +106,7 @@ public void TryWriteAll_RejectsWriterThatClaimsMoreThanRemaining () Assert.False (UnixIOHelper.TryWriteAll (1, payload, (_, _) => 2)); } - // Codex - GPT-6 + // CoPilot - GPT-6 (generated by Codex) [Fact] public void TryWriteAll_NativeSpanWrite_UsesOnlyRequestedBytes () { From 4cde18e0f17cd19348abd83c1b979d889fc15e97 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 30 Sep 2026 21:03:58 -0500 Subject: [PATCH 38/42] Harden output failure paths and remove legacy hook quadratic cost - Route ProgressIndicator and Kitty keyboard enable/disable through DriverImpl.WriteRaw so terminal I/O failures are contained. - ProgressIndicator only records sequences that were actually written, so a failed set or clear is retried. - Remove IOException swallows in NetOutput writes so failures reach the frame transaction; make NetOutput.SetCursor best-effort like AnsiOutput. - Back off repeated output retries (0, 25ms .. 1s) and log only the first failure and the recovery, instead of a full frame and an error per loop. - Reset the Unix EAGAIN/EINTR budget on progress so a slow terminal still receives the whole frame; the budget bounds consecutive stalls only. - Legacy AppendOrWriteAttribute shim: flush pending text before the hook (matching the built-in Win32 hook) instead of re-decoding the pending row per attribute change (114 MB -> linear for a 200x50 colored frame). - GetColumns single-char fast path avoids grapheme enumeration per cell. - Rename review-named test files to feature names; add regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Terminal.Gui/App/ApplicationImpl.Screen.cs | 2 +- .../KittyKeyboardProtocolDetector.cs | 4 +- .../Drivers/AnsiHandling/ProgressIndicator.cs | 24 ++- .../Drivers/DotNetDriver/NetOutput.cs | 49 +----- Terminal.Gui/Drivers/DriverImpl.cs | 58 +++++- Terminal.Gui/Drivers/Output/OutputBase.cs | 26 ++- .../Drivers/UnixHelpers/UnixIOHelper.cs | 6 +- Terminal.Gui/Text/StringExtensions.cs | 8 + .../OutputBuffer/UnixRawOutputBenchmark.cs | 2 +- .../KittyKeyboardProtocolDetectorTests.cs | 5 +- ...Tests.cs => OutputFailureHandlingTests.cs} | 165 +++++++++++++++++- ...cs => OutputRetryAndCompatibilityTests.cs} | 49 +++++- .../Text/StringTests.cs | 12 ++ 13 files changed, 337 insertions(+), 73 deletions(-) rename Tests/UnitTestsParallelizable/Drivers/Output/{OutputClaudeReviewTests.cs => OutputFailureHandlingTests.cs} (76%) rename Tests/UnitTestsParallelizable/Drivers/Output/{OutputReviewRegressionTests.cs => OutputRetryAndCompatibilityTests.cs} (90%) diff --git a/Terminal.Gui/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs index e5492195aa..4ce165d8a8 100644 --- a/Terminal.Gui/App/ApplicationImpl.Screen.cs +++ b/Terminal.Gui/App/ApplicationImpl.Screen.cs @@ -311,7 +311,7 @@ public void LayoutAndDraw (bool forceRedraw = false) // A failed physical frame leaves output dirty after View.Draw has cleared NeedsDraw. // Retry those cells without forcing a full repaint (which would erase inline content). - if (!neededLayout && !needsDraw && Driver is DriverImpl { NeedsOutputRetry: true } retryDriver) + if (!neededLayout && !needsDraw && Driver is DriverImpl { IsOutputRetryDue: true } retryDriver) { retryDriver.Refresh (); } diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs index 5bf5929f52..39a16bd102 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs @@ -45,7 +45,7 @@ internal void Enable (KittyKeyboardFlags flags) } Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Enable", $"Writing enable sequence for flags {flags}"); - _driver?.GetOutput ().Write (EscSeqUtils.CSI_EnableKittyKeyboardFlags (flags)); + _driver?.WriteRaw (EscSeqUtils.CSI_EnableKittyKeyboardFlags (flags)); Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Enable", "Running Detector again, to get reported flags..."); @@ -78,7 +78,7 @@ internal void Disable () } Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Disable", "Writing disable sequence"); - _driver?.GetOutput ().Write (EscSeqUtils.CSI_DisableKittyKeyboardFlags); + _driver?.WriteRaw (EscSeqUtils.CSI_DisableKittyKeyboardFlags); } /// diff --git a/Terminal.Gui/Drivers/AnsiHandling/ProgressIndicator.cs b/Terminal.Gui/Drivers/AnsiHandling/ProgressIndicator.cs index a9912787f2..e6c5fa5444 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/ProgressIndicator.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/ProgressIndicator.cs @@ -5,10 +5,10 @@ namespace Terminal.Gui.Drivers; /// public class ProgressIndicator { - private readonly IDriver _driver; + private readonly DriverImpl _driver; private string? _lastSequence; - internal ProgressIndicator (IDriver driver) + internal ProgressIndicator (DriverImpl driver) { ArgumentNullException.ThrowIfNull (driver); _driver = driver; @@ -44,11 +44,10 @@ public void Clear () return; } - string clearSequence = EscSeqUtils.OSC_ClearProgress (); - - if (_lastSequence != clearSequence) + // Keep the last sequence if the clear fails so a later Clear retries it. + if (!WriteSequence (EscSeqUtils.OSC_ClearProgress ())) { - WriteSequence (clearSequence); + return; } _lastSequence = null; @@ -77,14 +76,21 @@ public void Clear () /// public void SetIndeterminate () => WriteSequence (EscSeqUtils.OSC_SetProgressIndeterminate ()); - private void WriteSequence (string sequence) + private bool WriteSequence (string sequence) { if (_lastSequence == sequence || _driver.IsLegacyConsole) { - return; + return true; + } + + // Only remember sequences the terminal actually received; otherwise a repeat would be skipped. + if (!_driver.TryWriteRaw (sequence)) + { + return false; } - _driver.GetOutput ().Write (sequence.AsSpan ()); _lastSequence = sequence; + + return true; } } diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index 4ed7bf55e5..8482ca97d5 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -81,14 +81,7 @@ public void Write (ReadOnlySpan text) return; } - try - { - Console.Out.Write (text); - } - catch (IOException) - { - // Not connected to a terminal; do nothing - } + Console.Out.Write (text); } /// @@ -101,14 +94,7 @@ protected override void Write (StringBuilder output) return; } - try - { - Console.Out.Write (output); - } - catch (IOException) - { - // Not connected to a terminal; do nothing - } + Console.Out.Write (output); } /// @@ -127,14 +113,7 @@ private protected override void WriteEncodedString (string output) return; } - try - { - Console.Out.Write (output.AsSpan ()); - } - catch (IOException) - { - // Not connected to a terminal; do nothing. - } + Console.Out.Write (output.AsSpan ()); } /// @@ -177,14 +156,7 @@ protected override void Write (ReadOnlySpan output) return; } - try - { - Console.Out.Write (text); - } - catch (IOException) - { - // Not connected to a terminal; do nothing - } + Console.Out.Write (text); } private Cursor _currentCursor = new (); @@ -210,17 +182,14 @@ public void SetCursor (Cursor cursor) Write (EscSeqUtils.CSI_ShowCursor); } - } - catch - { - // Ignore any exceptions - } - finally - { - SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); + SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); _currentCursor = cursor; } + catch (IOException ex) + { + Logging.Error ($"Error updating .NET console cursor: {ex.Message}"); + } } /// diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 8a8b09a812..bf92e41ebe 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -106,20 +106,57 @@ public void Refresh () } _output.Write (_outputBuffer); - NeedsOutputRetry = false; + RecordOutputSuccess (); } catch (Exception ex) when (ex is IOException or Win32Exception) { // OutputBase has restored the exact dirty cells and invalidated graphics state. // Retry even if no view requests another draw; keep input and dispatch responsive. - NeedsOutputRetry = true; - Logging.Error ($"Error refreshing terminal output: {ex.Message}"); + RecordOutputFailure ("refreshing terminal output", ex); } // Cursor visibility restored by ApplicationMainLoop to reduce flicker. } - internal bool NeedsOutputRetry { get; private set; } + private const int MAX_OUTPUT_RETRY_DELAY_MS = 1000; + private int _outputFailures; + private long _nextOutputRetryTicks; + + /// Gets whether a failed physical write left output that must be retried. + internal bool NeedsOutputRetry => _outputFailures > 0; + + /// Gets whether a failed write should be retried now. Repeated failures back off up to one second. + internal bool IsOutputRetryDue => NeedsOutputRetry && Environment.TickCount64 >= _nextOutputRetryTicks; + + private void RecordOutputFailure (string operation, Exception ex) + { + _outputFailures++; + + // Retry the first failure immediately; then back off so a terminal that stays broken + // (e.g. a hung-up tty) does not cost a full frame write on every main loop iteration. + int delayMs = _outputFailures == 1 ? 0 : Math.Min (MAX_OUTPUT_RETRY_DELAY_MS, 25 << Math.Min (_outputFailures - 2, 6)); + _nextOutputRetryTicks = Environment.TickCount64 + delayMs; + + if (_outputFailures == 1) + { + Logging.Error ($"Error {operation}: {ex.Message}"); + + return; + } + + Logging.Debug ($"Error {operation} (failure {_outputFailures}): {ex.Message}"); + } + + private void RecordOutputSuccess () + { + if (_outputFailures == 0) + { + return; + } + + Logging.Information ($"Terminal output recovered after {_outputFailures} failed write(s)."); + _outputFailures = 0; + } /// public string? GetName () => _componentFactory.GetDriverName (); @@ -464,11 +501,17 @@ public Attribute SetAttribute (Attribute newAttribute) public Attribute GetAttribute () => _outputBuffer.CurrentAttribute; /// - public void WriteRaw (string ansi) + public void WriteRaw (string ansi) => TryWriteRaw (ansi); + + /// Writes , containing terminal I/O failures. + /// if the sequence was written; otherwise, . + internal bool TryWriteRaw (string ansi) { try { _output.Write (ansi); + + return true; } catch (Exception ex) when (ex is IOException or Win32Exception) { @@ -479,8 +522,9 @@ public void WriteRaw (string ansi) outputBase.InvalidatePhysicalState (_outputBuffer); } - NeedsOutputRetry = true; - Logging.Error ($"Error writing raw terminal output: {ex.Message}"); + RecordOutputFailure ("writing raw terminal output", ex); + + return false; } } diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 3dfa8210e9..e51a8768a5 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -24,6 +24,7 @@ protected OutputBase () } private readonly bool _usesLegacyAttributeWriter; + private StringBuilder? _legacyAttributeText; private readonly Type? _stringWriterType; /// Gets whether a derived StringBuilder sink needs forwarding outside the current compatibility call. @@ -472,6 +473,10 @@ public void Dispose () /// background color is used via ANSI reset sequences (CSI 39m / CSI 49m), allowing native terminal /// transparency to show through. /// + /// + /// When writing a frame, text buffered before the attribute change has already been written, so + /// is empty on entry. + /// /// /// /// @@ -513,12 +518,23 @@ internal virtual void AppendOrWriteAttributeUtf8 (Utf8Buffer output, Attribute a { if (_usesLegacyAttributeWriter) { - // The hook can flush accumulated text before an out-of-band color change. - StringBuilder legacyAttributeBuffer = new (Encoding.UTF8.GetString (output.AsSpan ())); - output.Clear (); - AppendOrWriteAttribute (legacyAttributeBuffer, attr, redrawTextStyle); + // Flush pending text first (as the built-in Win32 hook does) so an out-of-band color + // change stays ordered without re-decoding the whole pending row on every attribute. + if (output.Length > 0) + { + Write (output.AsSpan ()); + output.Clear (); + } + + StringBuilder attributeText = _legacyAttributeText ??= new (); + attributeText.Clear (); + AppendOrWriteAttribute (attributeText, attr, redrawTextStyle); + // Encode once: StringBuilder chunks can split a UTF-16 surrogate pair. - output.Append (legacyAttributeBuffer.ToString ()); + if (attributeText.Length > 0) + { + output.Append (attributeText.ToString ()); + } return; } diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 90cfaf3b51..a821f7a03a 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -380,8 +380,9 @@ internal static bool TryWriteAll (int fd, ReadOnlySpan buffer, WriteBytes { int error = getError?.Invoke () ?? Marshal.GetLastPInvokeError (); - // Bound the entire call, including intermittent progress, so a slow or broken - // nonblocking terminal cannot monopolize the UI thread. The next frame retries. + // Bound consecutive stalls so a broken nonblocking terminal cannot monopolize the + // UI thread; the next frame retries. Progress resets the budget so a slow terminal + // draining its buffer still receives the whole frame. if ((error == 4 || error == wouldBlock) && transientFailures++ < 8) { if (error == wouldBlock) @@ -401,6 +402,7 @@ internal static bool TryWriteAll (int fd, ReadOnlySpan buffer, WriteBytes } offset += (int)written; + transientFailures = 0; } return true; diff --git a/Terminal.Gui/Text/StringExtensions.cs b/Terminal.Gui/Text/StringExtensions.cs index ec923de921..621aa2d822 100644 --- a/Terminal.Gui/Text/StringExtensions.cs +++ b/Terminal.Gui/Text/StringExtensions.cs @@ -62,6 +62,14 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) return 0; } + // Fast path: renderers measure one cell at a time; avoid allocating a grapheme enumerator. + if (str.Length == 1 && !char.IsSurrogate (str [0])) + { + int width = new Rune (str [0]).GetColumns (); + + return ignoreLessThanZero && width < 0 ? 0 : Math.Min (width, 2); + } + var total = 0; foreach (string grapheme in GraphemeHelper.GetGraphemes (str)) diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs index 06d32e66c7..06d7071a9a 100644 --- a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs @@ -16,7 +16,7 @@ public class UnixRawOutputBenchmark private long _bytes; private int _writes; - /// Builds and warms the two rendered frame sizes. + /// Builds and warms the small (80×25), large (240×70), and per-cell colored (400×110) frames. [GlobalSetup] public void Setup () { diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs index b142c7e24c..c51e842c07 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs @@ -9,10 +9,9 @@ public class KittyKeyboardProtocolDetectorTests public void Enable_QueuesDetect_AndUpdatesDriverFlags_FromDetectionResponse () { Mock driverMock = new (MockBehavior.Strict); - using AnsiOutput output = new (); KittyKeyboardCapabilities existingCapabilities = new () { IsSupported = true, Flags = KittyKeyboardFlags.None }; driverMock.Setup (d => d.IsLegacyConsole).Returns (false); - driverMock.Setup (d => d.GetOutput ()).Returns (output); + driverMock.Setup (d => d.WriteRaw (It.IsAny ())); driverMock.Setup (d => d.KittyKeyboardCapabilities).Returns (existingCapabilities); driverMock.Setup (d => d.QueueAnsiRequest (It.IsAny ())) @@ -25,7 +24,7 @@ public void Enable_QueuesDetect_AndUpdatesDriverFlags_FromDetectionResponse () Assert.NotNull (existingCapabilities); Assert.True (existingCapabilities.IsSupported); Assert.Equal (EscSeqUtils.KittyKeyboardRequestedFlags, existingCapabilities.Flags); - driverMock.Verify (d => d.GetOutput (), Times.Once); + driverMock.Verify (d => d.WriteRaw (EscSeqUtils.CSI_EnableKittyKeyboardFlags (EscSeqUtils.KittyKeyboardRequestedFlags)), Times.Once); driverMock.Verify (d => d.QueueAnsiRequest (It.IsAny ()), Times.Once); } diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs similarity index 76% rename from Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs rename to Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs index 00c7eb68c3..3726eb7ade 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputClaudeReviewTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs @@ -4,7 +4,7 @@ namespace DriverTests.Output; [Collection ("Driver Tests")] -public class OutputClaudeReviewTests +public class OutputFailureHandlingTests { // CoPilot - GPT-6 (generated by Codex) [Theory] @@ -42,6 +42,30 @@ public void UnixRepeatedFailure_IsBounded (int error) Assert.Equal (error == 4 ? 9 : 1, calls); } + // Claude - Opus 5.5 + [Fact] + public void UnixTransientFailuresBetweenProgress_DoNotAbortWrite () + { + List written = []; + int calls = 0; + + // Every byte is preceded by EINTR: 32 interruptions in total, but never more than one in a row. + bool success = UnixIOHelper.TryWriteAll (1, Encoding.UTF8.GetBytes (new string ('x', 32)), (_, bytes) => + { + if (calls++ % 2 == 0) + { + return -1; + } + + written.Add (bytes [0]); + + return 1; + }, () => 4); + + Assert.True (success); + Assert.Equal (32, written.Count); + } + // CoPilot - GPT-6 (generated by Codex) [Theory] [InlineData (false)] @@ -112,6 +136,145 @@ public void DriverRefresh_ContainsNativeErrorAndRetriesDirtyCells (bool windowsF Assert.Contains ("X", Assert.Single (writes)); } + // Claude - Opus 5.5 + [Fact] + public void ProgressIndicator_WriteFailure_DoesNotThrowAndRequestsRetry () + { + AnsiOutput output = new (_ => false); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + using DriverImpl driver = CreateDriver (output, buffer); + ProgressIndicator indicator = new (driver); + + Exception? exception = Record.Exception (() => indicator.SetValue (50)); + + Assert.Null (exception); + Assert.True (driver.NeedsOutputRetry); + } + + // Claude - Opus 5.5 + [Fact] + public void ProgressIndicator_FailedWrite_IsRetried () + { + bool fail = true; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + using DriverImpl driver = CreateDriver (output, buffer); + ProgressIndicator indicator = new (driver); + + indicator.SetValue (50); + fail = false; + indicator.SetValue (50); + + Assert.Equal ([EscSeqUtils.OSC_SetProgressValue (50)], writes); + } + + // Claude - Opus 5.5 + [Fact] + public void ProgressIndicator_FailedClear_IsRetried () + { + bool fail = false; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + using DriverImpl driver = CreateDriver (output, buffer); + ProgressIndicator indicator = new (driver); + indicator.SetValue (50); + + fail = true; + indicator.Clear (); + fail = false; + indicator.Clear (); + indicator.Clear (); + + Assert.Equal ([EscSeqUtils.OSC_SetProgressValue (50), EscSeqUtils.OSC_ClearProgress ()], writes); + } + + // Claude - Opus 5.5 + [Fact] + public void RepeatedOutputFailures_BackOffUntilRecovery () + { + bool fail = true; + AnsiOutput output = new (_ => !fail); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + using DriverImpl driver = CreateDriver (output, buffer); + + driver.Refresh (); + Assert.True (driver.IsOutputRetryDue); + + driver.Refresh (); + Assert.True (driver.NeedsOutputRetry); + Assert.False (driver.IsOutputRetryDue); + + fail = false; + driver.Refresh (); + Assert.False (driver.NeedsOutputRetry); + Assert.False (driver.IsOutputRetryDue); + + fail = true; + buffer.Move (0, 0); + buffer.AddStr ("Y"); + driver.Refresh (); + Assert.True (driver.IsOutputRetryDue); + } + + // Claude - Opus 5.5 + [Fact] + public void KittyKeyboardEnable_WriteFailure_DoesNotThrowAndRequestsRetry () + { + AnsiOutput output = new (_ => false); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + using DriverImpl driver = CreateDriver (output, buffer); + KittyKeyboardProtocolDetector detector = new (driver); + + Exception? exception = Record.Exception (() => detector.Enable (EscSeqUtils.KittyKeyboardRequestedFlags)); + + Assert.Null (exception); + Assert.True (driver.NeedsOutputRetry); + } + + // Claude - Opus 5.5 + [Fact] + public void KittyKeyboardDisable_WriteFailure_DoesNotThrowAndRequestsRetry () + { + AnsiOutput output = new (_ => false); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + using DriverImpl driver = CreateDriver (output, buffer); + KittyKeyboardProtocolDetector detector = new (driver); + + Exception? exception = Record.Exception (detector.Disable); + + Assert.Null (exception); + Assert.True (driver.NeedsOutputRetry); + } + // CoPilot - GPT-6 (generated by Codex) [Fact] public void DriverRefresh_ProgrammingErrorStillEscapes () diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputRetryAndCompatibilityTests.cs similarity index 90% rename from Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs rename to Tests/UnitTestsParallelizable/Drivers/Output/OutputRetryAndCompatibilityTests.cs index dad3e6ba01..ff14cefca2 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputReviewRegressionTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputRetryAndCompatibilityTests.cs @@ -3,9 +3,9 @@ namespace DriverTests.Output; -/// Behavioral regressions found during the follow-up output review. +/// Verifies frame retry after I/O failure and compatibility with legacy output overrides. [Collection ("Driver Tests")] -public class OutputReviewRegressionTests +public class OutputRetryAndCompatibilityTests { // CoPilot - GPT-6 (generated by Codex) [Fact] @@ -24,6 +24,34 @@ public void LegacyAttributeHook_CanFlushPreviouslyBufferedText () Assert.Equal (["attribute:1", "text:A", "attribute:2", "text:B"], output.Events); } + // Claude - Opus 5.5 + [Fact] + public void LegacyAttributeHook_PerCellColors_AllocationIsLinear () + { + AppendingAttributeOutput output = new (); + OutputBufferImpl buffer = new (); + buffer.SetSize (200, 50); + + for (var row = 0; row < 50; row++) + { + buffer.Move (0, row); + + for (var col = 0; col < 200; col++) + { + buffer.CurrentAttribute = new (new Color (col, row, (col + row) % 256), Color.Black); + buffer.AddStr ("x"); + } + } + + long before = GC.GetAllocatedBytesForCurrentThread (); + output.Write (buffer); + long allocated = GC.GetAllocatedBytesForCurrentThread () - before; + + // A quadratic hook path re-copied the pending frame on every attribute change (hundreds of MB here). + Assert.InRange (allocated, 0, 16 * 1024 * 1024); + Assert.Equal (10_000, output.AttributeCalls); + } + // CoPilot - GPT-6 (generated by Codex) [Fact] public void LegacyAttributeHook_SurrogatePairAcrossChunks_IsPreserved () @@ -477,6 +505,23 @@ private class StringOutput : OutputBase protected override void Write (StringBuilder output) => Written.Append (output); } + private sealed class AppendingAttributeOutput : OutputBase + { + public int AttributeCalls { get; private set; } + + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) => true; + + protected override void Write (StringBuilder output) { } + + protected override void AppendOrWriteAttribute (StringBuilder output, + Terminal.Gui.Drawing.Attribute attr, + TextStyle redrawTextStyle) + { + AttributeCalls++; + output.Append ("\u001b[38;2;1;2;3m"); + } + } + private sealed class SplitSurrogateOutput : StringOutput { protected override void AppendOrWriteAttribute (StringBuilder output, diff --git a/Tests/UnitTestsParallelizable/Text/StringTests.cs b/Tests/UnitTestsParallelizable/Text/StringTests.cs index 9b104f6d92..bfe7e4c6e8 100644 --- a/Tests/UnitTestsParallelizable/Text/StringTests.cs +++ b/Tests/UnitTestsParallelizable/Text/StringTests.cs @@ -4,6 +4,18 @@ public class StringTests { + // Claude - Opus 5.5 + [Theory] + [InlineData ("a", true, 1)] + [InlineData ("你", true, 2)] + [InlineData ("\u0301", true, 0)] + [InlineData ("\u0007", true, 0)] + [InlineData ("\u0007", false, -1)] + public void TestGetColumns_SingleChar_MatchesGraphemePath (string str, bool ignoreLessThanZero, int expected) + { + Assert.Equal (expected, str.GetColumns (ignoreLessThanZero)); + } + [Fact] public void TestGetColumns_Null () { From 77d686a0cdc4741dbbf2a9fda8eea7d65fc957fd Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 30 Sep 2026 23:22:55 -0500 Subject: [PATCH 39/42] Let DriverImpl own output failure logging AnsiOutput logged and rethrew write IOExceptions, so DriverImpl logged each failure a second time and every backed-off retry still emitted an error. Drop that log and keep only the pending-move cleanup. Cursor updates are best effort and run every iteration, so log their failures at Debug; a broken sink also fails the next frame, which DriverImpl reports once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs | 8 +++++--- Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index dcf079ea8f..347840fef8 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -362,10 +362,11 @@ private void WriteUtf8 (ReadOnlySpan output) WriteDirect (output); } - catch (IOException ex) + catch (IOException) { - Logging.Error ($"Error writing ANSI output: {ex.Message}"); + // DriverImpl owns failure logging and retry; only drop moves that no longer apply. _pendingCursorMoves.Clear (); + throw; } } @@ -425,7 +426,8 @@ public void SetCursor (Cursor cursor) } catch (Exception ex) when (ex is IOException or System.ComponentModel.Win32Exception) { - Logging.Error ($"Error updating ANSI cursor: {ex.Message}"); + // Best effort: a broken sink also fails the next frame, which DriverImpl logs once. + Logging.Debug ($"Error updating ANSI cursor: {ex.Message}"); } } diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index 8482ca97d5..4878b0c951 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -188,7 +188,8 @@ public void SetCursor (Cursor cursor) } catch (IOException ex) { - Logging.Error ($"Error updating .NET console cursor: {ex.Message}"); + // Best effort: a broken sink also fails the next frame, which DriverImpl logs once. + Logging.Debug ($"Error updating .NET console cursor: {ex.Message}"); } } From c44042bb095dcc9c697585c283634bf68a38d6cc Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 1 Oct 2026 09:33:06 -0500 Subject: [PATCH 40/42] Apply output retry backoff to every Refresh The backoff deadline was only checked in the no-draw retry branch, so a view that redraws every iteration still flushed a full frame to a failing terminal on each loop. Gate DriverImpl.Refresh itself: while a retry is pending but not due, keep the dirty cells and let the next due call write the latest buffer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Terminal.Gui/Drivers/DriverImpl.cs | 7 ++++ .../Output/OutputFailureHandlingTests.cs | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index bf92e41ebe..70f9818591 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -93,6 +93,13 @@ public DriverImpl (IComponentFactory componentFactory, /// public void Refresh () { + // While backing off after repeated failures, keep the dirty cells for the next due attempt. + // Gating here covers every flush path, including views that redraw every iteration. + if (NeedsOutputRetry && !IsOutputRetryDue) + { + return; + } + try { // Hide cursor during rendering to prevent flicker. diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs index 3726eb7ade..8362d1a9e8 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs @@ -232,6 +232,7 @@ public void RepeatedOutputFailures_BackOffUntilRecovery () Assert.False (driver.IsOutputRetryDue); fail = false; + Assert.True (SpinWait.SpinUntil (() => driver.IsOutputRetryDue, TimeSpan.FromSeconds (5))); driver.Refresh (); Assert.False (driver.NeedsOutputRetry); Assert.False (driver.IsOutputRetryDue); @@ -243,6 +244,46 @@ public void RepeatedOutputFailures_BackOffUntilRecovery () Assert.True (driver.IsOutputRetryDue); } + // Claude - Opus 5.5 + [Fact] + public void Refresh_DuringBackoff_DoesNotWriteUntilRetryIsDue () + { + bool fail = true; + List writes = []; + AnsiOutput output = new (bytes => + { + if (fail) + { + return false; + } + + writes.Add (Encoding.UTF8.GetString (bytes)); + + return true; + }); + OutputBufferImpl buffer = new (); + buffer.SetSize (1, 1); + buffer.AddStr ("X"); + using DriverImpl driver = CreateDriver (output, buffer); + driver.Refresh (); + driver.Refresh (); + Assert.False (driver.IsOutputRetryDue); + + // A view that keeps redrawing must not bypass the backoff. + fail = false; + buffer.Move (0, 0); + buffer.AddStr ("Y"); + driver.Refresh (); + Assert.Empty (writes); + Assert.True (driver.NeedsOutputRetry); + + Assert.True (SpinWait.SpinUntil (() => driver.IsOutputRetryDue, TimeSpan.FromSeconds (5))); + driver.Refresh (); + + Assert.Contains ("Y", Assert.Single (writes)); + Assert.False (driver.NeedsOutputRetry); + } + // Claude - Opus 5.5 [Fact] public void KittyKeyboardEnable_WriteFailure_DoesNotThrowAndRequestsRetry () From 7fba24513e3087772ec1d722a02d1470f4342261 Mon Sep 17 00:00:00 2001 From: Omm Prakash Date: Sun, 4 Oct 2026 10:58:09 +0530 Subject: [PATCH 41/42] feat(analyzers): add Terminal.Gui.Analyzers package to catch v1 API hallucinations at compile time - Implement V1ApiDiagnosticAnalyzer covering rules TGUI0001 through TGUI0014 - Implement V1ApiCodeFixProvider for automated Roslyn AST refactorings - Bundle Terminal.Gui.Analyzers.dll inside Terminal.Gui.nupkg under analyzers/dotnet/cs - Add unit test suite in Tests/Terminal.Gui.Analyzers.Tests and link to UnitTestsParallelizable --- .gitignore | 6 + Directory.Packages.props | 7 +- .../Terminal.Gui.Analyzers.csproj | 24 ++ .../V1ApiCodeFixProvider.cs | 108 ++++++ .../V1ApiDiagnosticAnalyzer.cs | 342 ++++++++++++++++++ Terminal.Gui/Terminal.Gui.csproj | 2 + Terminal.slnx | 3 + .../Terminal.Gui.Analyzers.Tests.csproj | 28 ++ .../V1ApiDiagnosticAnalyzerTests.cs | 185 ++++++++++ .../UnitTests.Parallelizable.csproj | 6 +- 10 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj create mode 100644 Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs create mode 100644 Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs create mode 100644 Tests/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj create mode 100644 Tests/Terminal.Gui.Analyzers.Tests/V1ApiDiagnosticAnalyzerTests.cs diff --git a/.gitignore b/.gitignore index 022de0dfcb..e2764bd054 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,9 @@ artifacts/ docfx/artifacts/ local_packages/ *.cast + +# Graphic memory & plan markdown files +GRAPHIC_MEMORY.md +plans/ +*.skill.md + diff --git a/Directory.Packages.props b/Directory.Packages.props index 9388129c61..4880e9c406 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,9 @@ - + + + @@ -13,7 +15,8 @@ - + + diff --git a/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj b/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj new file mode 100644 index 0000000000..8c978eb73d --- /dev/null +++ b/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj @@ -0,0 +1,24 @@ + + + + + netstandard2.0 + Latest + enable + Terminal.Gui.Analyzers + true + true + $(NoWarn);RS2008;RS1032 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs b/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs new file mode 100644 index 0000000000..ae72e80d0a --- /dev/null +++ b/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs @@ -0,0 +1,108 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Terminal.Gui.Analyzers +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(V1ApiCodeFixProvider)), Shared] + public class V1ApiCodeFixProvider : CodeFixProvider + { + public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + V1ApiDiagnosticAnalyzer.RuleClicked.Id, + V1ApiDiagnosticAnalyzer.RuleToplevel.Id, + V1ApiDiagnosticAnalyzer.RuleBounds.Id, + V1ApiDiagnosticAnalyzer.RuleTabView.Id, + V1ApiDiagnosticAnalyzer.RuleRadioGroup.Id, + V1ApiDiagnosticAnalyzer.RuleAcceptingIgnoresArgs.Id); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var diagnosticSpan = diagnostic.Location.SourceSpan; + var token = root.FindToken(diagnosticSpan.Start); + + if (diagnostic.Id == V1ApiDiagnosticAnalyzer.RuleClicked.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with 'Accepted'", + createChangedDocument: c => ReplaceTokenAsync(context.Document, root, token, "Accepted", c), + equivalenceKey: "UseAccepted"), + diagnostic); + } + else if (diagnostic.Id == V1ApiDiagnosticAnalyzer.RuleBounds.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with 'Viewport'", + createChangedDocument: c => ReplaceTokenAsync(context.Document, root, token, "Viewport", c), + equivalenceKey: "UseViewport"), + diagnostic); + } + else if (diagnostic.Id == V1ApiDiagnosticAnalyzer.RuleToplevel.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with 'Window'", + createChangedDocument: c => ReplaceTokenAsync(context.Document, root, token, "Window", c), + equivalenceKey: "UseWindow"), + diagnostic); + } + else if (diagnostic.Id == V1ApiDiagnosticAnalyzer.RuleTabView.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with 'Tabs'", + createChangedDocument: c => ReplaceTokenAsync(context.Document, root, token, "Tabs", c), + equivalenceKey: "UseTabs"), + diagnostic); + } + else if (diagnostic.Id == V1ApiDiagnosticAnalyzer.RuleRadioGroup.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with 'OptionSelector'", + createChangedDocument: c => ReplaceTokenAsync(context.Document, root, token, "OptionSelector", c), + equivalenceKey: "UseOptionSelector"), + diagnostic); + } + else if (diagnostic.Id == V1ApiDiagnosticAnalyzer.RuleAcceptingIgnoresArgs.Id) + { + var node = root.FindNode(diagnosticSpan); + if (node is AssignmentExpressionSyntax assignment && assignment.Left is MemberAccessExpressionSyntax memberAccess) + { + var eventToken = memberAccess.Name.Identifier; + context.RegisterCodeFix( + CodeAction.Create( + title: "Switch event to 'Accepted'", + createChangedDocument: c => ReplaceTokenAsync(context.Document, root, eventToken, "Accepted", c), + equivalenceKey: "SwitchToAccepted"), + diagnostic); + } + } + } + } + + private static Task ReplaceTokenAsync(Document document, SyntaxNode root, SyntaxToken oldToken, string newName, CancellationToken cancellationToken) + { + var newToken = SyntaxFactory.Identifier(oldToken.LeadingTrivia, newName, oldToken.TrailingTrivia); + var newRoot = root.ReplaceToken(oldToken, newToken); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + } +} diff --git a/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs b/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs new file mode 100644 index 0000000000..dad974a078 --- /dev/null +++ b/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Terminal.Gui.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class V1ApiDiagnosticAnalyzer : DiagnosticAnalyzer + { + public const string Category = "TerminalGuiV2"; + + public static readonly DiagnosticDescriptor RuleClicked = new DiagnosticDescriptor( + "TGUI0001", + "Use Accepted or Accepting instead of Clicked", + "'{0}' is a removed v1 member. In Terminal.Gui v2, use 'Accepted' (post-event) or 'Accepting' (cancellable event)", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleApplicationStatic = new DiagnosticDescriptor( + "TGUI0002", + "Use instance Application instead of static methods", + "Static Application.{0} is removed in Terminal.Gui v2. Use 'IApplication app = Application.Create().Init()', 'app.Run()', and 'app.Dispose()'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleToplevel = new DiagnosticDescriptor( + "TGUI0003", + "Use Runnable or Window instead of Toplevel", + "'Toplevel' has been removed in Terminal.Gui v2. Inherit from 'Runnable' or use 'Window'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleBounds = new DiagnosticDescriptor( + "TGUI0004", + "Use Viewport instead of Bounds", + "'View.Bounds' was removed in Terminal.Gui v2. Use 'View.Viewport'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RulePositionalConstructor = new DiagnosticDescriptor( + "TGUI0005", + "Use object initializers for Views", + "Terminal.Gui v2 views use parameterless constructors and object initializers: 'new {0} {{ Text = \"...\" }}'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleTabView = new DiagnosticDescriptor( + "TGUI0006", + "Use Tabs instead of TabView", + "'TabView' was replaced by 'Tabs' in Terminal.Gui v2. Add SubViews directly and set 'View.Title' for tab labels", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleTileView = new DiagnosticDescriptor( + "TGUI0007", + "TileView is removed in Terminal.Gui v2", + "'TileView' was removed in Terminal.Gui v2. Use Pos/Dim panes and ViewArrangement", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleRadioGroup = new DiagnosticDescriptor( + "TGUI0008", + "Use OptionSelector instead of RadioGroup", + "'RadioGroup' was replaced by 'OptionSelector' in Terminal.Gui v2", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleColorScheme = new DiagnosticDescriptor( + "TGUI0009", + "Use Scheme instead of ColorScheme", + "Terminal.Gui v2 replaces 'ColorScheme' with 'Scheme' and 'SchemeManager'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleKeyBitmask = new DiagnosticDescriptor( + "TGUI0010", + "Use Key fluent methods instead of KeyCode bitmasks", + "Terminal.Gui v2 key bitmasks are replaced by fluent methods like 'Key.X.WithCtrl' or 'Key.X.WithAlt'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleAcceptingIgnoresArgs = new DiagnosticDescriptor( + "TGUI0011", + "Use Accepted when event args are unused", + "The 'Accepting' event is a pre-event for cancellation. If event arguments (e.g. e.Cancel) are not used, subscribe to 'Accepted' instead", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleMenuItem6ArgCtor = new DiagnosticDescriptor( + "TGUI0012", + "Use v2 MenuItem constructor", + "Terminal.Gui v2 MenuItem constructor takes (title, help, action). Use object initializers for Shortcut/Data", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RuleLayoutStyle = new DiagnosticDescriptor( + "TGUI0013", + "LayoutStyle is removed in v2", + "'LayoutStyle' was removed in Terminal.Gui v2. All layout is declarative via Pos and Dim", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RulePosAt = new DiagnosticDescriptor( + "TGUI0014", + "Use direct integer assignment instead of Pos.At / Pos.Left", + "'Pos.At(n)' and 'Pos.Left(v)' are removed in Terminal.Gui v2. Assign integers directly e.g. X = 5", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + RuleClicked, + RuleApplicationStatic, + RuleToplevel, + RuleBounds, + RulePositionalConstructor, + RuleTabView, + RuleTileView, + RuleRadioGroup, + RuleColorScheme, + RuleKeyBitmask, + RuleAcceptingIgnoresArgs, + RuleMenuItem6ArgCtor, + RuleLayoutStyle, + RulePosAt); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + context.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName); + context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression); + context.RegisterSyntaxNodeAction(AnalyzeBitwiseOr, SyntaxKind.BitwiseOrExpression); + context.RegisterSyntaxNodeAction(AnalyzeEventSubscription, SyntaxKind.AddAssignmentExpression); + } + + private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) + { + var memberAccess = (MemberAccessExpressionSyntax)context.Node; + var name = memberAccess.Name.Identifier.Text; + + if (name == "Clicked") + { + context.ReportDiagnostic(Diagnostic.Create(RuleClicked, memberAccess.Name.GetLocation(), memberAccess.ToString())); + } + else if (name == "Bounds") + { + var exprType = context.SemanticModel.GetTypeInfo(memberAccess.Expression).Type; + if (exprType != null && IsOrInheritsFromView(exprType)) + { + context.ReportDiagnostic(Diagnostic.Create(RuleBounds, memberAccess.Name.GetLocation())); + } + } + else if (name == "ColorScheme" || name == "ColorSchemes") + { + context.ReportDiagnostic(Diagnostic.Create(RuleColorScheme, memberAccess.Name.GetLocation())); + } + else if (name == "Top" && memberAccess.Expression is IdentifierNameSyntax id && id.Identifier.Text == "Application") + { + context.ReportDiagnostic(Diagnostic.Create(RuleApplicationStatic, memberAccess.GetLocation(), "Top")); + } + else if (name == "Computed" && memberAccess.Expression is IdentifierNameSyntax ls && ls.Identifier.Text == "LayoutStyle") + { + context.ReportDiagnostic(Diagnostic.Create(RuleLayoutStyle, memberAccess.GetLocation())); + } + } + + private static bool IsOrInheritsFromView(ITypeSymbol typeSymbol) + { + var current = typeSymbol; + while (current != null) + { + if (current.Name == "View" || current.Name == "Toplevel" || current.Name == "Window" || current.Name == "Button") + { + return true; + } + current = current.BaseType; + } + return false; + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + if (memberAccess.Expression is IdentifierNameSyntax id && id.Identifier.Text == "Application") + { + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == "Init" || methodName == "Run" || methodName == "Shutdown") + { + context.ReportDiagnostic(Diagnostic.Create(RuleApplicationStatic, memberAccess.GetLocation(), methodName)); + } + } + else if (memberAccess.Expression is IdentifierNameSyntax pos && pos.Identifier.Text == "Pos") + { + var posMethod = memberAccess.Name.Identifier.Text; + if (posMethod == "At" || posMethod == "Left") + { + context.ReportDiagnostic(Diagnostic.Create(RulePosAt, invocation.GetLocation())); + } + } + } + } + + private static void AnalyzeIdentifier(SyntaxNodeAnalysisContext context) + { + var identifier = (IdentifierNameSyntax)context.Node; + var text = identifier.Identifier.Text; + + if (identifier.Parent is TypeDeclarationSyntax || + identifier.Parent is NamespaceDeclarationSyntax || + identifier.Parent is ParameterSyntax || + identifier.Parent is VariableDeclaratorSyntax) + { + return; + } + + if (text == "Toplevel") + { + context.ReportDiagnostic(Diagnostic.Create(RuleToplevel, identifier.GetLocation())); + } + else if (text == "TabView" || text == "Tab") + { + if (identifier.Parent is ObjectCreationExpressionSyntax || + identifier.Parent is VariableDeclarationSyntax || + identifier.Parent is GenericNameSyntax) + { + context.ReportDiagnostic(Diagnostic.Create(RuleTabView, identifier.GetLocation())); + } + } + else if (text == "TileView") + { + context.ReportDiagnostic(Diagnostic.Create(RuleTileView, identifier.GetLocation())); + } + else if (text == "RadioGroup") + { + context.ReportDiagnostic(Diagnostic.Create(RuleRadioGroup, identifier.GetLocation())); + } + else if (text == "LayoutStyle") + { + context.ReportDiagnostic(Diagnostic.Create(RuleLayoutStyle, identifier.GetLocation())); + } + } + + private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) + { + var objectCreation = (ObjectCreationExpressionSyntax)context.Node; + if (objectCreation.ArgumentList == null || objectCreation.ArgumentList.Arguments.Count == 0) + { + return; + } + + string typeName = objectCreation.Type switch + { + IdentifierNameSyntax id => id.Identifier.Text, + QualifiedNameSyntax qn => qn.Right.Identifier.Text, + _ => null + }; + + if (typeName == "MenuItem" && objectCreation.ArgumentList.Arguments.Count > 3) + { + context.ReportDiagnostic(Diagnostic.Create(RuleMenuItem6ArgCtor, objectCreation.GetLocation())); + } + else if (typeName == "Button" || typeName == "Label" || typeName == "Window" || typeName == "TextField") + { + context.ReportDiagnostic(Diagnostic.Create(RulePositionalConstructor, objectCreation.GetLocation(), typeName)); + } + } + + private static void AnalyzeBitwiseOr(SyntaxNodeAnalysisContext context) + { + var binaryExpr = (BinaryExpressionSyntax)context.Node; + string exprText = binaryExpr.ToString(); + + if (exprText.Contains("CtrlMask") || exprText.Contains("AltMask") || exprText.Contains("ShiftMask")) + { + context.ReportDiagnostic(Diagnostic.Create(RuleKeyBitmask, binaryExpr.GetLocation())); + } + } + + private static void AnalyzeEventSubscription(SyntaxNodeAnalysisContext context) + { + var assignment = (AssignmentExpressionSyntax)context.Node; + if (assignment.Left is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "Accepting") + { + if (assignment.Right is SimpleLambdaExpressionSyntax simpleLambda) + { + if (simpleLambda.Parameter.Identifier.Text == "_") + { + context.ReportDiagnostic(Diagnostic.Create(RuleAcceptingIgnoresArgs, assignment.GetLocation())); + } + } + else if (assignment.Right is ParenthesizedLambdaExpressionSyntax lambda) + { + var paramsList = lambda.ParameterList.Parameters; + if (paramsList.Count >= 2) + { + var arg2 = paramsList[1].Identifier.Text; + if (arg2 == "_" || !LambdaBodyUsesIdentifier(lambda.Body, arg2)) + { + context.ReportDiagnostic(Diagnostic.Create(RuleAcceptingIgnoresArgs, assignment.GetLocation())); + } + } + } + } + } + + private static bool LambdaBodyUsesIdentifier(SyntaxNode body, string name) + { + if (body == null || string.IsNullOrEmpty(name) || name == "_") + { + return false; + } + + return body.DescendantNodes() + .OfType() + .Any(id => id.Identifier.Text == name); + } + } +} diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index a22f1a93f6..fa28149f19 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -70,8 +70,10 @@ + + diff --git a/Terminal.slnx b/Terminal.slnx index 6a1b34315d..f19e783dd9 100644 --- a/Terminal.slnx +++ b/Terminal.slnx @@ -101,9 +101,12 @@ + + + diff --git a/Tests/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj b/Tests/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj new file mode 100644 index 0000000000..ee2b8d8ca3 --- /dev/null +++ b/Tests/Terminal.Gui.Analyzers.Tests/Terminal.Gui.Analyzers.Tests.csproj @@ -0,0 +1,28 @@ + + + Exe + true + true + enable + false + enable + net10.0 + + + + + + + + + + + + + + + + + + + diff --git a/Tests/Terminal.Gui.Analyzers.Tests/V1ApiDiagnosticAnalyzerTests.cs b/Tests/Terminal.Gui.Analyzers.Tests/V1ApiDiagnosticAnalyzerTests.cs new file mode 100644 index 0000000000..07397c5f8a --- /dev/null +++ b/Tests/Terminal.Gui.Analyzers.Tests/V1ApiDiagnosticAnalyzerTests.cs @@ -0,0 +1,185 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Terminal.Gui.Analyzers; +using Xunit; + +namespace Terminal.Gui.Analyzers.Tests +{ + public class V1ApiDiagnosticAnalyzerTests + { + private static async Task> GetDiagnosticsAsync(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("TestAssembly", + new[] { tree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new V1ApiDiagnosticAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } + + [Fact] + public void TestAnalyzerSupportedDiagnostics() + { + var analyzer = new V1ApiDiagnosticAnalyzer(); + Assert.Equal(14, analyzer.SupportedDiagnostics.Length); + } + + [Fact] + public async Task TestClickedTriggersDiagnostic() + { + var source = @" +class Test { + void Demo(dynamic btn) { + btn.Clicked += null; + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleClicked.Id); + } + + [Fact] + public async Task TestStaticApplicationTriggersDiagnostic() + { + var source = @" +class Application { public static void Init() {} } +class Test { + void Demo() { + Application.Init(); + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleApplicationStatic.Id); + } + + [Fact] + public async Task TestToplevelTriggersDiagnostic() + { + var source = @" +class Toplevel {} +class Test { + Toplevel top; +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleToplevel.Id); + } + + [Fact] + public async Task TestPositionalConstructorTriggersDiagnostic() + { + var source = @" +class Button { public Button(string text) {} } +class Test { + void Demo() { + var b = new Button(""OK""); + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RulePositionalConstructor.Id); + } + + [Fact] + public async Task TestTabViewTriggersDiagnostic() + { + var source = @" +class TabView {} +class Test { + void Demo() { + var tv = new TabView(); + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleTabView.Id); + } + + [Fact] + public async Task TestRadioGroupTriggersDiagnostic() + { + var source = @" +class RadioGroup {} +class Test { + void Demo() { + var rg = new RadioGroup(); + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleRadioGroup.Id); + } + + [Fact] + public async Task TestKeyBitmaskTriggersDiagnostic() + { + var source = @" +enum KeyCode { A, CtrlMask } +class Test { + void Demo() { + var k = KeyCode.A | KeyCode.CtrlMask; + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleKeyBitmask.Id); + } + + [Fact] + public async Task TestAcceptingIgnoredArgsTriggersDiagnostic() + { + var source = @" +class View { public event System.EventHandler Accepting; } +class Test { + void Demo(View view) { + view.Accepting += (_, _) => { }; + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleAcceptingIgnoresArgs.Id); + } + + [Fact] + public async Task TestMenuItem6ArgCtorTriggersDiagnostic() + { + var source = @" +class MenuItem { public MenuItem(string a, string b, System.Action c, System.Func d, object e, object f) {} } +class Test { + void Demo() { + var mi = new MenuItem(""a"", ""b"", null, null, null, null); + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleMenuItem6ArgCtor.Id); + } + + [Fact] + public async Task TestLayoutStyleTriggersDiagnostic() + { + var source = @" +class LayoutStyle { public static object Computed; } +class Test { + void Demo() { + var style = LayoutStyle.Computed; + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RuleLayoutStyle.Id); + } + + [Fact] + public async Task TestPosAtTriggersDiagnostic() + { + var source = @" +class Pos { public static object At(int n) => null; } +class Test { + void Demo() { + var p = Pos.At(5); + } +}"; + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == V1ApiDiagnosticAnalyzer.RulePosAt.Id); + } + } +} diff --git a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj index 096f6afe44..97b9a9b8b3 100644 --- a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj +++ b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj @@ -1,4 +1,4 @@ - + Exe true @@ -27,8 +27,10 @@ + + @@ -47,7 +49,9 @@ + + From ce910cf684ac523a1498a515a924b202a6c9c75e Mon Sep 17 00:00:00 2001 From: Omm Prakash Date: Tue, 6 Oct 2026 00:39:18 +0530 Subject: [PATCH 42/42] fix(analyzers): resolve RS1038, CS9208, and CS8600 warnings and configure nupkg analyzer bundling --- Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj | 2 +- Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs | 4 ++-- Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs | 6 +++--- Terminal.Gui/Terminal.Gui.csproj | 4 ++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj b/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj index 8c978eb73d..d9191013ab 100644 --- a/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj +++ b/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj @@ -8,7 +8,7 @@ Terminal.Gui.Analyzers true true - $(NoWarn);RS2008;RS1032 + $(NoWarn);RS2008;RS1032;RS1038 diff --git a/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs b/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs index ae72e80d0a..a6da94f578 100644 --- a/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs +++ b/Terminal.Gui.Analyzers/V1ApiCodeFixProvider.cs @@ -13,13 +13,13 @@ namespace Terminal.Gui.Analyzers [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(V1ApiCodeFixProvider)), Shared] public class V1ApiCodeFixProvider : CodeFixProvider { - public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(new[] { V1ApiDiagnosticAnalyzer.RuleClicked.Id, V1ApiDiagnosticAnalyzer.RuleToplevel.Id, V1ApiDiagnosticAnalyzer.RuleBounds.Id, V1ApiDiagnosticAnalyzer.RuleTabView.Id, V1ApiDiagnosticAnalyzer.RuleRadioGroup.Id, - V1ApiDiagnosticAnalyzer.RuleAcceptingIgnoresArgs.Id); + V1ApiDiagnosticAnalyzer.RuleAcceptingIgnoresArgs.Id }); public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs b/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs index dad974a078..c5563b4d05 100644 --- a/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs +++ b/Terminal.Gui.Analyzers/V1ApiDiagnosticAnalyzer.cs @@ -125,7 +125,7 @@ public class V1ApiDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticSeverity.Warning, isEnabledByDefault: true); - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(new[] { RuleClicked, RuleApplicationStatic, RuleToplevel, @@ -139,7 +139,7 @@ public class V1ApiDiagnosticAnalyzer : DiagnosticAnalyzer RuleAcceptingIgnoresArgs, RuleMenuItem6ArgCtor, RuleLayoutStyle, - RulePosAt); + RulePosAt }); public override void Initialize(AnalysisContext context) { @@ -271,7 +271,7 @@ private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) return; } - string typeName = objectCreation.Type switch + string? typeName = objectCreation.Type switch { IdentifierNameSyntax id => id.Identifier.Text, QualifiedNameSyntax qn => qn.Right.Identifier.Text, diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index fa28149f19..96c9c2241e 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -74,6 +74,10 @@ + + + +