diff --git a/Examples/UICatalog/Scenarios/TableEditor.cs b/Examples/UICatalog/Scenarios/TableEditor.cs index 955d778eed..336cfa4b7d 100644 --- a/Examples/UICatalog/Scenarios/TableEditor.cs +++ b/Examples/UICatalog/Scenarios/TableEditor.cs @@ -2,6 +2,7 @@ using System.Data; using System.Globalization; using System.Text; + // ReSharper disable StringLiteralTypo namespace UICatalog.Scenarios; @@ -13,7 +14,6 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Text and Formatting")] public class TableEditor : Scenario { - private IApplication? _app; private readonly HashSet? _checkedFileSystemInfos = []; private readonly List? _toDispose = []; @@ -163,6 +163,8 @@ public class TableEditor : Scenario new UnicodeRange (0xE0000, 0xE007F, "Tags") ]; + private IApplication? _app; + private Scheme? _alternatingScheme; private DataTable? _currentTable; private Scheme? _redScheme; @@ -259,7 +261,7 @@ public override void Main () _tableView!.Accepted += EditCurrentCell; _tableView!.KeyDown += TableViewKeyPress; - //SetupScrollBar (); + // SetupScrollBar (); _redScheme = new Scheme { @@ -322,6 +324,83 @@ public override void Main () app.Run (appWindow); } + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + + foreach (IDisposable d in _toDispose!) + { + d.Dispose (); + } + } + + private DataTable BuildUnicodeMap () + { + var dt = new DataTable (); + + // add cols called 0 to 9 + for (var i = 0; i < 10; i++) + { + DataColumn col = dt.Columns.Add (i.ToString (), typeof (uint)); + ColumnStyle style = _tableView!.Style.GetOrCreateColumnStyle (col.Ordinal); + + style.RepresentationGetter = RuneToString; + } + + // add cols called a to z + for (int i = 'a'; i < 'a' + 26; i++) + { + DataColumn col = dt.Columns.Add (((char)i).ToString (), typeof (uint)); + ColumnStyle style = _tableView!.Style.GetOrCreateColumnStyle (col.Ordinal); + style.RepresentationGetter = RuneToString; + } + + // now add table contents + List runes = []; + + foreach (UnicodeRange range in _ranges!) + { + for (uint i = range.Start; i <= range.End; i++) + { + runes.Add (i); + } + } + + DataRow? dr = null; + + for (var i = 0; i < runes.Count; i++) + { + if (dr == null || i % dt.Columns.Count == 0) + { + dr = dt.Rows.Add (); + } + + dr [i % dt.Columns.Count] = runes [i]; + } + + return dt; + } + + private void CheckOrUncheckFile (FileSystemInfo info, bool check) + { + if (check) + { + _checkedFileSystemInfos!.Add (info); + } + else + { + _checkedFileSystemInfos!.Remove (info); + } + } + + private void ClearColumnStyles () + { + _tableView!.Style.ColumnStyles.Clear (); + _tableView!.Update (); + } + + private void CloseExample () => _tableView!.Table = null; + private MenuBarItem CreateViewMenu () { // Store checkbox references for the toggle methods to access @@ -643,85 +722,6 @@ MenuItem CreateOptionSelectorMenuItem (string title, T initialState, Action runes = new (); - - foreach (UnicodeRange range in _ranges!) - { - for (uint i = range.Start; i <= range.End; i++) - { - runes.Add (i); - } - } - - DataRow? dr = null; - - for (var i = 0; i < runes.Count; i++) - { - if (dr == null || i % dt.Columns.Count == 0) - { - dr = dt.Rows.Add (); - } - - dr [i % dt.Columns.Count] = runes [i].ToString (); - } - - return dt; - } - - private string RuneToString (object o) => Rune.TryCreate ((uint)o, out Rune rune) ? rune.ToString () : " "; - - private void CheckOrUncheckFile (FileSystemInfo info, bool check) - { - if (check) - { - _checkedFileSystemInfos!.Add (info); - } - else - { - _checkedFileSystemInfos!.Remove (info); - } - } - - private void ClearColumnStyles () - { - _tableView!.Style.ColumnStyles.Clear (); - _tableView!.Update (); - } - - private void CloseExample () => _tableView!.Table = null; - private void EditCurrentCell (object? sender, CommandEventArgs args) { if (_tableView?.Table is not DataTableSource || _currentTable == null) @@ -884,36 +884,6 @@ private void OpenExample (bool big) private void OpenSimple (bool big) => SetTable (BuildSimpleDataTable (big ? 30 : 5, big ? 1000 : 5)); - // Demonstrates the fix for #5072: a column with very wide content used to consume all viewport - // space and push later columns off-screen. With the fix, "Description" is clamped so "Status" and - // "Owner" remain visible at their header widths. - private void OpenWideColumnExample () - { - DataTable dt = new (); - dt.Columns.Add ("Id", typeof (int)); - dt.Columns.Add ("Description", typeof (string)); - dt.Columns.Add ("Status", typeof (string)); - dt.Columns.Add ("Owner", typeof (string)); - - string [] statuses = ["Open", "InProgress", "Blocked", "Done"]; - string [] owners = ["Alice", "Bob", "Carol", "Dan"]; - - for (var i = 0; i < 25; i++) - { - dt.Rows.Add ( - i, - $"Row {i}: " + new string ('x', 120 + i % 40), - statuses [i % statuses.Length], - owners [i % owners.Length]); - } - - SetTable (dt); - - // Clear any styles inherited from a previous example - _tableView!.Style.ColumnStyles.Clear (); - _tableView!.Update (); - } - private void OpenTreeExample () { _tableView!.Style.ColumnStyles.Clear (); @@ -960,6 +930,32 @@ private void OpenUnicodeMap () _tableView?.Update (); } + // Demonstrates the fix for #5072: a column with very wide content used to consume all viewport + // space and push later columns off-screen. With the fix, "Description" is clamped so "Status" and + // "Owner" remain visible at their header widths. + private void OpenWideColumnExample () + { + DataTable dt = new (); + dt.Columns.Add ("Id", typeof (int)); + dt.Columns.Add ("Description", typeof (string)); + dt.Columns.Add ("Status", typeof (string)); + dt.Columns.Add ("Owner", typeof (string)); + + string [] statuses = ["Open", "InProgress", "Blocked", "Done"]; + string [] owners = ["Alice", "Bob", "Carol", "Dan"]; + + for (var i = 0; i < 25; i++) + { + dt.Rows.Add (i, $"Row {i}: " + new string ('x', 120 + i % 40), statuses [i % statuses.Length], owners [i % owners.Length]); + } + + SetTable (dt); + + // Clear any styles inherited from a previous example + _tableView!.Style.ColumnStyles.Clear (); + _tableView!.Update (); + } + private void Quit () => _tableView?.App?.RequestStop (); private void RunColumnWidthDialog (int? col, string prompt, Action setter, Func getter) @@ -1000,6 +996,8 @@ private void RunColumnWidthDialog (int? col, string prompt, Action Rune.TryCreate ((uint)o, out Rune rune) ? rune.ToString () : " "; + private void SetDemoTableStyles () { _tableView!.Style.ColumnStyles.Clear (); @@ -1084,8 +1082,8 @@ private void SetMinWidth () private void SetTable (DataTable dataTable) => _tableView!.Table = new DataTableSource (_currentTable = dataTable); - //private void SetupScrollBar () - //{ + // private void SetupScrollBar () + // { // var scrollBar = new ScrollBarView (_tableView, true); // scrollBar.ChangedPosition += (s, e) => @@ -1117,7 +1115,7 @@ private void SetMinWidth () // //scrollBar.OtherScrollBarView.Position = tableView.LeftItem; // scrollBar.Refresh (); // }; - //} + // } private void ShowAllColumns () { diff --git a/Terminal.Gui/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs index b0f986d460..98c4492769 100644 --- a/Terminal.Gui/App/ApplicationImpl.Screen.cs +++ b/Terminal.Gui/App/ApplicationImpl.Screen.cs @@ -1,9 +1,8 @@ +using System.Diagnostics; using Terminal.Gui.Tracing; namespace Terminal.Gui.App; -using Trace = Trace; - internal partial class ApplicationImpl { /// @@ -102,7 +101,7 @@ private void Driver_SizeChanged (object? sender, SizeChangedEventArgs e) /// public void LayoutAndDraw (bool forceRedraw = false) { - Trace.Draw ("ApplicationImpl", "Start", $"forceRedraw={forceRedraw}, Screen={Screen}, _inlineScreenSized={_inlineScreenSized}"); + Tracing.Trace.Draw ("ApplicationImpl", "Start", $"forceRedraw={forceRedraw}, Screen={Screen}, _inlineScreenSized={_inlineScreenSized}"); if (ClearScreenNextIteration) { @@ -284,7 +283,7 @@ public void LayoutAndDraw (bool forceRedraw = false) { LayoutAndDrawComplete?.Invoke (this, EventArgs.Empty); } - Trace.Draw ("ApplicationImpl", "End", $"neededLayout={neededLayout}, needsDraw={needsDraw}"); + Tracing.Trace.Draw ("ApplicationImpl", "End", $"neededLayout={neededLayout}, needsDraw={needsDraw}"); } /// diff --git a/Terminal.Gui/App/Mouse/ApplicationMouse.cs b/Terminal.Gui/App/Mouse/ApplicationMouse.cs index 413b18b8e7..f6c101d756 100644 --- a/Terminal.Gui/App/Mouse/ApplicationMouse.cs +++ b/Terminal.Gui/App/Mouse/ApplicationMouse.cs @@ -1,5 +1,6 @@ using System.ComponentModel; -using Terminal.Gui.Tracing; +using System.Diagnostics; +using Trace = Terminal.Gui.Tracing.Trace; namespace Terminal.Gui.App; @@ -21,21 +22,6 @@ public ApplicationMouse () => // Subscribe to Application static property change events Application.IsMouseDisabledChanged += OnIsMouseDisabledChanged; - /// - public IApplication? App { get; set; } - - /// - public Point? LastMousePosition { get; set; } - - /// - public bool IsMouseDisabled { get; set; } - - // Event handler for Application static property changes - private void OnIsMouseDisabledChanged (object? sender, ValueChangedEventArgs e) => IsMouseDisabled = e.NewValue; - - /// - public List CachedViewsUnderMouse { get; } = []; - /// /// The popover that was just dismissed by a mouse-press-outside event. /// Used to prevent re-entrant show of the same popover during the @@ -58,12 +44,26 @@ public ApplicationMouse () => /// private bool _isDismissRecursing; - /// - /// Gets the popover that was just dismissed by a mouse-press-outside event, if any. - /// Checked by to suppress re-show during the - /// same press → release → click cycle. - /// - internal IPopoverView? DismissedByMousePress => _dismissedByMousePress; + /// + public void Dispose () + { + ResetState (); + + // Unsubscribe from Application static property change events + Application.IsMouseDisabledChanged -= OnIsMouseDisabledChanged; + } + + /// + public IApplication? App { get; set; } + + /// + public Point? LastMousePosition { get; set; } + + /// + public bool IsMouseDisabled { get; set; } + + /// + public List CachedViewsUnderMouse { get; } = []; /// public event EventHandler? MouseEvent; @@ -288,13 +288,6 @@ public void RaiseMouseEvent (Mouse mouseEvent) } } - /// - /// Returns when the mouse is currently grabbed by a view - /// that belongs to 's view hierarchy. - /// - private bool IsGrabbedByViewInHierarchy (View hierarchyRoot) => - _mouseGrabViewRef?.TryGetTarget (out View? grabbed) is true && View.IsInHierarchy (hierarchyRoot, grabbed, true); - /// public void RaiseMouseEnterLeaveEvents (Point screenPosition, List currentViewsUnderMouse) { @@ -364,6 +357,34 @@ public void RaiseMouseEnterLeaveEvents (Point screenPosition, List curren } } + /// + public void ResetState () + { + // Do not clear LastMousePosition; Popovers require it to stay set with last mouse pos. + CachedViewsUnderMouse.Clear (); + MouseEvent = null; + _mouseGrabViewRef = null; + _dismissedByMousePress = null; + _isDismissRecursing = false; + } + + /// + /// Gets the popover that was just dismissed by a mouse-press-outside event, if any. + /// Checked by to suppress re-show during the + /// same press → release → click cycle. + /// + internal IPopoverView? DismissedByMousePress => _dismissedByMousePress; + + /// + /// Returns when the mouse is currently grabbed by a view + /// that belongs to 's view hierarchy. + /// + private bool IsGrabbedByViewInHierarchy (View hierarchyRoot) => + _mouseGrabViewRef?.TryGetTarget (out View? grabbed) is true && View.IsInHierarchy (hierarchyRoot, grabbed, true); + + // Event handler for Application static property changes + private void OnIsMouseDisabledChanged (object? sender, ValueChangedEventArgs e) => IsMouseDisabled = e.NewValue; + #region IMouseGrabHandler Implementation private WeakReference? _mouseGrabViewRef; @@ -546,24 +567,4 @@ public bool HandleMouseGrab (View? deepestViewUnderMouse, Mouse mouse) } #endregion IMouseGrabHandler Implementation - - /// - public void ResetState () - { - // Do not clear LastMousePosition; Popovers require it to stay set with last mouse pos. - CachedViewsUnderMouse.Clear (); - MouseEvent = null; - _mouseGrabViewRef = null; - _dismissedByMousePress = null; - _isDismissRecursing = false; - } - - /// - public void Dispose () - { - ResetState (); - - // Unsubscribe from Application static property change events - Application.IsMouseDisabledChanged -= OnIsMouseDisabledChanged; - } } diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index ba45bff6b7..9a40d2153d 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -9,6 +9,27 @@ namespace Terminal.Gui.Drivers; /// public class OutputBufferImpl : IOutputBuffer { + /// + /// Stable lock object for synchronizing access to , , + /// , and related state. Unlike locking on Contents itself, this object is + /// never replaced, guaranteeing mutual exclusion across , + /// , and . + /// + private readonly Lock _contentsLock = new (); + + private int _cols; + private int _rows; + + /// + /// Maps cell positions to URLs for OSC 8 hyperlink support. + /// Only stores entries for cells that actually have URLs, minimizing memory overhead. + /// + private Dictionary? _urlMap; + + private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; + + private Region? _clip; + /// /// The contents of the application output. The driver outputs this buffer to the terminal when /// UpdateScreen is called. @@ -16,9 +37,6 @@ public class OutputBufferImpl : IOutputBuffer /// public Cell [,]? Contents { get; set; } = new Cell [0, 0]; - private int _cols; - private int _rows; - /// /// The that will be used for the next or /// call. @@ -32,30 +50,18 @@ public class OutputBufferImpl : IOutputBuffer /// public string? CurrentUrl { get; set; } - /// - /// Maps cell positions to URLs for OSC 8 hyperlink support. - /// Only stores entries for cells that actually have URLs, minimizing memory overhead. - /// - private Dictionary? _urlMap; - /// /// Gets the URL associated with the cell at the specified position. /// /// The column. /// The row. /// The URL if one exists, otherwise null. - public string? GetCellUrl (int col, int row) => _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; - - /// - /// Sets the URL for the cell at the specified position. - /// - /// The column. - /// The row. - /// The URL to associate with this cell. - private void SetCellUrl (int col, int row, string url) + public string? GetCellUrl (int col, int row) { - _urlMap ??= []; - _urlMap [new Point (col, row)] = url; + lock (_contentsLock) + { + return _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; + } } /// The leftmost column in the terminal. @@ -98,8 +104,6 @@ public int Cols /// The topmost row in the terminal. public virtual int Top { get; set; } = 0; - private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; - /// public void SetWideGlyphReplacement (Rune column1ReplacementChar) => _column1ReplacementChar = column1ReplacementChar; @@ -108,12 +112,6 @@ public int Cols /// public bool [] DirtyLines { get; set; } = []; - // QUESTION: When non-full screen apps are supported, will this represent the app size, or will that be in Application? - /// Gets the location and size of the terminal screen. - internal Rectangle Screen => new (0, 0, Cols, Rows); - - private Region? _clip; - /// /// Gets or sets the clip rectangle that and are subject /// to. @@ -177,24 +175,166 @@ public void AddStr (string str) } } + /// Clears the of the driver. + public void ClearContents () => ClearContents (!InlineMode); + + /// Tests whether the specified coordinate are valid for drawing the specified Text. + /// Used to determine if one or two columns are required. + /// The column. + /// The row. + /// + /// if the coordinate is outside the screen bounds or outside of . + /// otherwise. + /// + public bool IsValidLocation (string text, int col, int row) + { + int textWidth = text.GetColumns (); + + return col >= 0 && row >= 0 && col + textWidth <= Cols && row < Rows && Clip!.Contains (col, row); + } + + /// + public void SetSize (int cols, int rows) + { + lock (_contentsLock) + { + _cols = cols; + _rows = rows; + ClearContentsCore (!InlineMode); + } + } + + /// + public void FillRect (Rectangle rect, Rune rune) + { + lock (_contentsLock) + { + if (Contents is null) + { + return; + } + + Clip ??= new Region (Screen); + Rectangle clipBounds = Clip!.GetBounds (); + + rect = Rectangle.Intersect (rect, clipBounds); + + for (int r = rect.Y; r < rect.Y + rect.Height; r++) + { + for (int c = rect.X; c < rect.X + rect.Width; c++) + { + if (!IsValidLocation (rune.ToString (), c, r)) + { + continue; + } + + // We could call AddGrapheme here, but that would acquire the lock again. + // So we inline the logic instead. + SetAttributeAndDirty (c, r); + InvalidateOverlappedWideGlyph (c, r); + string grapheme = rune != default (Rune) ? rune.ToString () : " "; + WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); + } + } + } + } + + /// + public void FillRect (Rectangle rect, char rune) + { + FillRect (rect, new Rune (rune)); + } + + /// + /// Updates and to the specified column and row in . + /// Used by and to determine where to add content. + /// + /// + /// This does not move the cursor on the screen, it only updates the internal state of the driver. + /// + /// If or are negative or beyond and + /// , the method still sets those properties. + /// + /// + /// Column to move to. + /// Row to move to. + public void Move (int col, int row) + { + Col = col; + Row = row; + } + + /// Clears the of the driver. + /// + /// When (the default), all cells are marked dirty so the first render + /// overwrites the entire screen. When (used in inline mode), cells start + /// clean so only cells that are explicitly drawn will be flushed, leaving the rest of the terminal + /// untouched. + /// + public void ClearContents (bool initiallyDirty) + { + lock (_contentsLock) + { + ClearContentsCore (initiallyDirty); + } + } + + /// + /// Non-locking implementation of . + /// Caller must already hold . + /// + private void ClearContentsCore (bool initiallyDirty) + { + Contents = new Cell [Rows, Cols]; + + // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. + Clip = new Region (Screen); + + DirtyLines = new bool [Rows]; + + // Clear the URL map + _urlMap?.Clear (); + + for (var row = 0; row < Rows; row++) + { + for (var c = 0; c < Cols; c++) + { + Contents [row, c] = new Cell { Grapheme = " ", Attribute = new Attribute (Color.White, Color.Black), IsDirty = initiallyDirty }; + } + + DirtyLines [row] = initiallyDirty; + } + } + + /// + /// Gets or sets a value indicating whether this buffer is operating in inline mode. + /// When , initialises cells with + /// IsDirty = false so that only cells explicitly drawn are flushed to the + /// terminal, leaving the rest of the visible terminal untouched. + /// + public bool InlineMode { get; set; } + + /// Gets the location and size of the terminal screen. + internal Rectangle Screen => new (0, 0, Cols, Rows); + /// /// Adds a single grapheme to the display at the current cursor position. /// /// The grapheme to add. private void AddGrapheme (string grapheme) { - if (Contents is null) + lock (_contentsLock) { - return; - } + if (Contents is null) + { + return; + } - Clip ??= new Region (Screen); - Rectangle clipRect = Clip!.GetBounds (); + Clip ??= new Region (Screen); + Rectangle clipRect = Clip!.GetBounds (); - int printableGraphemeWidth = -1; + int printableGraphemeWidth = -1; - lock (Contents) - { if (IsValidLocation (grapheme, Col, Row)) { // Set attribute and mark dirty for current cell @@ -237,6 +377,22 @@ private void AddGrapheme (string grapheme) } } + /// + /// INTERNAL: If we're writing at an odd column and there's a wide glyph to our left, + /// invalidate it since we're overwriting the second half. + /// + /// The column. + /// The row. + private void InvalidateOverlappedWideGlyph (int col, int row) + { + if (col <= 0 || Contents! [row, col - 1].Grapheme.GetColumns () <= 1) + { + return; + } + Contents [row, col - 1].Grapheme = _column1ReplacementChar.ToString (); + Contents [row, col - 1].IsDirty = true; + } + /// /// INTERNAL: Helper to set the attribute and mark the cell as dirty. /// @@ -255,19 +411,39 @@ private void SetAttributeAndDirty (int col, int row) } /// - /// INTERNAL: If we're writing at an odd column and there's a wide glyph to our left, - /// invalidate it since we're overwriting the second half. + /// Sets the URL for the cell at the specified position. /// /// The column. /// The row. - private void InvalidateOverlappedWideGlyph (int col, int row) + /// The URL to associate with this cell. + private void SetCellUrl (int col, int row, string url) { - if (col <= 0 || Contents! [row, col - 1].Grapheme.GetColumns () <= 1) + _urlMap ??= []; + _urlMap [new Point (col, row)] = url; + } + + /// + /// INTERNAL: Writes a (0 or 1 column wide) Grapheme. + /// + /// The column. + /// The row. + /// The single-width Grapheme to write. + /// The clipping rectangle. + private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRect) + { + if (grapheme is null) { return; } - Contents [row, col - 1].Grapheme = _column1ReplacementChar.ToString (); - Contents [row, col - 1].IsDirty = true; + + Debug.Assert (grapheme.GetColumns () < 2); + Contents! [row, col].Grapheme = grapheme; + + // Mark the next cell as dirty to ensure proper rendering of adjacent content + if (col < clipRect.Right - 1 && col + 1 < Cols) + { + Contents [row, col + 1].IsDirty = true; + } } /// @@ -302,25 +478,6 @@ private void WriteGraphemeByWidth (int col, int row, string text, int textWidth, } } - /// - /// INTERNAL: Writes a (0 or 1 column wide) Grapheme. - /// - /// The column. - /// The row. - /// The single-width Grapheme to write. - /// The clipping rectangle. - private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRect) - { - Debug.Assert (grapheme.GetColumns () < 2); - Contents! [row, col].Grapheme = grapheme; - - // Mark the next cell as dirty to ensure proper rendering of adjacent content - if (col < clipRect.Right - 1 && col + 1 < Cols) - { - Contents [row, col + 1].IsDirty = true; - } - } - /// /// INTERNAL: Writes a wide Grapheme (2 columns wide) handling clipping and partial overlap cases. /// @@ -349,134 +506,4 @@ private void WriteWideGrapheme (int col, int row, string grapheme) // See: https://github.com/gui-cs/Terminal.Gui/issues/4258 } } - - /// - /// Gets or sets a value indicating whether this buffer is operating in inline mode. - /// When , initialises cells with - /// IsDirty = false so that only cells explicitly drawn are flushed to the - /// terminal, leaving the rest of the visible terminal untouched. - /// - public bool InlineMode { get; set; } - - /// Clears the of the driver. - public void ClearContents () => ClearContents (!InlineMode); - - /// Clears the of the driver. - /// - /// When (the default), all cells are marked dirty so the first render - /// overwrites the entire screen. When (used in inline mode), cells start - /// clean so only cells that are explicitly drawn will be flushed, leaving the rest of the terminal - /// untouched. - /// - public void ClearContents (bool initiallyDirty) - { - Contents = new Cell [Rows, Cols]; - - // CONCURRENCY: Unsynchronized access to Clip isn't safe. - // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. - Clip = new Region (Screen); - - DirtyLines = new bool [Rows]; - - // Clear the URL map - _urlMap?.Clear (); - - lock (Contents) - { - for (var row = 0; row < Rows; row++) - { - for (var c = 0; c < Cols; c++) - { - Contents [row, c] = new Cell { Grapheme = " ", Attribute = new Attribute (Color.White, Color.Black), IsDirty = initiallyDirty }; - } - - DirtyLines [row] = initiallyDirty; - } - } - } - - /// Tests whether the specified coordinate are valid for drawing the specified Text. - /// Used to determine if one or two columns are required. - /// The column. - /// The row. - /// - /// if the coordinate is outside the screen bounds or outside of . - /// otherwise. - /// - public bool IsValidLocation (string text, int col, int row) - { - int textWidth = text.GetColumns (); - - return col >= 0 && row >= 0 && col + textWidth <= Cols && row < Rows && Clip!.Contains (col, row); - } - - /// - public void SetSize (int cols, int rows) - { - Cols = cols; - Rows = rows; - ClearContents (); - } - - /// - public void FillRect (Rectangle rect, Rune rune) - { - Rectangle clipBounds = Clip?.GetBounds () ?? Screen; - - // BUGBUG: This should be a method on Region - rect = Rectangle.Intersect (rect, clipBounds); - - lock (Contents!) - { - for (int r = rect.Y; r < rect.Y + rect.Height; r++) - { - for (int c = rect.X; c < rect.X + rect.Width; c++) - { - if (!IsValidLocation (rune.ToString (), c, r)) - { - continue; - } - - // We could call AddGrapheme here, but that would acquire the lock again. - // So we inline the logic instead. - SetAttributeAndDirty (c, r); - InvalidateOverlappedWideGlyph (c, r); - string grapheme = rune != default (Rune) ? rune.ToString () : " "; - WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); - } - } - } - } - - /// - public void FillRect (Rectangle rect, char rune) - { - for (int y = rect.Top; y < rect.Top + rect.Height; y++) - { - for (int x = rect.Left; x < rect.Left + rect.Width; x++) - { - Move (x, y); - AddRune (rune); - } - } - } - - /// - /// Updates and to the specified column and row in . - /// Used by and to determine where to add content. - /// - /// - /// This does not move the cursor on the screen, it only updates the internal state of the driver. - /// - /// If or are negative or beyond and - /// , the method still sets those properties. - /// - /// - /// Column to move to. - /// Row to move to. - public void Move (int col, int row) - { - Col = col; - Row = row; - } } diff --git a/Terminal.Gui/Text/RuneExtensions.cs b/Terminal.Gui/Text/RuneExtensions.cs index c8a273c834..ffa20ddb62 100644 --- a/Terminal.Gui/Text/RuneExtensions.cs +++ b/Terminal.Gui/Text/RuneExtensions.cs @@ -7,6 +7,8 @@ namespace Terminal.Gui.Text; /// Extends to support TUI text manipulation. public static class RuneExtensions { + private static readonly Lock _wcwidthLock = new Lock (); + /// Maximum Unicode code point. public static readonly int MaxUnicodeCodePoint = 0x10FFFF; @@ -125,7 +127,13 @@ public static bool EncodeSurrogatePair (char highSurrogate, char lowSurrogate, o /// The number of columns required to fit the rune, 0 if the argument is the null character, or -1 if the value is /// not printable, otherwise the number of columns that the rune occupies. /// - public static int GetColumns (this Rune rune) { return UnicodeCalculator.GetWidth (rune); } + public static int GetColumns (this Rune rune) + { + lock (_wcwidthLock) + { + return UnicodeCalculator.GetWidth (rune); + } + } /// Get number of bytes required to encode the rune, based on the provided encoding. /// This is a Terminal.Gui extension method to to support TUI text manipulation. diff --git a/Terminal.Gui/Text/StringExtensions.cs b/Terminal.Gui/Text/StringExtensions.cs index 59f433b60d..4693a28d7d 100644 --- a/Terminal.Gui/Text/StringExtensions.cs +++ b/Terminal.Gui/Text/StringExtensions.cs @@ -91,7 +91,7 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) /// This is a Terminal.Gui extension method to to support TUI text manipulation. /// The string to count. /// - public static int GetRuneCount (this string str) { return str.EnumerateRunes ().Count (); } + public static int GetRuneCount (this string str) => str.EnumerateRunes ().Count (); /// /// Determines if this of is composed entirely of ASCII @@ -102,7 +102,7 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) /// A indicating if all elements of the are ASCII digits ( /// ) or not ( /// - public static bool IsAllAsciiDigits (this ReadOnlySpan stringSpan) { return !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiDigit); } + public static bool IsAllAsciiDigits (this ReadOnlySpan stringSpan) => !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiDigit); /// /// Determines if this of is composed entirely of ASCII @@ -113,7 +113,42 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) /// A indicating if all elements of the are ASCII digits ( /// ) or not ( /// - public static bool IsAllAsciiHexDigits (this ReadOnlySpan stringSpan) { return !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiHexDigit); } + public static bool IsAllAsciiHexDigits (this ReadOnlySpan stringSpan) => !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiHexDigit); + + /// Reports whether a string is a surrogate code point. + /// This is a Terminal.Gui extension method to to support TUI text manipulation. + /// The string to probe. + /// if the string is a surrogate code point; otherwise. + public static bool IsSurrogatePair (this string str) + { + if (str.Length != 2) + { + return false; + } + + var rune = Rune.GetRuneAt (str, 0); + + return rune.IsSurrogatePair (); + } + + /// + /// Ensures the text is not a control character and can be displayed by translating characters below 0x20 to + /// equivalent, printable, Unicode chars. + /// + /// This is a Terminal.Gui extension method to to support TUI text manipulation. + /// The text. + /// + public static string MakePrintable (this string str) + { + if (str.Length > 1) + { + return str; + } + + char ch = str [0]; + + return char.IsControl (ch) ? new string ((char)(ch + 0x2400), 1) : str; + } /// Repeats the string times. /// This is a Terminal.Gui extension method to to support TUI text manipulation. @@ -132,30 +167,28 @@ public static int GetColumns (this string str, bool ignoreLessThanZero = true) return str; } - return new StringBuilder (str.Length * n) - .Insert (0, str, n) - .ToString (); + return new StringBuilder (str.Length * n).Insert (0, str, n).ToString (); } /// Converts the string into a . /// This is a Terminal.Gui extension method to to support TUI text manipulation. /// The string to convert. /// - public static List ToRuneList (this string str) { return str.EnumerateRunes ().ToList (); } + public static List ToRuneList (this string str) => str.EnumerateRunes ().ToList (); /// Converts the string into a array. /// This is a Terminal.Gui extension method to to support TUI text manipulation. /// The string to convert. /// - public static Rune [] ToRunes (this string str) { return str.EnumerateRunes ().ToArray (); } + public static Rune [] ToRunes (this string str) => str.EnumerateRunes ().ToArray (); /// Converts a generic collection into a string. /// The enumerable rune to convert. /// public static string ToString (IEnumerable runes) { - const int maxCharsPerRune = 2; - const int maxStackallocTextBufferSize = 1048; // ~2 kB + const int MAX_CHARS_PER_RUNE = 2; + const int MAX_STACKALLOC_TEXT_BUFFER_SIZE = 1048; // ~2 kB // If rune count is easily available use stackalloc buffer or alternatively rented array. if (runes.TryGetNonEnumeratedCount (out int count)) @@ -165,22 +198,26 @@ public static string ToString (IEnumerable runes) return string.Empty; } - char[]? rentedBufferArray = null; + char []? rentedBufferArray = null; + try { - int maxRequiredTextBufferSize = count * maxCharsPerRune; - Span textBuffer = maxRequiredTextBufferSize <= maxStackallocTextBufferSize - ? stackalloc char[maxRequiredTextBufferSize] - : (rentedBufferArray = ArrayPool.Shared.Rent(maxRequiredTextBufferSize)); + int maxRequiredTextBufferSize = count * MAX_CHARS_PER_RUNE; + + Span textBuffer = maxRequiredTextBufferSize <= MAX_STACKALLOC_TEXT_BUFFER_SIZE + ? stackalloc char [maxRequiredTextBufferSize] + : rentedBufferArray = ArrayPool.Shared.Rent (maxRequiredTextBufferSize); Span remainingBuffer = textBuffer; + foreach (Rune rune in runes) { int charsWritten = rune.EncodeToUtf16 (remainingBuffer); remainingBuffer = remainingBuffer [charsWritten..]; } - ReadOnlySpan text = textBuffer[..^remainingBuffer.Length]; + ReadOnlySpan text = textBuffer [..^remainingBuffer.Length]; + return text.ToString (); } finally @@ -193,14 +230,16 @@ public static string ToString (IEnumerable runes) } // Fallback to StringBuilder append. - StringBuilder stringBuilder = new(); - Span runeBuffer = stackalloc char[maxCharsPerRune]; + StringBuilder stringBuilder = new (); + Span runeBuffer = stackalloc char [MAX_CHARS_PER_RUNE]; + foreach (Rune rune in runes) { int charsWritten = rune.EncodeToUtf16 (runeBuffer); ReadOnlySpan runeChars = runeBuffer [..charsWritten]; stringBuilder.Append (runeChars); } + return stringBuilder.ToString (); } @@ -218,7 +257,7 @@ public static string ToString (IEnumerable bytes, Encoding? encoding = nul /// Converts a generic collection into a string. /// The enumerable string to convert. /// - public static string ToString (IEnumerable strings) { return string.Concat (strings); } + public static string ToString (IEnumerable strings) => string.Concat (strings); /// Converts the string into a . /// This is a Terminal.Gui extension method to to support TUI text manipulation. @@ -235,39 +274,4 @@ public static List ToStringList (this string str) return strings; } - - /// Reports whether a string is a surrogate code point. - /// This is a Terminal.Gui extension method to to support TUI text manipulation. - /// The string to probe. - /// if the string is a surrogate code point; otherwise. - public static bool IsSurrogatePair (this string str) - { - if (str.Length != 2) - { - return false; - } - - Rune rune = Rune.GetRuneAt (str, 0); - - return rune.IsSurrogatePair (); - } - - /// - /// Ensures the text is not a control character and can be displayed by translating characters below 0x20 to - /// equivalent, printable, Unicode chars. - /// - /// This is a Terminal.Gui extension method to to support TUI text manipulation. - /// The text. - /// - public static string MakePrintable (this string str) - { - if (str.Length > 1) - { - return str; - } - - char ch = str [0]; - - return char.IsControl (ch) ? new ((char)(ch + 0x2400), 1) : str; - } } diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputBufferBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputBufferBenchmark.cs new file mode 100644 index 0000000000..cfe036f637 --- /dev/null +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputBufferBenchmark.cs @@ -0,0 +1,102 @@ +using System.Drawing; +using System.Text; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Drivers; +using Attribute = Terminal.Gui.Drawing.Attribute; +using Color = Terminal.Gui.Drawing.Color; + +namespace Terminal.Gui.Benchmarks.ConsoleDrivers.OutputBuffer; + +/// +/// Benchmarks for operations. +/// Measures the performance of AddStr, FillRect, ClearContents, and SetSize +/// to establish a baseline before and after the _contentsLock fix (#5130). +/// +[MemoryDiagnoser] +[BenchmarkCategory ("OutputBuffer")] +public class OutputBufferBenchmark +{ + private OutputBufferImpl _buffer = null!; + + [Params (80, 200)] + public int Cols { get; set; } + + [Params (25, 50)] + public int Rows { get; set; } + + [GlobalSetup] + public void Setup () + { + _buffer = new (); + _buffer.SetSize (Cols, Rows); + _buffer.CurrentAttribute = new Attribute (Color.White, Color.Black); + } + + /// + /// Baseline: Write a full row of text using AddStr. + /// + [Benchmark (Baseline = true)] + public void AddStr_FullRow () + { + _buffer.Move (0, 0); + _buffer.AddStr (new string ('A', Cols)); + } + + /// + /// Write text to every row in the buffer. + /// + [Benchmark] + public void AddStr_AllRows () + { + string line = new ('A', Cols); + + for (var row = 0; row < Rows; row++) + { + _buffer.Move (0, row); + _buffer.AddStr (line); + } + } + + /// + /// Fill the entire screen using FillRect(Rectangle, Rune). + /// + [Benchmark] + public void FillRect_FullScreen () + { + _buffer.FillRect (new Rectangle (0, 0, Cols, Rows), new Rune (' ')); + } + + /// + /// ClearContents resets the entire buffer. + /// + [Benchmark] + public void ClearContents () + { + _buffer.ClearContents (); + } + + /// + /// SetSize triggers ClearContents internally. + /// + [Benchmark] + public void SetSize () + { + _buffer.SetSize (Cols, Rows); + } + + /// + /// Simulates a typical draw cycle: clear, then fill every row. + /// + [Benchmark] + public void TypicalDrawCycle () + { + _buffer.ClearContents (); + string line = new ('X', Cols); + + for (var row = 0; row < Rows; row++) + { + _buffer.Move (0, row); + _buffer.AddStr (line); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBufferImplConcurrencyTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBufferImplConcurrencyTests.cs new file mode 100644 index 0000000000..1371ef99b8 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBufferImplConcurrencyTests.cs @@ -0,0 +1,441 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace DriverTests.Output; + +/// +/// Proves the race conditions in where +/// replaces the Contents +/// reference while other methods are concurrently operating on it. +/// See: https://github.com/gui-cs/Terminal.Gui/issues/5130 +/// +public class OutputBufferImplConcurrencyTests +{ + // Copilot + + private const int ITERATIONS = 5_000; + + /// + /// Concurrently calls and + /// from separate threads. + /// With the current broken lock (Contents) pattern this reproduces + /// crashes (AccessViolationException, NullReferenceException, or + /// IndexOutOfRangeException) within a few hundred iterations. + /// + [Fact] + public void AddStr_And_ClearContents_Concurrent_DoesNotThrow () + { + ConcurrentBag exceptions = RunConcurrent ((buffer, i, _) => + { + buffer.Move (0, i % 25); + buffer.AddStr (new string ('A', 80)); + }, + (buffer, i, _) => + { + // Alternate sizes to maximize chance of IndexOutOfRange + buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); + }); + + AssertNoExceptions (exceptions); + } + + /// + /// Runs and + /// concurrently with to exercise all three write paths + /// simultaneously. + /// + [Fact] + public void AddStr_FillRect_And_ClearContents_ThreeWay_Concurrent_DoesNotThrow () + { + OutputBufferImpl buffer = new (); + buffer.SetSize (80, 25); + + ConcurrentBag exceptions = []; + CancellationTokenSource cts = new (); + + Thread addStrThread = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + buffer.Move (0, i % 25); + buffer.AddStr (new string ('X', 40)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + Thread fillRectThread = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + buffer.FillRect (new Rectangle (0, 0, 20, 5), new Rune ('Z')); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + Thread clearerThread = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + addStrThread.Start (); + fillRectThread.Start (); + clearerThread.Start (); + + TimeSpan joinTimeout = TimeSpan.FromSeconds (10); + bool addStrJoined = addStrThread.Join (joinTimeout); + bool fillRectJoined = fillRectThread.Join (joinTimeout); + bool clearerJoined = clearerThread.Join (joinTimeout); + + cts.Cancel (); + + if (!addStrJoined) + { + addStrJoined = addStrThread.Join (TimeSpan.FromSeconds (5)); + } + + if (!fillRectJoined) + { + fillRectJoined = fillRectThread.Join (TimeSpan.FromSeconds (5)); + } + + if (!clearerJoined) + { + clearerJoined = clearerThread.Join (TimeSpan.FromSeconds (5)); + } + + Assert.True (addStrJoined, "addStrThread did not stop within the timeout."); + Assert.True (fillRectJoined, "fillRectThread did not stop within the timeout."); + Assert.True (clearerJoined, "clearerThread did not stop within the timeout."); + + AssertNoExceptions (exceptions); + } + + /// + /// Concurrently calls and + /// from separate threads. + /// The FillRect(Rectangle, Rune) overload has the same broken lock (Contents!) pattern. + /// + [Fact] + public void FillRect_And_ClearContents_Concurrent_DoesNotThrow () + { + ConcurrentBag exceptions = RunConcurrent ((buffer, _, _) => + { + // Fill a region that fits within the smallest size (40x10) + buffer.FillRect (new Rectangle (0, 0, 30, 8), new Rune ('B')); + }, + (buffer, i, _) => { buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); }); + + AssertNoExceptions (exceptions); + } + + /// + /// Concurrently calls , , + /// and from separate threads to verify that + /// interleaved Move + AddStr doesn't corrupt state when Contents is being replaced. + /// + [Fact] + public void Move_AddStr_And_ClearContents_Concurrent_DoesNotThrow () + { + ConcurrentBag exceptions = RunConcurrent ((buffer, i, _) => + { + // Rapidly move around and write short strings + buffer.Move (i % 40, i % 10); + buffer.AddStr ("Hello"); + buffer.Move ((i + 20) % 40, (i + 5) % 10); + buffer.AddStr ("World!"); + }, + (buffer, i, _) => { buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); }); + + AssertNoExceptions (exceptions); + } + + /// + /// Proves that has a race condition + /// because it calls Move + AddRune without holding _contentsLock across + /// the pair. A concurrent writer can change Col/Row between the Move and AddRune, + /// causing characters to be written at wrong positions. + /// + [Fact] + public void FillRect_Char_NonAtomic_Move_AddRune_Causes_Wrong_Position () + { + // Copilot + // This test proves that FillRect(Rectangle, char) is not atomic: + // Two threads both call FillRect(char) targeting different rows. If Move+AddRune + // were atomic, each row should contain only its own character. Because they + // are NOT atomic, Col/Row shared state gets corrupted between threads. + OutputBufferImpl buffer = new (); + buffer.SetSize (80, 25); + + ConcurrentBag exceptions = []; + var wrongPositionDetected = false; + + const int iterations = 50_000; + CancellationTokenSource cts = new (); + + // Thread 1: fills row 0, cols 0-79 with 'A' + Thread thread1 = new (() => + { + try + { + for (var i = 0; i < iterations && !cts.IsCancellationRequested; i++) + { + buffer.FillRect (new Rectangle (0, 0, 80, 1), 'A'); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + // Thread 2: fills row 20, cols 0-79 with 'B' + Thread thread2 = new (() => + { + try + { + for (var i = 0; i < iterations && !cts.IsCancellationRequested; i++) + { + buffer.FillRect (new Rectangle (0, 20, 80, 1), 'B'); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + // Checker thread: looks for 'B' in row 0 or 'A' in row 20 + Thread checker = new (() => + { + try + { + while (!cts.IsCancellationRequested) + { + Cell [,]? contents = buffer.Contents; + + if (contents is null) + { + continue; + } + + // Check if row 0 has 'B' (should only have 'A' or space) + for (var c = 0; c < 80; c++) + { + string grapheme = contents [0, c].Grapheme; + + if (grapheme == "B") + { + wrongPositionDetected = true; + cts.Cancel (); + + return; + } + } + + // Check if row 20 has 'A' (should only have 'B' or space) + for (var c = 0; c < 80; c++) + { + string grapheme = contents [20, c].Grapheme; + + if (grapheme == "A") + { + wrongPositionDetected = true; + cts.Cancel (); + + return; + } + } + } + } + catch + { + // Buffer access may race; ignore exceptions in checker + } + }) { IsBackground = true }; + + thread1.Start (); + thread2.Start (); + checker.Start (); + + thread1.Join (TimeSpan.FromSeconds (10)); + thread2.Join (TimeSpan.FromSeconds (10)); + cts.Cancel (); + checker.Join (TimeSpan.FromSeconds (5)); + + // The test proves the bug exists: wrong position IS detected due to the race. + // After the fix, this should no longer detect wrong positions. + Assert.False (wrongPositionDetected, + "FillRect(Rectangle, char) wrote characters to wrong positions due to non-atomic Move+AddRune."); + } + + /// + /// Concurrently calls and + /// from separate threads. + /// The FillRect(Rectangle, char) overload lacks locking, so the non-atomic + /// Move + AddRune pair can be interleaved with buffer resizing, + /// causing IndexOutOfRangeException or NullReferenceException. + /// + [Fact] + public void FillRect_Char_And_SetSize_Concurrent_DoesNotThrow () + { + // Copilot + ConcurrentBag exceptions = RunConcurrent ((buffer, i, _) => + { + // Use the char overload which lacks a lock + buffer.FillRect (new Rectangle (0, 0, 30, 8), 'C'); + }, + (buffer, i, _) => + { + buffer.SetSize (i % 2 == 0 ? 80 : 40, i % 2 == 0 ? 25 : 10); + }); + + AssertNoExceptions (exceptions); + } + + private static void AssertNoExceptions (ConcurrentBag exceptions) => + Assert.True (exceptions.IsEmpty, + $"Caught {exceptions.Count} exception(s) during concurrent access. " + + $"First: {exceptions.FirstOrDefault ()?.GetType ().Name}: {exceptions.FirstOrDefault ()?.Message}"); + + /// + /// Runs a writer action and a clearer action concurrently, collecting any exceptions. + /// Returns the collected exceptions for assertion. + /// + private static ConcurrentBag RunConcurrent (Action writerAction, + Action clearerAction) + { + OutputBufferImpl buffer = new (); + buffer.SetSize (80, 25); + + ConcurrentBag exceptions = []; + CancellationTokenSource cts = new (); + + Thread writer = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + writerAction (buffer, i, cts.Token); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + Thread clearer = new (() => + { + try + { + for (var i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) + { + try + { + clearerAction (buffer, i, cts.Token); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + exceptions.Add (ex); + + break; + } + } + } + catch (Exception ex) + { + exceptions.Add (ex); + } + }) { IsBackground = true }; + + writer.Start (); + clearer.Start (); + + try + { + bool writerCompleted = writer.Join (TimeSpan.FromSeconds (10)); + bool clearerCompleted = clearer.Join (TimeSpan.FromSeconds (10)); + + if (!writerCompleted || !clearerCompleted) + { + cts.Cancel (); + + if (!writerCompleted && !writer.Join (TimeSpan.FromSeconds (5))) + { + exceptions.Add (new TimeoutException ("Writer thread did not stop after cancellation.")); + } + + if (!clearerCompleted && !clearer.Join (TimeSpan.FromSeconds (5))) + { + exceptions.Add (new TimeoutException ("Clearer thread did not stop after cancellation.")); + } + } + } + finally + { + cts.Cancel (); + cts.Dispose (); + } + + return exceptions; + } +}