diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml index 5fee72ef2b..88b36db335 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 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 b329997c77..4880e9c406 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,9 @@ - + + + @@ -13,21 +15,22 @@ - - - - - + + + + + + - + - + - - + + @@ -37,7 +40,7 @@ - + @@ -49,7 +52,7 @@ - + diff --git a/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj b/Terminal.Gui.Analyzers/Terminal.Gui.Analyzers.csproj new file mode 100644 index 0000000000..d9191013ab --- /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;RS1038 + + + + + + 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..a6da94f578 --- /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(new[] { + 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..c5563b4d05 --- /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(new[] { + 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/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs index 8b90d9d7c2..4ce165d8a8 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 { IsOutputRetryDue: true } retryDriver) + { + retryDriver.Refresh (); + } + if (neededLayout || needsDraw) { LayoutAndDrawComplete?.Invoke (this, EventArgs.Empty); 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); diff --git a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs index e5ad8b6521..04836bd92b 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs @@ -45,6 +45,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..96c693d005 100644 --- a/Terminal.Gui/App/Timeout/ITimedEvents.cs +++ b/Terminal.Gui/App/Timeout/ITimedEvents.cs @@ -15,47 +15,96 @@ 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); /// + /// + /// 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. + /// + /// + /// or its is . + /// 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. + /// + /// + /// 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; /// - /// 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. 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 (); /// - /// 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; } /// - /// 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 9443c4ba59..9dd48b2491 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,17 @@ namespace Terminal.Gui.App; /// /// /// +/// , , , +/// , , , and are +/// 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 +/// or cancels timeouts. +/// +/// /// By default, uses for high-resolution timing to provide microsecond-level /// precision and eliminate race conditions from timer resolution issues. /// @@ -40,8 +50,16 @@ 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 Dictionary _queuedTimeoutOccurrenceIds = []; + private readonly object _runTimersLockToken = new (); private readonly object _timeoutsLockToken = new (); private readonly ITimeProvider? _timeProvider; + private long _nextTimeoutOccurrenceId; + private long _stopAllEpoch; /// /// Initializes a new instance of with the default system time provider. @@ -68,7 +86,20 @@ 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. /// - public SortedList Timeouts => _timeouts; + /// + /// 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 + { + get + { + lock (_timeoutsLockToken) + { + return new (_timeouts); + } + } + } /// public event EventHandler? Added; @@ -102,31 +133,65 @@ private long GetTimestampTicks () /// public void RunTimers () { - lock (_timeoutsLockToken) + // 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)) { - if (_timeouts.Count > 0) - { - RunTimersImpl (); - } + return; + } + + try + { + RunTimersImpl (); + } + finally + { + Monitor.Exit (_runTimersLockToken); } } /// 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)!); + var found = false; - if (idx == -1) + // 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--) { - return false; + if (!ReferenceEquals (_timeouts.Values [i], timeout)) + { + continue; + } + + long key = _timeouts.Keys [i]; + bool occurrenceRemoved = _queuedTimeoutOccurrenceIds.Remove (key); + Debug.Assert (occurrenceRemoved); + _timeouts.RemoveAt (i); + found = true; } - _timeouts.RemoveAt (idx); - } + if (_activeTimeoutStates.TryGetValue (timeout, out ActiveTimeoutState state) + && state.StopAllEpoch == _stopAllEpoch + && state.UncancelledActiveCount > 0) + { + state.RemovalGeneration++; + state.UncancelledActiveCount = 0; + _activeTimeoutStates [timeout] = state; + found = true; + } - return true; + return found; + } } /// @@ -143,12 +208,26 @@ 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; } - /// + /// + /// 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 (); @@ -183,36 +262,64 @@ 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) + var found = false; + + lock (_timeoutsLockToken) + { + foreach (Timeout queuedTimeout in _timeouts.Values) { - return null; - } + if (!ReferenceEquals (queuedTimeout, timeout)) + { + continue; + } + + found = true; - return _timeouts.Values [idx].Span; + break; + } } + + return found ? timeout.Span : null; } private void AddTimeout (TimeSpan time, Timeout timeout) { + long timestampTicks = GetTimestampTicks (); + long k; + lock (_timeoutsLockToken) { - long k = GetTimestampTicks () + time.Ticks; + k = AddTimeoutCore (timestampTicks, 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)); + } - _timeouts.Add (NudgeToUniqueKey (k), timeout); - Added?.Invoke (this, new (timeout, k)); + private long AddTimeoutCore (long timestampTicks, TimeSpan time, Timeout timeout) + { + // Caller must hold _timeoutsLockToken. + Debug.Assert (Monitor.IsEntered (_timeoutsLockToken)); + + 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) + { + // 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; } + + k = NudgeToUniqueKey (k); + _timeouts.Add (k, timeout); + _queuedTimeoutOccurrenceIds.Add (k, _nextTimeoutOccurrenceId++); + + return k; } /// @@ -223,12 +330,12 @@ private void AddTimeout (TimeSpan time, Timeout timeout) /// private long NudgeToUniqueKey (long k) { - lock (_timeoutsLockToken) + // Caller must hold _timeoutsLockToken. + Debug.Assert (Monitor.IsEntered (_timeoutsLockToken)); + + while (_timeouts.ContainsKey (k)) { - while (_timeouts.ContainsKey (k)) - { - k++; - } + k++; } return k; @@ -236,51 +343,184 @@ private long NudgeToUniqueKey (long k) private void RunTimersImpl () { - long now = GetTimestampTicks (); + long occurrenceIdCutoff; + + lock (_timeoutsLockToken) + { + if (_timeouts.Count == 0) + { + return; + } + + occurrenceIdCutoff = _nextTimeoutOccurrenceId; + } - // Process due timeouts one at a time, without blocking the entire queue + 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. while (true) { - Timeout? timeoutToExecute = null; - long scheduledTime = 0; + ActiveTimeoutOccurrence occurrence; - // Find the next due timeout lock (_timeoutsLockToken) { - if (_timeouts.Count == 0) + int timeoutIndex = GetNextDueTimeoutIndex (passStart, occurrenceIdCutoff); + + if (timeoutIndex == -1) { - break; // No more timeouts + return; } - // Re-evaluate current time for each iteration - now = GetTimestampTicks (); - - // Check if the earliest timeout is due - scheduledTime = _timeouts.Keys [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 (scheduledTime > now) + if (state.StopAllEpoch != _stopAllEpoch) { - // Earliest timeout is not yet due, we're done - break; + state.StopAllEpoch = _stopAllEpoch; + state.UncancelledActiveCount = 0; } - // This timeout is due - remove it from the queue - timeoutToExecute = _timeouts.Values [0]; - _timeouts.RemoveAt (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 - if (timeoutToExecute != null) + // This allows nested RunTimers() calls to access the timeout queue + bool repeat = false; + + try + { + repeat = occurrence.Timeout.Callback! (); + } + finally { - bool repeat = timeoutToExecute.Callback! (); + CompleteTimeout (occurrence, repeat); + } + } + } - if (repeat) - { - AddTimeout (timeoutToExecute.Span, timeoutToExecute); - } + 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) + { + 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; + + lock (_timeoutsLockToken) + { + 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 || !canReschedule) + { + return; + } + + k = AddTimeoutCore (timestampTicks, repeatInterval, occurrence.Timeout); + } + + Added?.Invoke (this, new (occurrence.Timeout, k)); } /// @@ -289,6 +529,38 @@ public void StopAll () lock (_timeoutsLockToken) { _timeouts.Clear (); + _queuedTimeoutOccurrenceIds.Clear (); + _stopAllEpoch++; } } + + /// + /// 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/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/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 5dce76a584..347840fef8 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; @@ -75,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) { @@ -96,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 ()) @@ -113,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; } @@ -168,10 +157,25 @@ 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, 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); + } + /// public void Suspend () => UnixTerminalHelper.Suspend (this); @@ -212,76 +216,190 @@ public Size GetSize () /// protected override void Write (StringBuilder output) { - base.Write (output); + if (_platform == AnsiPlatform.Degraded && !_batchMode) + { + base.Write (output); + return; + } - try + WriteUtf8 (Encoding.UTF8.GetBytes (output.ToString ())); + } + + /// + private protected override void WriteEncodedString (string output) + { + if (HasCustomStringWriter (typeof (AnsiOutput))) { - switch (_platform) - { - case AnsiPlatform.WindowsVT: - _windowsVTOutput!.Write (output); + WriteLegacyString (new StringBuilder (output)); + return; + } - break; + Write (output.AsSpan ()); + } - case AnsiPlatform.UnixRaw: - byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ()); - UnixIOHelper.TryWriteStdout (utf8); + /// + public void Write (ReadOnlySpan text) + { + if (_platform == AnsiPlatform.Degraded && !_batchMode) + { + CaptureText (text); + return; + } - return; + // 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); - case AnsiPlatform.Degraded: - default: - break; + 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..]; + } + } + + /// + public override void Write (IOutputBuffer buffer) + { + _lastBuffer = buffer; + // An out-of-band legacy attribute hook must take effect between physical writes. + _batchMode = !UsesLegacyAttributeWriter && !HasCustomStringWriter (typeof (AnsiOutput)); + _frameBuffer.Clear (); + + try + { + base.Write (buffer); } - catch (Exception) + finally { - // ignore for unit tests + _batchMode = false; + _pendingCursorMoves.Clear (); + _pendingCursorMoves.TrimExcess (); + _frameBuffer.Clear (); + _frameBuffer.TrimExcess (); } } - /// - public void Write (ReadOnlySpan text) + internal override void FlushFrame () { - StringBuilder capturedOutput = new (); - capturedOutput.Append (text); - base.Write (capturedOutput); + 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))) + { + base.Write (output); + return; + } + + WriteUtf8 (output); + } + + private void WriteUtf8 (ReadOnlySpan output) + { try { - switch (_platform) + if (_batchMode) { - case AnsiPlatform.WindowsVT: - _windowsVTOutput!.Write (capturedOutput); + if (_frameBuffer.Length + (long)_pendingCursorMoves.Length > Utf8Buffer.RetainedCapacityLimit) + { + WriteDirect (_frameBuffer.AsSpan ()); + _frameBuffer.Clear (); + } - break; + if (_pendingCursorMoves.Length > 0) + { + _frameBuffer.Append (_pendingCursorMoves); + _pendingCursorMoves.Clear (); + } + + // 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 (); + } - case AnsiPlatform.UnixRaw: - byte [] utf8 = Encoding.UTF8.GetBytes (text.ToArray ()); - UnixIOHelper.TryWriteStdout (utf8); + // Large images bypass staging after all preceding content. + if (output.Length > Utf8Buffer.RetainedCapacityLimit) + { + if (_frameBuffer.Length > 0) + { + WriteDirect (_frameBuffer.AsSpan ()); + _frameBuffer.Clear (); + } + WriteDirect (output); return; + } - case AnsiPlatform.Degraded: - default: - break; + _frameBuffer.AppendBytes (output); + return; } + + WriteDirect (output); } - catch (Exception) + catch (IOException) { - // ignore for unit tests + // DriverImpl owns failure logging and retry; only drop moves that no longer apply. + _pendingCursorMoves.Clear (); + + throw; } } - /// - public override void Write (IOutputBuffer buffer) + private void WriteDirect (ReadOnlySpan output) { - _lastBuffer = buffer; - base.Write (buffer); + switch (_platform) + { + case AnsiPlatform.WindowsVT: + _windowsVTOutput!.Write (output); + break; + + case AnsiPlatform.UnixRaw: + if (!(_unixWriter?.Invoke (output) ?? UnixIOHelper.TryWriteStdout (output))) + { + throw new IOException ("Failed to write ANSI output to the terminal."); + } + break; + } + + 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 readonly Utf8Buffer _encodedText = new (); + private bool _batchMode; + + internal int RetainedWriteBufferBytes => _pendingCursorMoves.Capacity + _frameBuffer.Capacity + _encodedText.Capacity; + /// public Cursor GetCursor () => _currentCursor; @@ -303,27 +421,19 @@ 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) + { + // Best effort: a broken sink also fails the next frame, which DriverImpl logs once. + Logging.Debug ($"Error updating ANSI cursor: {ex.Message}"); + } } /// 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; @@ -334,7 +444,18 @@ 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.Clear (); + _pendingCursorMoves.AppendAscii (EscSeqUtils.CSI_SetCursorPosition (row + 1 + inlineRowOffset, col + 1)); + } + else + { + Write (EscSeqUtils.CSI_SetCursorPosition (row + 1 + inlineRowOffset, col + 1)); + } return true; } @@ -378,44 +499,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) { @@ -424,5 +534,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/AnsiHandling/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs index 3014733ba3..d51f87c9bd 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs @@ -816,6 +816,35 @@ internal static void CSI_AppendTextStyleChange (StringBuilder output, TextStyle return; } + List sgr = ComputeTextStyleSgrCodes (prev, next); + + output.Append ("\x1b["); + output.Append (string.Join (';', sgr)); + 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; + } + + 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. @@ -915,9 +944,7 @@ internal static void CSI_AppendTextStyleChange (StringBuilder output, TextStyle } } - output.Append ("\x1b["); - output.Append (string.Join (';', sgr)); - output.Append ('m'); + return sgr; } #endregion Text Styles 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 cb7c41882c..4878b0c951 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. @@ -80,34 +81,82 @@ public void Write (ReadOnlySpan text) return; } - try + Console.Out.Write (text); + } + + /// + protected override void Write (StringBuilder output) + { + base.Write (output); + + if (!IsAttachedToTerminal) { - Console.Out.Write (text); + return; } - catch (IOException) + + Console.Out.Write (output); + } + + /// + private protected override void WriteEncodedString (string output) + { + if (HasCustomStringWriter (typeof (NetOutput))) + { + WriteLegacyString (new StringBuilder (output)); + return; + } + + CaptureText (output.AsSpan ()); + + if (!IsAttachedToTerminal) { - // Not connected to a terminal; do nothing + return; } + + Console.Out.Write (output.AsSpan ()); } /// - protected override void Write (StringBuilder output) + protected override void Write (ReadOnlySpan output) { - base.Write (output); + if (HasCustomStringWriter (typeof (NetOutput))) + { + base.Write (output); + return; + } - if (!IsAttachedToTerminal) + if (output.IsEmpty) { return; } - try + int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + char [] decoded; + + if (maxCharCount > Utf8Buffer.RetainedCapacityLimit) { - Console.Out.Write (output); + decoded = new char [maxCharCount]; } - catch (IOException) + 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) { - // Not connected to a terminal; do nothing + return; } + + Console.Out.Write (text); } private Cursor _currentCursor = new (); @@ -133,27 +182,20 @@ 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) + { + // Best effort: a broken sink also fails the next frame, which DriverImpl logs once. + Logging.Debug ($"Error updating .NET console cursor: {ex.Message}"); + } } /// 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/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 09cf7a1d78..70f9818591 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,18 +93,76 @@ public DriverImpl (IComponentFactory componentFactory, /// public void Refresh () { - // Hide cursor during rendering to prevent flicker - Cursor cursor = _output.GetCursor (); + // 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; + } - if (cursor.IsVisible) + try { - Cursor hiddenCursor = cursor with { Position = null, Style = cursor.Style }; - _output.SetCursor (hiddenCursor); - SetCursorNeedsUpdate (true); + // 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); + } + + _output.Write (_outputBuffer); + 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. + RecordOutputFailure ("refreshing terminal output", ex); } - _output.Write (_outputBuffer); - // Cursor visibility restored by ApplicationMainLoop to reduce flicker + // Cursor visibility restored by ApplicationMainLoop to reduce flicker. + } + + 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; } /// @@ -449,7 +508,32 @@ public Attribute SetAttribute (Attribute newAttribute) public Attribute GetAttribute () => _outputBuffer.CurrentAttribute; /// - public void WriteRaw (string ansi) => _output.Write (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) + { + // 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); + } + + RecordOutputFailure ("writing raw terminal output", ex); + + return false; + } + } /// public void SetTerminalTitle (string title, int mode = 0) 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 0d6931f5a5..e51a8768a5 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -10,7 +10,28 @@ 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; + // 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; + _stringWriterType = stringWriter.Method.DeclaringType; + } + + private readonly bool _usesLegacyAttributeWriter; + private StringBuilder? _legacyAttributeText; + private readonly Type? _stringWriterType; + + /// 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. + private protected bool UsesLegacyAttributeWriter => _usesLegacyAttributeWriter; /// /// Gets whether this output instance is attached to a real terminal device. @@ -76,6 +97,33 @@ public bool IsLegacyConsole private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; + 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 + { + 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 @@ -113,10 +161,92 @@ 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; - StringBuilder outputStringBuilder = new (); + _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 (); + InvalidatePhysicalState (buffer); + 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 = []; + + // 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); + + 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 (_rowBuffer); + Utf8Buffer outputBuffer = lease.Buffer; var top = 0; var left = 0; int rows = buffer.Rows; @@ -132,7 +262,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. @@ -157,20 +287,20 @@ 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; @@ -188,13 +318,13 @@ public virtual void Write (IOutputBuffer buffer) { if (skipRasterBlank) { - buffer.Contents [row, col].IsDirty = false; + dirtyState.ClearCell (row, col); } if (outputWidth > 0) { - // This clears outputStringBuilder - WriteToConsole (outputStringBuilder, ref lastCol, ref outputWidth); + // This clears outputBuffer + WriteToConsole (outputBuffer, ref lastCol, ref outputWidth); } continue; @@ -222,13 +352,13 @@ public virtual void Write (IOutputBuffer buffer) // If we were in a hyperlink, end it if (_lastUrl is { }) { - outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + outputBuffer.Append (EscSeqUtils.OSC_EndHyperlink ()); } // If starting a new hyperlink, begin it if (!string.IsNullOrEmpty (cellUrl)) { - outputStringBuilder.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + outputBuffer.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); } _lastUrl = cellUrl; @@ -238,20 +368,20 @@ 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; - AppendCellAnsi (cell, outputStringBuilder, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); + 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); } } // 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 row scan (leaving outputBuffer empty at this point). if (!IsLegacyConsole) { if (rowHasUrlsNow) @@ -266,22 +396,22 @@ 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 // 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 +419,11 @@ public virtual void Write (IOutputBuffer buffer) if (_lastUrl is { }) { - outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + outputBuffer.AppendAscii (EscSeqUtils.OSC_EndHyperlink ()); _lastUrl = null; } - Write (outputStringBuilder); + Write (outputBuffer.AsSpan ()); } if (IsLegacyConsole) @@ -312,11 +442,23 @@ public virtual void Write (IOutputBuffer buffer) } SetCursorPositionImpl (s.ScreenPosition.X, s.ScreenPosition.Y); - Write (new StringBuilder (s.SixelData)); + WriteEncodedString (s.SixelData); s.IsDirty = false; } } + // Writes are serialized by the main loop; a finally scope also releases oversized row buffers. + private readonly struct Utf8BufferLease (Utf8Buffer buffer) : IDisposable + { + public Utf8Buffer Buffer { get; } = buffer; + + public void Dispose () + { + Buffer.Clear (); + Buffer.TrimExcess (); + } + } + /// public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString (); @@ -331,6 +473,10 @@ public virtual void Write (IOutputBuffer buffer) /// 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. + /// /// /// /// @@ -366,6 +512,83 @@ protected virtual void AppendOrWriteAttribute (StringBuilder output, Attribute a 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) + { + // 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. + if (attributeText.Length > 0) + { + output.Append (attributeText.ToString ()); + } + + return; + } + + if (attr.Foreground == Color.None) + { + output.AppendAscii (EscSeqUtils.CSI); + output.AppendAscii ("39m"); + } + else if (Force16Colors) + { + output.AppendAscii (EscSeqUtils.CSI_SetForegroundColor (attr.Foreground.GetAnsiColorCode ())); + } + else + { + 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); + output.AppendAscii ("49m"); + } + else if (Force16Colors) + { + output.AppendAscii (EscSeqUtils.CSI_SetBackgroundColor (attr.Background.GetAnsiColorCode ())); + } + else + { + 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); + + if (styleChange.Length > 0) + { + output.AppendAscii (styleChange); + } + } + /// /// When overriden in derived class, positions the terminal draw cursor to the specified point on the screen. /// Note, this does NOT update any internal cursor position state - that is the responsibility of the caller. @@ -381,6 +604,89 @@ protected virtual void AppendOrWriteAttribute (StringBuilder output, Attribute a /// protected virtual void Write (StringBuilder output) { + if (!CaptureOutput) + { + return; + } + + if (_clearLastOutputPending) + { + _lastOutputStringBuilder.Clear (); + _clearLastOutputPending = false; + } + + _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) + { + // 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. + /// + 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. + private protected void CaptureUtf8 (ReadOnlySpan output) + { + if (!CaptureOutput) + { + return; + } + + CaptureText (Encoding.UTF8.GetString (output)); + } + + /// Captures decoded output when output capture is enabled. + private protected void CaptureText (ReadOnlySpan output) + { + if (!CaptureOutput) + { + return; + } + if (_clearLastOutputPending) { _lastOutputStringBuilder.Clear (); @@ -464,6 +770,7 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, output.Append ('\n'); } } + } /// @@ -486,7 +793,6 @@ protected void AppendCellAnsi (Cell cell, { Attribute? attribute = cell.Attribute; - // Add ANSI escape sequence for attribute change if (attribute.HasValue && attribute.Value != lastAttr) { lastAttr = attribute.Value; @@ -494,7 +800,38 @@ protected void AppendCellAnsi (Cell cell, redrawTextStyle = attribute.Value.Style; } - // Add the grapheme + 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, + int maxCol, + ref int currentCol, + ref int outputWidth) + { + Attribute? attribute = cell.Attribute; + + // Add ANSI escape sequence for attribute change + if (attribute.HasValue && attribute.Value != lastAttr) + { + lastAttr = attribute.Value; + AppendOrWriteAttributeUtf8 (output, attribute.Value, redrawTextStyle); + redrawTextStyle = attribute.Value.Style; + } + + // Add the grapheme (Utf8Buffer.Append auto-detects ASCII vs Unicode) string grapheme = cell.Grapheme; output.Append (grapheme); outputWidth++; @@ -544,9 +881,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 (); @@ -650,9 +987,10 @@ private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer lastUrl = null; } } + } - private void ClearKittyRasterBlankCells (IOutputBuffer buffer) + private void ClearKittyRasterBlankCells (IOutputBuffer buffer, ref OutputDirtyState dirtyState) { if (buffer.Contents is null) { @@ -684,7 +1022,7 @@ private void ClearKittyRasterBlankCells (IOutputBuffer buffer) } WriteTransparentBlankCell (); - buffer.Contents [row, col].IsDirty = false; + dirtyState.ClearCell (row, col); } } } @@ -802,7 +1140,7 @@ private void EmitVanishedKittyDeletes (IOutputBuffer buffer) if (_kittyCurrentImageId.TryGetValue (id, out int residentImageId)) { - Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacements (residentImageId))); + DeleteKittyImage (residentImageId); } vanishedSourceCrop.Add (id); @@ -821,10 +1159,16 @@ private void DeleteKittyPlacements (List imageIds) { foreach (int imageId in imageIds) { - Write (new StringBuilder (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. @@ -888,7 +1232,12 @@ 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))); + if (trackKitty) + { + _kittyImagesTouchedThisFrame.Add (imageId); + } + + WriteEncodedString (GetRasterImageData (command, visibleCells, pixels, imageId)); if (trackKitty) { @@ -935,7 +1284,8 @@ 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))); + _kittyImagesTouchedThisFrame.Add (imageId); + WriteEncodedString (new KittyGraphicsEncoder ().EncodeTransmit (command.Pixels!, imageId)); _kittyTransmittedImage [command.Id!] = command.Pixels!; _kittyTransmittedVersion [command.Id!] = command.SourceVersion; _kittyCurrentImageId [command.Id!] = imageId; @@ -950,14 +1300,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++; } @@ -966,7 +1316,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))); + DeleteKittyImage (previousImageId); } // Fewer fragments than last frame (e.g. a clip hole closed): delete the now-unused placements so @@ -977,7 +1327,8 @@ private void RenderKittySourceCrop (RasterImageCommand command, Rectangle source { if (!placementIds.Contains (pid)) { - Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacement (imageId, pid))); + _kittyImagesTouchedThisFrame.Add (imageId); + WriteEncodedString (KittyGraphicsEncoder.EncodeDeletePlacement (imageId, pid)); } } } @@ -1216,9 +1567,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; @@ -1240,7 +1591,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 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/Output/Utf8Buffer.cs b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs new file mode 100644 index 0000000000..fc1cbcf8bf --- /dev/null +++ b/Terminal.Gui/Drivers/Output/Utf8Buffer.cs @@ -0,0 +1,224 @@ +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. +/// Reuses its backing array at stable sizes; other rendering work can still allocate. +/// +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 ASCII as raw bytes. Non-ASCII input falls back to UTF-8 encoding + /// instead of truncating characters. + /// + public void AppendAscii (string text) + { + int len = text.Length; + if (len == 0) + { + return; + } + + if (len <= 32) + { + EnsureCapacity (len); + + for (int index = 0; index < len; index++) + { + char current = text [index]; + + if (current >= 0x80) + { + Append (text.AsSpan ()); + return; + } + + _buffer [_length + index] = (byte)current; + } + + _length += len; + return; + } + + if (!Ascii.IsValid (text)) + { + Append (text.AsSpan ()); + return; + } + + AppendKnownAscii (text); + } + + /// + /// Appends a string, auto-detecting ASCII vs Unicode. + /// ASCII strings use a direct byte path; Unicode strings are UTF-8 encoded. + /// + public void Append (string text) + { + int len = text.Length; + if (len == 0) + { + return; + } + + if (len == 1 && text [0] < 0x80) + { + AppendByte ((byte)text [0]); + return; + } + + AppendAscii (text); + } + + /// + /// 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 an integer as ASCII digits directly (no string allocation). + /// + public void AppendInt (int value) + { + uint magnitude = value < 0 ? (uint)-(long)value : (uint)value; + uint remaining = magnitude; + int digits = 1; + + while (remaining >= 10) + { + remaining /= 10; + digits++; + } + + int width = digits + (value < 0 ? 1 : 0); + EnsureCapacity (width); + int position = _length + width; + + do + { + _buffer [--position] = (byte)('0' + magnitude % 10); + magnitude /= 10; + } while (magnitude > 0); + + if (value < 0) + { + _buffer [--position] = (byte)'-'; + } + + _length += width; + } + + /// + /// 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) + { + long required = (long)_length + additional; + + if (required <= _buffer.Length) + { + return; + } + + 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; + } + + if (newSize > Array.MaxLength) + { + newSize = Array.MaxLength; + } + + 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 0d5bc69900..a821f7a03a 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,39 @@ public static bool TryWriteStdout (byte [] buffer) return false; } - return TryWriteAll (fd, buffer, write); + return TryWriteAll (fd, buffer, WriteSpan); + } + catch + { + return false; + } + } + + /// + /// 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; + } + + // 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.AsSpan (0, count), WriteSpan); } catch { @@ -313,29 +348,69 @@ public static bool TryWriteStdout (byte [] buffer) } } - internal static bool TryWriteAll (int fd, byte [] buffer, 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, Func? getError = null) { int offset = 0; - int remaining = buffer.Length; + int transientFailures = 0; + int wouldBlock = OperatingSystem.IsMacOS () || OperatingSystem.IsFreeBSD () ? 35 : 11; - 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 == -1) { + int error = getError?.Invoke () ?? Marshal.GetLastPInvokeError (); + + // 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) + { + Thread.Sleep (1); + } + + continue; + } + return false; } - offset += written; - remaining -= written; + if (written <= 0 || written > remaining.Length) + { + return false; + } + + offset += (int)written; + transientFailures = 0; } 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 4cc3f3120c..bbcf454df4 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -14,6 +14,49 @@ 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) + { + if (error == 0) + { + throw new IOException ("WriteConsoleW failed without an error code."); + } + + throw new Win32Exception (error); + } + + if (written == 0 || written > expected) + { + throw new IOException ($"WriteConsoleW wrote {written} of {expected} characters."); + } + } + + 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) + { + 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)] private static partial nint GetStdHandle (int nStdHandle); @@ -90,6 +133,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)}"); @@ -201,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. } } @@ -256,20 +299,12 @@ private void CreateScreenBuffer () public void Write (ReadOnlySpan str) { - if (!IsAttachedToTerminal) + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows () || str.IsEmpty) { return; } - if (!OperatingSystem.IsWindows ()) - { - 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) @@ -337,146 +372,95 @@ 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) + if (output.IsEmpty) { return; } - base.Write (output); + // Decode once for both the optional capture and WriteConsoleW. + int maxCharCount = Encoding.UTF8.GetMaxCharCount (output.Length); + char [] decoded; - if (!IsAttachedToTerminal) + if (maxCharCount > Utf8Buffer.RetainedCapacityLimit) { - return; - } - - var str = output.ToString (); - - if (Force16Colors && IsLegacyConsole) - { - char [] a = str.ToCharArray (); - WriteConsole (_screenBuffer, a, (uint)a.Length, out _, nint.Zero); + decoded = new char [maxCharCount]; } else { - try + if (_utf16DecodeBuffer is null || _utf16DecodeBuffer.Length < maxCharCount) { - ReadOnlySpan span = str.AsSpan (); // still allocates the string + _utf16DecodeBuffer = new char [maxCharCount]; + } - bool result = WriteConsole (_outputHandle, span, (uint)span.Length, out _, nint.Zero); + decoded = _utf16DecodeBuffer; + } - if (result) - { - return; - } - int err = Marshal.GetLastWin32Error (); + int charCount = Encoding.UTF8.GetChars (output, decoded); + ReadOnlySpan charSpan = decoded.AsSpan (0, charCount); + CaptureText (charSpan); - if (err == 1) - { - Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows ()) + { + return; + } - return; - } + nint handle = !IsLegacyConsole ? _outputHandle : _screenBuffer; + WriteConsoleChecked (handle, charSpan); + } - if (err != 0) - { - throw new Win32Exception (err); - } - } - catch (DllNotFoundException) - { - // Running unit tests or in an environment where writing is not possible. - } - catch (Exception e) - { - Logging.Error ($"Error: {e.Message} in {nameof (WindowsOutput)}"); + /// + private protected override void WriteEncodedString (string output) + { + CaptureText (output.AsSpan ()); - if (OperatingSystem.IsWindows ()) - { - throw; - } - } + if (!IsAttachedToTerminal || !OperatingSystem.IsWindows () || output.Length == 0) + { + return; } + + nint handle = !IsLegacyConsole ? _outputHandle : _screenBuffer; + WriteConsoleChecked (handle, output.AsSpan ()); } /// - protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle) + internal override void AppendOrWriteAttributeUtf8 (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); + if (IsAttachedToTerminal && OperatingSystem.IsWindows ()) + { + SetConsoleTextAttribute (_screenBuffer, as16ColorInt); + } } else { - base.AppendOrWriteAttribute (output, attr, redrawTextStyle); + base.AppendOrWriteAttributeUtf8 (output, attr, redrawTextStyle); } } @@ -597,8 +581,6 @@ public void SetSize (int width, int height) } private bool _isDisposed; - private nint _consoleBuffer; - private readonly StringBuilder _everythingStringBuilder = new (); /// public void Dispose () @@ -615,35 +597,46 @@ public void Dispose () return; } - if (IsLegacyConsole) + try { - if (_screenBuffer != nint.Zero) + if (IsLegacyConsole) + { + if (_screenBuffer != nint.Zero) + { + CloseHandle (_screenBuffer); + } + + _screenBuffer = nint.Zero; + return; + } + + if (Environment.GetEnvironmentVariable ("VSAPPIDNAME") is null) { - CloseHandle (_screenBuffer); + Restore (() => Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll)); + Restore (() => Write (EscSeqUtils.CSI_ShowCursor)); + return; } - _screenBuffer = nint.Zero; + Restore (() => Console.ForegroundColor = _foreground); + Restore (() => Console.BackgroundColor = _background); + Restore (Console.Clear); } - else + finally { - if (Environment.GetEnvironmentVariable ("VSAPPIDNAME") is null) - { - //Disable alternative screen buffer. - Console.Out.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + _isDisposed = true; + } - //Set cursor key to cursor. - Write (EscSeqUtils.CSI_ShowCursor); + 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 0717a976f8..b14e1cf438 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,6 +48,11 @@ internal sealed class WindowsVTOutputHelper : IDisposable private uint _originalConsoleMode; private bool _disposed; + 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. /// @@ -167,12 +178,44 @@ public void Dispose () _disposed = true; } - public void Write (StringBuilder output) + public void Write (StringBuilder output) => Write (Encoding.UTF8.GetBytes (output.ToString ())); + + /// Writes UTF-8 directly, retrying short writes without copying their tail. + public void Write (ReadOnlySpan utf8) { - // Convert StringBuilder to string and then to byte array - byte [] byteArray = Encoding.UTF8.GetBytes (output.ToString ()); + while (!utf8.IsEmpty) + { + uint written; + int error; + bool succeeded; + + 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 (); + } - WriteFile (OutputHandle, byteArray, (uint)byteArray.Length, out _, nint.Zero); + if (!succeeded) + { + if (error == 0) + { + throw new IOException ("WriteFile failed without an error code."); + } + + throw new Win32Exception (error); + } + + if (written == 0 || written > utf8.Length) + { + throw new IOException ($"WriteFile reported {written} of {utf8.Length} bytes."); + } + + utf8 = utf8 [(int)written..]; + } } /// diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index a22f1a93f6..96c9c2241e 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -70,8 +70,14 @@ + + + + + + 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/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 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/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) { 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/Terminal.slnx b/Terminal.slnx index 6a1b34315d..f19e783dd9 100644 --- a/Terminal.slnx +++ b/Terminal.slnx @@ -101,9 +101,12 @@ + + + diff --git a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs index 3665f9ab3b..5988a68013 100644 --- a/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/OutputWriteBenchmark.cs @@ -38,16 +38,21 @@ 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); } /// /// Marks every cell dirty before measuring a full-frame flush. /// - [IterationSetup (Target = nameof (FullFrame))] public void PrepareFullFrame () { MarkFullFrame (_fullBuffer); @@ -57,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); @@ -70,9 +74,15 @@ public void PrepareSparseBorderFrame () [Benchmark (Baseline = true)] public long FullFrame () { + PrepareFullFrame (); _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; } /// @@ -81,9 +91,15 @@ public long FullFrame () [Benchmark] public long SparseBorderFrame () { + PrepareSparseBorderFrame (); _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 +214,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 +224,8 @@ private sealed class MeasuringOutput : OutputBase public void ResetCounters () { - CharactersWritten = 0; + BytesWritten = 0; + PayloadBytesWritten = 0; CursorMoves = 0; Writes = 0; } @@ -218,7 +237,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 +245,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..06d7071a9a --- /dev/null +++ b/Tests/Benchmarks/ConsoleDrivers/OutputBuffer/UnixRawOutputBenchmark.cs @@ -0,0 +1,142 @@ +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] +[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; + + /// Builds and warms the small (80×25), large (240×70), and per-cell colored (400×110) frames. + [GlobalSetup] + 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++; + _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); + _output.Write (_colored); + } + + /// Marks the 80×25 frame dirty before each measurement. + public void PrepareSmall () + { + MarkDirty (_small); + _writes = 0; + _bytes = 0; + } + + /// Marks the 240×70 frame dirty before each measurement. + public void PrepareLarge () + { + MarkDirty (_large); + _writes = 0; + _bytes = 0; + } + + /// Flushes a fully dirty 80×25 frame. + [Benchmark] + public long SmallFrame () + { + PrepareSmall (); + _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 () + { + PrepareLarge (); + _output.Write (_large); + + if (_writes != 1) + { + throw new InvalidOperationException ($"Expected one UnixRaw write, got {_writes}."); + } + + 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 (); + 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/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/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 () { diff --git a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs index 33142b0875..ec9a8c41bc 100644 --- a/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Timeouts/TimedEventsTests.cs @@ -7,6 +7,1365 @@ 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 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 () + { + 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 - 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 () + { + 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_Returns_Without_Running_Newly_Due_Timeout () + { + 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 (0, callbackCount); + Assert.Single (timedEvents.Timeouts); + + timedEvents.RunTimers (); + + Assert.Equal (1, callbackCount); + Assert.Empty (timedEvents.Timeouts); + } + + // CoPilot - GPT-5 + [Fact] + public void RunTimers_Callback_Exception_Propagates_And_Ends_Pass () + { + TimedEvents timedEvents = new (); + InvalidOperationException expectedException = new ("Expected callback failure."); + var followUpCallbackCount = 0; + + timedEvents.Add (TimeSpan.Zero, () => throw expectedException); + timedEvents.Add ( + TimeSpan.Zero, + () => + { + Interlocked.Increment (ref followUpCallbackCount); + + return false; + }); + + InvalidOperationException actualException = Assert.Throws (timedEvents.RunTimers); + + Assert.Same (expectedException, actualException); + Assert.Equal (0, followUpCallbackCount); + Assert.Single (timedEvents.Timeouts); + + timedEvents.RunTimers (); + + Assert.Equal (1, followUpCallbackCount); + 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 () + { + 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 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 () + { + 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); + } + + // 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 () { @@ -87,6 +1446,142 @@ 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; + var stopRepeating = 0; + object token = timedEvents.Add ( + TimeSpan.Zero, + () => + { + Interlocked.Increment (ref callbackCount); + + return Volatile.Read (ref stopRepeating) == 0; + }); + 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); + } + + 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); + Assert.Equal (1, queuedAfterPass); + Assert.Equal (2, countAfterNextPass); + } + + // CoPilot - GPT-5 + [Fact] + 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, + () => + { + Interlocked.Increment (ref repeatingCallbackCount); + + return Volatile.Read (ref stopRepeating) == 0; + }); + timedEvents.Add ( + TimeSpan.Zero, + () => + { + Interlocked.Increment (ref peerCallbackCount); + + return false; + }); + + 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] public void TimeSpan_Zero_Executes_Immediately () { @@ -166,4 +1661,83 @@ 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)); + + 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); } + } } 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); + } + } +} 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/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 diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs new file mode 100644 index 0000000000..8362d1a9e8 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputFailureHandlingTests.cs @@ -0,0 +1,642 @@ +using System.ComponentModel; +using System.Text; + +namespace DriverTests.Output; + +[Collection ("Driver Tests")] +public class OutputFailureHandlingTests +{ + // 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); + } + + // 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)] + [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)); + } + + // 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; + Assert.True (SpinWait.SpinUntil (() => driver.IsOutputRetryDue, TimeSpan.FromSeconds (5))); + 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 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 () + { + 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 () + { + 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 new file mode 100644 index 0000000000..896d842b77 --- /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 +{ + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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]); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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])); + } + + // CoPilot - GPT-6 (generated by Codex) + [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])); + } + + // CoPilot - GPT-6 (generated by Codex) + [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)); + } + + // CoPilot - GPT-6 (generated by Codex) + [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)); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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/OutputRetryAndCompatibilityTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputRetryAndCompatibilityTests.cs new file mode 100644 index 0000000000..ff14cefca2 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputRetryAndCompatibilityTests.cs @@ -0,0 +1,560 @@ +using System.ComponentModel; +using System.Text; + +namespace DriverTests.Output; + +/// Verifies frame retry after I/O failure and compatibility with legacy output overrides. +[Collection ("Driver Tests")] +public class OutputRetryAndCompatibilityTests +{ + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // 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 () + { + 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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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]); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [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)); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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]); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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])); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 ()); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void StreamingFrame_LateFailure_RetriesTextAndLargeSixel () + { + int attempts = 0; + int textWrites = 0; + int imageBytes = 0; + AnsiOutput output = new (bytes => + { + if (++attempts == 2) + { + return false; + } + + if (bytes.IndexOf ((byte)'X') >= 0) textWrites++; + foreach (byte value in bytes) + { + if (value == (byte)'q') imageBytes++; + } + 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); + int previouslyWrittenImageBytes = imageBytes; + Assert.True (previouslyWrittenImageBytes > 0); + output.Write (buffer); + + Assert.Equal (2, textWrites); + Assert.Equal (previouslyWrittenImageBytes + sixel.SixelData.Length, imageBytes); + 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 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, + 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}"); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs new file mode 100644 index 0000000000..0ab99841bc --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Output/Utf8BufferTests.cs @@ -0,0 +1,354 @@ +using System.Reflection; +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 ())); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void AppendAscii_NonAscii_FallsBackToUtf8 () + { + Utf8Buffer buffer = new (); + + buffer.AppendAscii ("é中"); + + Assert.Equal ("é中", 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)] + [InlineData (int.MinValue)] + [InlineData (-1)] + [InlineData (-1000)] + 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 ())); + } + + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 () + { + 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..572eda4af4 100644 --- a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs @@ -23,7 +23,7 @@ public void TryWriteAll_ReturnsFalse_WhenWriteReturnsZero () bool result = UnixIOHelper.TryWriteAll (1, payload, - (_, _, _) => + (_, _) => { calls++; @@ -43,9 +43,9 @@ public void TryWriteAll_RetriesUntilAllBytesWritten () bool result = UnixIOHelper.TryWriteAll (1, payload, - (_, buffer, count) => + (_, buffer) => { - counts.Add (count); + counts.Add (buffer.Length); firstBytes.Add (buffer [0]); return 1; @@ -56,6 +56,88 @@ public void TryWriteAll_RetriesUntilAllBytesWritten () Assert.Equal ([0x41, 0x42, 0x43], firstBytes); } + // CoPilot - GPT-6 (generated by Codex) + [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); + } + + // CoPilot - GPT-6 (generated by Codex) + [Fact] + public void TryWriteAll_RejectsWriterThatClaimsMoreThanRemaining () + { + byte [] payload = [0x41]; + + Assert.False (UnixIOHelper.TryWriteAll (1, payload, (_, _) => 2)); + } + + // CoPilot - GPT-6 (generated by Codex) + [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 () 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 () { 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 @@ + + 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 () { 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 () {