Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions TUI.Core/Contracts/IInterruptSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Core.Contracts;

/// <summary>
/// Defines the contract for a source of process interrupt signals such as Ctrl+C and SIGTERM
/// </summary>
/// <remarks>
/// The real source is the process itself, which a test cannot signal without terminating the test
/// run. This seam lets the application's interrupt handling be exercised directly.
/// </remarks>
internal interface IInterruptSource
{
/// <summary>
/// Registers a callback to invoke when an interrupt signal arrives
/// </summary>
/// <param name="onInterrupt">The callback to invoke</param>
/// <returns>A registration that unhooks the callback when disposed</returns>
public IDisposable Register(Action onInterrupt);
}
93 changes: 93 additions & 0 deletions TUI.Core/Services/ConsoleInterruptSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Core.Services;

using System.Runtime.InteropServices;

using ktsu.TUI.Core.Contracts;

/// <summary>
/// An <see cref="IInterruptSource"/> backed by the process's real interrupt signals
/// </summary>
/// <remarks>
/// Ctrl+C is taken through <see cref="Console.CancelKeyPress"/> rather than by setting
/// <see cref="Console.TreatControlCAsInput"/>, because the latter only delivers the key while the
/// application happens to be inside a blocking read, whereas the event is observed wherever the
/// application is. SIGTERM is taken for the same reason: left alone, either signal ends the
/// process before the application can put back the terminal state it changed.
/// </remarks>
internal sealed class ConsoleInterruptSource : IInterruptSource
{
/// <inheritdoc />
public IDisposable Register(Action onInterrupt)
{
Ensure.NotNull(onInterrupt);

return new Registration(onInterrupt);
}

/// <summary>
/// Responds to an arriving signal, whichever mechanism delivered it
/// </summary>
/// <param name="cancelDefaultTermination">Cancels the runtime's default "terminate now" response</param>
/// <param name="onInterrupt">Notifies the application that it should shut down</param>
/// <remarks>
/// Both delivery mechanisms funnel through here because neither
/// <see cref="ConsoleCancelEventArgs"/> nor <see cref="PosixSignalContext"/> can be constructed
/// by a test, so the response to a signal is only assertable once it is separated from the
/// delivery of one.
/// </remarks>
internal static void OnSignal(Action cancelDefaultTermination, Action onInterrupt)
{
// Cancel first. Notifying can run arbitrary application code, and until the default
// response is cancelled the runtime is still entitled to kill the process underneath it.
cancelDefaultTermination();
onInterrupt();
}

/// <summary>
/// Holds the signal hooks for one <see cref="Register"/> call and unhooks them on disposal
/// </summary>
private sealed class Registration : IDisposable
{
private readonly ConsoleCancelEventHandler _cancelKeyPress;
private readonly PosixSignalRegistration? _sigTerm;
private bool _disposed;

internal Registration(Action onInterrupt)
{
_cancelKeyPress = (_, e) => OnSignal(() => e.Cancel = true, onInterrupt);

Console.CancelKeyPress += _cancelKeyPress;
_sigTerm = TryRegisterSigTerm(onInterrupt);
}

/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;
Console.CancelKeyPress -= _cancelKeyPress;
_sigTerm?.Dispose();
}

private static PosixSignalRegistration? TryRegisterSigTerm(Action onInterrupt)
{
try
{
return PosixSignalRegistration.Create(
PosixSignal.SIGTERM,
context => OnSignal(() => context.Cancel = true, onInterrupt));
}
catch (PlatformNotSupportedException)
{
// Nothing to do: Ctrl+C is still handled, which is the common case.
return null;
}
}
}
}
4 changes: 3 additions & 1 deletion TUI.Core/Services/SpectreConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ public async Task<InputResult> ReadInputAsync()
{
ConsoleKeyInfo keyInfo = Console.ReadKey(true);

// Handle special cases
// Handle special cases. Ctrl+C normally arrives as an interrupt signal rather than as
// a key, and UIApplication handles it there; this branch only fires for a host that
// has set Console.TreatControlCAsInput.
if (keyInfo.Key == ConsoleKey.Escape ||
(keyInfo.Key == ConsoleKey.C && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)))
{
Expand Down
39 changes: 38 additions & 1 deletion TUI.Core/Services/UIApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@
private static readonly Action<ILogger, string, Exception?> LogUIApplicationSetup =
LoggerMessage.Define<string>(LogLevel.Information, new EventId(16, nameof(LogUIApplicationSetup)), "UI application setup with root element of type {ElementType}");

private static readonly Action<ILogger, Exception?> LogInterruptReceived =
LoggerMessage.Define(LogLevel.Information, new EventId(17, nameof(LogInterruptReceived)), "Interrupt signal received, shutting down");

/// <summary>
/// Gets the source of process interrupt signals that shuts the application down
/// </summary>
/// <remarks>
/// Defaults to the real console and process signals. Tests substitute a source they can raise.
/// </remarks>
internal IInterruptSource InterruptSource { get; init; } = new ConsoleInterruptSource();

/// <inheritdoc />
public IUIElement? RootElement { get; set; }

Expand All @@ -89,7 +100,8 @@
}

IsRunning = true;
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

Check warning on line 103 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.

Check warning on line 103 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.
IDisposable? interruptRegistration = null;

try
{
Expand All @@ -98,6 +110,10 @@
LogStartingApplication(_logger, null);
}

// Take Ctrl+C and SIGTERM for the duration of the run. Both otherwise end the process
// outright, skipping the finally below that puts the cursor back.
interruptRegistration = InterruptSource.Register(OnInterrupt);

// Initialize console
ConsoleProvider.Clear();
ConsoleProvider.SetCursorVisibility(false);
Expand Down Expand Up @@ -125,6 +141,9 @@
}
finally
{
// Stop taking signals before restoring the terminal, so a second Ctrl+C arriving
// during teardown gets the runtime's default behaviour rather than a second shutdown.
interruptRegistration?.Dispose();
IsRunning = false;
ConsoleProvider.SetCursorVisibility(true);
if (_logger != null)
Expand All @@ -134,6 +153,19 @@
}
}

/// <summary>
/// Handles an interrupt signal by shutting the application down through its normal path
/// </summary>
private void OnInterrupt()
{
if (_logger != null)
{
LogInterruptReceived(_logger, null);
}

Shutdown();
}

/// <inheritdoc />
public void Shutdown()
{
Expand Down Expand Up @@ -205,7 +237,7 @@
}

/// <inheritdoc />
public async Task ProcessInputAsync(CancellationToken cancellationToken = default)

Check warning on line 240 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed.

Check warning on line 240 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed.
{
if (_logger != null)
{
Expand All @@ -216,7 +248,12 @@
{
try
{
Models.InputResult input = await ConsoleProvider.ReadInputAsync().ConfigureAwait(false);
// Abandon the read when cancellation is requested. A provider parked in
// Console.ReadKey does not observe the token, so awaiting it directly would keep
// the loop alive until the user pressed an unrelated key after asking to exit.
Models.InputResult input = await ConsoleProvider.ReadInputAsync()
.WaitAsync(cancellationToken)
.ConfigureAwait(false);

if (_logger != null)
{
Expand All @@ -239,7 +276,7 @@

if (!handled)
{
if (_logger != null)

Check warning on line 279 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Merge this if statement with the enclosing one.

Check warning on line 279 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Merge this if statement with the enclosing one.
{
LogInputNotHandled(_logger, null);
}
Expand Down Expand Up @@ -270,12 +307,12 @@

// Continue processing for recoverable errors
}
catch (OutOfMemoryException)

Check warning on line 310 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.

Check warning on line 310 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
}
catch (StackOverflowException)

Check warning on line 315 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.

Check warning on line 315 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
Expand Down
81 changes: 81 additions & 0 deletions TUI.Test/BlockingConsoleProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Contracts;
using ktsu.TUI.Core.Models;

/// <summary>
/// An <see cref="IConsoleProvider"/> test double whose <see cref="ReadInputAsync"/> does not
/// complete until a test releases it, standing in for a real provider parked in
/// <c>Console.ReadKey</c>.
/// </summary>
/// <remarks>
/// Lifecycle tests need the input loop to be genuinely blocked. <c>RecordingConsoleProvider</c>
/// returns input immediately, which spins the loop and hides whether a shutdown request can end a
/// run that is waiting on the keyboard — the case that left the cursor hidden after Ctrl+C.
/// </remarks>
internal sealed class BlockingConsoleProvider : IConsoleProvider
{
private readonly TaskCompletionSource<InputResult> pendingRead =
new(TaskCreationOptions.RunContinuationsAsynchronously);

private readonly TaskCompletionSource readStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);

/// <summary>
/// Gets a task that completes once the application has begun waiting for input.
/// </summary>
internal Task ReadStarted => readStarted.Task;

/// <summary>
/// Volatile because the application sets it from the thread running the app while the test
/// reads it, and one assertion reads it mid-run rather than after the run has completed.
/// </summary>
private volatile bool cursorVisible = true;

/// <summary>
/// Gets the last cursor visibility set through <see cref="SetCursorVisibility"/>.
/// </summary>
internal bool CursorVisible => cursorVisible;

/// <inheritdoc />
public Dimensions Dimensions { get; set; } = new(80, 24);

/// <inheritdoc />
public void Clear()
{
// Nothing to record: these tests assert on lifecycle, not on drawn output.
}

/// <inheritdoc />
public void Render(IUIElement element, Position position) => element?.Render(this);

/// <inheritdoc />
public void WriteAt(string text, Position position, TextStyle? style = null)
{
// Nothing to record: these tests assert on lifecycle, not on drawn output.
}

/// <inheritdoc />
public Task<InputResult> ReadInputAsync()
{
readStarted.TrySetResult();
return pendingRead.Task;
}

/// <inheritdoc />
public void SetCursorVisibility(bool visible) => cursorVisible = visible;

/// <inheritdoc />
public void SetCursorPosition(Position position)
{
// Nothing to record: these tests assert on lifecycle, not on cursor placement.
}

/// <summary>
/// Completes the pending read, as pressing a key would.
/// </summary>
/// <param name="input">The input to deliver.</param>
internal void Release(InputResult input) => pendingRead.TrySetResult(input);
}
49 changes: 49 additions & 0 deletions TUI.Test/FakeInterruptSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Contracts;

/// <summary>
/// An <see cref="IInterruptSource"/> test double a test can raise on demand.
/// </summary>
/// <remarks>
/// The real source is the process's own Ctrl+C and SIGTERM handling, which a test cannot trigger
/// without signalling — and very likely terminating — the test host itself.
/// </remarks>
internal sealed class FakeInterruptSource : IInterruptSource
{
private readonly TaskCompletionSource registered =
new(TaskCreationOptions.RunContinuationsAsynchronously);

private Action? handler;

/// <summary>
/// Gets a task that completes once the application has registered its interrupt handler.
/// </summary>
internal Task Registered => registered.Task;

/// <summary>
/// Gets the number of times the registration returned by <see cref="Register"/> was disposed.
/// </summary>
internal int DisposeCount { get; private set; }

/// <inheritdoc />
public IDisposable Register(Action onInterrupt)
{
handler = onInterrupt;
registered.TrySetResult();
return new Registration(this);
}

/// <summary>
/// Invokes the registered handler, as Ctrl+C or SIGTERM would.
/// </summary>
internal void RaiseInterrupt() => handler?.Invoke();

private sealed class Registration(FakeInterruptSource owner) : IDisposable
{
/// <inheritdoc />
public void Dispose() => owner.DisposeCount++;
}
}
Loading