diff --git a/TUI.App/InteractiveDemo.cs b/TUI.App/InteractiveDemo.cs index a495838..98f2146 100644 --- a/TUI.App/InteractiveDemo.cs +++ b/TUI.App/InteractiveDemo.cs @@ -222,7 +222,7 @@ private static BorderElement CreateInstructionsPanel() => /// True when the key was one of the advertised controls, false otherwise /// /// Only input is acted on, because that is all - /// produces — every key arrives as + /// produces — every key arrives as /// , and Escape arrives as an exit that /// consumes before reaching any element. /// diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index d2666ad..5194d6e 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -14,6 +14,11 @@ namespace ktsu.TUI.Test; [TestClass] public sealed class SpectreConsoleProviderTests { + /// + /// Gets or sets the test context MSTest injects. + /// + public TestContext TestContext { get; set; } = null!; + private const int Width = 10; private const int Height = 5; @@ -153,6 +158,52 @@ public async Task ReadInputAsyncReturnsTheTypedCharacter() Assert.AreEqual('!', result.Character); } + /// + /// Tests that a cancellable read waits for a key to be available and then reads it. + /// + /// A task that completes when the test has run. + [TestMethod] + public async Task CancellableReadInputAsyncReadsTheKeyOnceOneIsAvailable() + { + int polls = 0; + SpectreConsoleProvider provider = new( + console: null, + () => new ConsoleKeyInfo('a', ConsoleKey.A, shift: false, alt: false, control: false), + keyAvailable: () => Interlocked.Increment(ref polls) > 2); + + InputResult result = await provider.ReadInputAsync(TestContext.CancellationToken).ConfigureAwait(false); + + Assert.AreEqual(ConsoleKey.A, result.Key); + } + + /// + /// Tests that cancelling a read that is waiting for a key ends it without reading one, so no + /// thread is left blocked in Console.ReadKey to take the next key (ktsu-dev/TUI#149). + /// + /// A task that completes when the test has run. + [TestMethod] + public async Task CancellingAReadThatIsWaitingEndsItWithoutReadingAKey() + { + int keysRead = 0; + SpectreConsoleProvider provider = new( + console: null, + () => + { + Interlocked.Increment(ref keysRead); + return new ConsoleKeyInfo('a', ConsoleKey.A, shift: false, alt: false, control: false); + }, + keyAvailable: () => false); + using CancellationTokenSource cancellation = new(); + + Task read = provider.ReadInputAsync(cancellation.Token); + await cancellation.CancelAsync().ConfigureAwait(false); + + Task finished = await Task.WhenAny(read, Task.Delay(TimeSpan.FromSeconds(10), TestContext.CancellationToken)).ConfigureAwait(false); + Assert.AreSame(read, finished, "A cancelled read should end"); + Assert.IsTrue(read.IsCanceled); + Assert.AreEqual(0, keysRead); + } + /// /// Tests that a key with no printable character, such as an arrow or Enter, carries no /// character. diff --git a/TUI.Test/UIApplicationPendingReadTests.cs b/TUI.Test/UIApplicationPendingReadTests.cs new file mode 100644 index 0000000..017f63e --- /dev/null +++ b/TUI.Test/UIApplicationPendingReadTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.TUI.Test; + +using ktsu.TUI.Core.Contracts; +using ktsu.TUI.Core.Elements; +using ktsu.TUI.Core.Models; +using ktsu.TUI.Core.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a run does not leave a read behind when it ends, where it would take the next key +/// meant for the host or for a later run (ktsu-dev/TUI#149). +/// +[TestClass] +public sealed class UIApplicationPendingReadTests +{ + /// + /// How long any single step is given before it is declared stuck. + /// + private static readonly TimeSpan StepTimeout = TimeSpan.FromSeconds(10); + + /// + /// Gets or sets the test context MSTest injects. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// A run ended by gives its read up, so no read is left + /// waiting to take a key the host reads next. + /// + [TestMethod] + public async Task AShutdownLeavesNoReadOutstanding() + { + KeyboardConsoleProvider provider = new(honoursCancellation: true); + UIApplication app = new(provider) { InterruptSource = new FakeInterruptSource() }; + app.Setup(new KeyCountingElement()); + + Task run = app.RunAsync(TestContext.CancellationToken); + await AssertCompletesAsync(provider.WaitForReadsStartedAsync(1), "The run should start a read").ConfigureAwait(false); + app.Shutdown(); + await AssertCompletesAsync(run, "Shutdown should end the run").ConfigureAwait(false); + + Assert.AreEqual(0, provider.PendingReadCount, "No read should still be waiting for a key once the run has ended"); + } + + /// + /// A key pressed during a second run reaches the root, rather than the read the first run + /// left behind. + /// + /// Whether the provider gives a read up when asked to. + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task AKeyPressedInASecondRunReachesTheRoot(bool honoursCancellation) + { + KeyboardConsoleProvider provider = new(honoursCancellation); + KeyCountingElement root = new(); + UIApplication app = new(provider) { InterruptSource = new FakeInterruptSource() }; + app.Setup(root); + + Task first = app.RunAsync(TestContext.CancellationToken); + await AssertCompletesAsync(provider.WaitForReadsStartedAsync(1), "The first run should start a read").ConfigureAwait(false); + app.Shutdown(); + await AssertCompletesAsync(first, "Shutdown should end the first run").ConfigureAwait(false); + + Task second = app.RunAsync(TestContext.CancellationToken); + await AssertCompletesAsync(provider.WaitForPendingReadAsync(), "The second run should be waiting for a key").ConfigureAwait(false); + provider.Press(ConsoleKey.A); + await AssertCompletesAsync(root.FirstKey, "The key should reach the root of the second run").ConfigureAwait(false); + app.Shutdown(); + await AssertCompletesAsync(second, "Shutdown should end the second run").ConfigureAwait(false); + + Assert.AreEqual(1, root.KeyCount); + } + + private async Task AssertCompletesAsync(Task task, string message) + { + Task finished = await Task.WhenAny(task, Task.Delay(StepTimeout, TestContext.CancellationToken)).ConfigureAwait(false); + Assert.AreSame(task, finished, message); + await task.ConfigureAwait(false); + } + + /// + /// An element that counts the keys it is given. + /// + private sealed class KeyCountingElement : UIElementBase + { + private readonly TaskCompletionSource firstKey = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int keyCount; + + public Task FirstKey => firstKey.Task; + + public int KeyCount => Volatile.Read(ref keyCount); + + public override bool HandleInput(InputResult input) + { + Interlocked.Increment(ref keyCount); + firstKey.TrySetResult(); + return true; + } + + protected override void OnRender(IConsoleProvider provider) + { + } + } + + /// + /// A provider whose keys go to the earliest read still waiting, as with Console.ReadKey. + /// + /// Whether a read ends when its token is cancelled, as + /// SpectreConsoleProvider's does, or keeps waiting, as a provider written against the + /// parameterless ReadInputAsync does. + private sealed class KeyboardConsoleProvider(bool honoursCancellation) : IConsoleProvider + { + private readonly Lock gate = new(); + private readonly List> pending = []; + private readonly List<(int Count, TaskCompletionSource Signal)> startedWaiters = []; + private TaskCompletionSource pendingWaiter = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int started; + + public int PendingReadCount + { + get + { + lock (gate) + { + return pending.Count(read => !read.Task.IsCompleted); + } + } + } + + public Dimensions Dimensions => new(80, 24); + + public void Clear() { } + + public void Render(IUIElement element, Position position) => element?.Render(this); + + public void WriteAt(string text, Position position, TextStyle? style = null) { } + + public void SetCursorVisibility(bool visible) { } + + public void SetCursorPosition(Position position) { } + + public Task ReadInputAsync() => StartRead(CancellationToken.None); + + public Task ReadInputAsync(CancellationToken cancellationToken) => + StartRead(honoursCancellation ? cancellationToken : CancellationToken.None); + + public Task WaitForReadsStartedAsync(int count) + { + lock (gate) + { + if (started >= count) + { + return Task.CompletedTask; + } + + TaskCompletionSource signal = new(TaskCreationOptions.RunContinuationsAsynchronously); + startedWaiters.Add((count, signal)); + return signal.Task; + } + } + + /// + /// Completes once a read is waiting that was started, or carried over, after any earlier + /// one was given up — that is, once a key pressed now would be read. + /// + public Task WaitForPendingReadAsync() + { + lock (gate) + { + if (pending.Exists(read => !read.Task.IsCompleted)) + { + return Task.CompletedTask; + } + + if (pendingWaiter.Task.IsCompleted) + { + pendingWaiter = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + return pendingWaiter.Task; + } + } + + public void Press(ConsoleKey key) + { + TaskCompletionSource? read; + lock (gate) + { + read = pending.Find(candidate => !candidate.Task.IsCompleted); + } + + read?.TrySetResult(InputResult.FromKey(key)); + } + + private Task StartRead(CancellationToken cancellationToken) + { + TaskCompletionSource read = new(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => read.TrySetCanceled(cancellationToken)); + + lock (gate) + { + pending.Add(read); + started++; + foreach ((int count, TaskCompletionSource signal) in startedWaiters.Where(waiter => started >= waiter.Count)) + { + signal.TrySetResult(); + } + + pendingWaiter.TrySetResult(); + } + + return read.Task; + } + } +} diff --git a/TUI/Contracts/IConsoleProvider.cs b/TUI/Contracts/IConsoleProvider.cs index 39ca5a4..3612bd0 100644 --- a/TUI/Contracts/IConsoleProvider.cs +++ b/TUI/Contracts/IConsoleProvider.cs @@ -40,6 +40,19 @@ public interface IConsoleProvider /// The input result public Task ReadInputAsync(); + /// + /// Reads input from the console, giving up once is cancelled + /// + /// Cancelled when the input is no longer wanted, such as when the + /// application shuts down + /// The input result, or a cancelled task if the read was given up + /// + /// A read that outlives the application takes the next key meant for the host or for a later + /// run, so a provider should end the read when the token is cancelled (ktsu-dev/TUI#149). The + /// default ignores the token and calls . + /// + public Task ReadInputAsync(CancellationToken cancellationToken) => ReadInputAsync(); + /// /// Sets the cursor visibility /// diff --git a/TUI/Services/SpectreConsoleProvider.cs b/TUI/Services/SpectreConsoleProvider.cs index c47c5dd..8bf06be 100644 --- a/TUI/Services/SpectreConsoleProvider.cs +++ b/TUI/Services/SpectreConsoleProvider.cs @@ -17,6 +17,13 @@ public class SpectreConsoleProvider(IAnsiConsole? console = null) : IConsoleProv { private readonly IAnsiConsole _console = console ?? AnsiConsole.Console; private readonly Func _readKey = () => Console.ReadKey(true); + private readonly Func _keyAvailable = () => Console.KeyAvailable; + + /// + /// How often a cancellable read checks for a key. Short enough that typing does not feel + /// delayed, long enough that waiting for a key costs next to nothing. + /// + internal static readonly TimeSpan KeyPollInterval = TimeSpan.FromMilliseconds(15); /// /// Initializes a new instance of the class that reads keys @@ -26,7 +33,24 @@ public class SpectreConsoleProvider(IAnsiConsole? console = null) : IConsoleProv /// The Spectre.Console instance to use /// Reads the next key. internal SpectreConsoleProvider(IAnsiConsole? console, Func readKey) - : this(console) => _readKey = readKey; + : this(console, readKey, keyAvailable: () => true) + { + } + + /// + /// Initializes a new instance of the class that reads keys + /// from and asks whether one is + /// waiting, instead of using , so tests can feed it input. + /// + /// The Spectre.Console instance to use + /// Reads the next key. + /// Reports whether a key is waiting to be read. + internal SpectreConsoleProvider(IAnsiConsole? console, Func readKey, Func keyAvailable) + : this(console) + { + _readKey = readKey; + _keyAvailable = keyAvailable; + } /// public Dimensions Dimensions => new(_console.Profile.Width, _console.Profile.Height); @@ -91,6 +115,25 @@ public void WriteAt(string text, Position position, TextStyle? style = null) public async Task ReadInputAsync() => await Task.Run(() => ToInputResult(_readKey())).ConfigureAwait(false); + /// + /// + /// cannot be interrupted, so this waits for + /// and reads only once a key is there. A cancelled read + /// therefore leaves no thread blocked in ReadKey to take the next key (ktsu-dev/TUI#149). + /// + public Task ReadInputAsync(CancellationToken cancellationToken) => + Task.Run( + async () => + { + while (!_keyAvailable()) + { + await Task.Delay(KeyPollInterval, cancellationToken).ConfigureAwait(false); + } + + return ToInputResult(_readKey()); + }, + cancellationToken); + /// /// Converts a key read from the console into an input result. /// diff --git a/TUI/Services/UIApplication.cs b/TUI/Services/UIApplication.cs index d2e3a0d..1799358 100644 --- a/TUI/Services/UIApplication.cs +++ b/TUI/Services/UIApplication.cs @@ -103,6 +103,18 @@ public class UIApplication(IConsoleProvider consoleProvider, ILogger internal const int MaxConsecutiveReadFailures = 10; + /// + /// A read still in progress when the last run ended, because its provider ignored the + /// cancellation. The next run waits on it rather than starting a second read to race it. + /// + private Task? _pendingRead; + + /// + /// How long the end of a run waits for its cancelled read to finish, so a provider that gives + /// reads up has done so by the time the run returns + /// + private static readonly TimeSpan PendingReadCancellationTimeout = TimeSpan.FromMilliseconds(250); + /// /// The terminal size the current layout was computed for, or null before the first render /// @@ -422,128 +434,147 @@ public async Task ProcessInputAsync(CancellationToken cancellationToken = defaul // One read is carried across iterations. The resize poll below wakes the loop without a // keypress, and starting a fresh read each time it woke would leave several reads racing - // for the next key. - Task? pendingRead = null; + // for the next key. It is carried across runs too: a provider that cannot give a read up + // is still waiting on it, and a second read would race it for every key (ktsu-dev/TUI#149). + Task? pendingRead = _pendingRead; + _pendingRead = null; int consecutiveReadFailures = 0; - while (!cancellationToken.IsCancellationRequested && IsRunning) - { - // Tells the catch blocks below whether the failure came from reading or from handling - bool readSucceeded = false; + // Cancelled when the loop ends, however it ends, so a provider can give its read up instead + // of leaving it behind to take the next key meant for the host (ktsu-dev/TUI#149). + using CancellationTokenSource readCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - try + try + { + while (!cancellationToken.IsCancellationRequested && IsRunning) { - pendingRead ??= ConsoleProvider.ReadInputAsync(); + // Tells the catch blocks below whether the failure came from reading or from handling + bool readSucceeded = false; - if (!pendingRead.IsCompleted) + try { - // Wake on a timer as well as on input. A resize produces no input at all, so a - // loop that only wakes for a key cannot notice one. Cancelling the delay is - // also what lets a shutdown request end a run blocked on the keyboard: a - // provider parked in Console.ReadKey does not observe the token itself. - Task idle = Task.Delay(ResizePollInterval, cancellationToken); - await Task.WhenAny(pendingRead, idle).ConfigureAwait(false); - - if (cancellationToken.IsCancellationRequested) - { - break; - } + pendingRead ??= ConsoleProvider.ReadInputAsync(readCancellation.Token); if (!pendingRead.IsCompleted) { - // The timer won the race, so no key arrived. Redraw only if the terminal - // changed size or an element changed while we waited, and go back to the - // same pending read. - if (Volatile.Read(ref _redrawRequested) == 1 || HasConsoleResized()) + // Wake on a timer as well as on input. A resize produces no input at all, so a + // loop that only wakes for a key cannot notice one. Cancelling the delay is + // also what lets a shutdown request end a run blocked on the keyboard: a + // provider parked in Console.ReadKey does not observe the token itself. + Task idle = Task.Delay(ResizePollInterval, cancellationToken); + await Task.WhenAny(pendingRead, idle).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (!pendingRead.IsCompleted) { - Render(); + // The timer won the race, so no key arrived. Redraw only if the terminal + // changed size or an element changed while we waited, and go back to the + // same pending read. + if (Volatile.Read(ref _redrawRequested) == 1 || HasConsoleResized()) + { + Render(); + } + + continue; } + } + + // Cleared before the await so a read that failed is not retried forever by the + // recoverable-error branches below. + Task completedRead = pendingRead; + pendingRead = null; + if (completedRead.IsCanceled && !cancellationToken.IsCancellationRequested) + { + // A read carried over from an earlier run, which its provider gave up when + // that run ended. It holds no key, so start a fresh one. continue; } - } - // Cleared before the await so a read that failed is not retried forever by the - // recoverable-error branches below. - Task completedRead = pendingRead; - pendingRead = null; - Models.InputResult input = await completedRead.ConfigureAwait(false); - readSucceeded = true; - consecutiveReadFailures = 0; + Models.InputResult input = await completedRead.ConfigureAwait(false); + readSucceeded = true; + consecutiveReadFailures = 0; - if (_logger != null) + if (_logger != null) + { + LogReceivedInput(_logger, input.Type.ToString(), null); + } + + // Handle global exit conditions + if (input.IsExit) + { + if (_logger != null) + { + LogExitInputReceived(_logger, null); + } + Shutdown(); + break; + } + + // Let the root element handle the input + bool handled = RootElement?.HandleInput(input) ?? false; + + if (!handled && _logger != null) + { + LogInputNotHandled(_logger, null); + } + + // Re-render if needed (elements invalidate themselves when they change) + Render(); + } + catch (OperationCanceledException) { - LogReceivedInput(_logger, input.Type.ToString(), null); + break; } - - // Handle global exit conditions - if (input.IsExit) + catch (InvalidOperationException ex) { if (_logger != null) { - LogExitInputReceived(_logger, null); + LogInputProcessingError(_logger, ex); } - Shutdown(); - break; - } - // Let the root element handle the input - bool handled = RootElement?.HandleInput(input) ?? false; + if (!readSucceeded && ++consecutiveReadFailures >= MaxConsecutiveReadFailures) + { + throw; + } - if (!handled) + // Continue processing for recoverable errors + } + catch (ArgumentException ex) { if (_logger != null) { - LogInputNotHandled(_logger, null); + LogInputProcessingError(_logger, ex); } - } - // Re-render if needed (elements invalidate themselves when they change) - Render(); - } - catch (OperationCanceledException) - { - break; - } - catch (InvalidOperationException ex) - { - if (_logger != null) - { - LogInputProcessingError(_logger, ex); - } + if (!readSucceeded && ++consecutiveReadFailures >= MaxConsecutiveReadFailures) + { + throw; + } - if (!readSucceeded && ++consecutiveReadFailures >= MaxConsecutiveReadFailures) - { - throw; + // Continue processing for recoverable errors } - - // Continue processing for recoverable errors - } - catch (ArgumentException ex) - { - if (_logger != null) + catch (OutOfMemoryException) { - LogInputProcessingError(_logger, ex); + // Critical error - rethrow + throw; } - - if (!readSucceeded && ++consecutiveReadFailures >= MaxConsecutiveReadFailures) + catch (StackOverflowException) { + // Critical error - rethrow throw; } - - // Continue processing for recoverable errors - } - catch (OutOfMemoryException) - { - // Critical error - rethrow - throw; - } - catch (StackOverflowException) - { - // Critical error - rethrow - throw; } } + finally + { + await readCancellation.CancelAsync().ConfigureAwait(false); + _pendingRead = await AwaitEndOfReadAsync(pendingRead).ConfigureAwait(false); + } if (_logger != null) { @@ -551,6 +582,26 @@ public async Task ProcessInputAsync(CancellationToken cancellationToken = defaul } } + /// + /// Gives a read the loop has just cancelled a moment to finish + /// + /// The read the loop was waiting on, if any + /// The read if it is still in progress, for the next run to wait on; otherwise null + /// + /// A read that completed with a key in the race against the shutdown is dropped, as a key + /// pressed while the application was closing would be. + /// + private static async Task?> AwaitEndOfReadAsync(Task? pendingRead) + { + if (pendingRead is null || pendingRead.IsCompleted) + { + return null; + } + + await Task.WhenAny(pendingRead, Task.Delay(PendingReadCancellationTimeout)).ConfigureAwait(false); + return pendingRead.IsCompleted ? null : pendingRead; + } + /// /// Sets up the application with the specified root element ///