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
///