diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 9b33bf4a6..dd6f606e6 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -15,7 +15,7 @@ permissions: env: DOTNET_VERSION: 10.0.x CONFIGURATION: Release - EXPECTED_VERSION: 1.29.0 + EXPECTED_VERSION: 1.29.1 PACKAGE_CANDIDATE_ARTIFACT: icod-terminal-main-package-candidate jobs: diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 995cdf7e8..368b9817e 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -39,6 +39,11 @@ jobs: if: runner.os == 'Windows' shell: powershell run: ./packaging/VerifyLicenseHeaders.ps1 + - name: Verify native Windows console input and cancellation + if: runner.os == 'Windows' + timeout-minutes: 3 + shell: pwsh + run: dotnet run --project tests/Icod.Terminal.WindowsInput.Smoke -c '${{ env.CONFIGURATION }}' - name: Verify runtime and source integration shell: pwsh run: ./packaging/VerifyRuntime.ps1 -Configuration '${{ env.CONFIGURATION }}' diff --git a/CHANGELOG.md b/CHANGELOG.md index b5db05601..ed643cfb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Notable changes to `Icod.Terminal` are recorded here for consumers who need a concise release history. Detailed design evidence remains in the versioned roadmaps, tranche records, and public-API baseline documents. +## 1.29.1 + +- Decode legacy Escape-prefixed Alt letters as a single modified key, including + fragmented UTF-8. Keep standalone Escape and control-sequence recovery intact. +- Read Windows console input through `ReadConsoleW`, preserving Ctrl+Z instead + of treating it as EOF on affected console hosts. Encode Unicode as UTF-8 + without depending on the console code page, including split surrogate pairs. +- Verify that raw sessions deliver Ctrl+C and Ctrl+Z as Control-modified + Character keys on Unix PTYs and through the native Windows console reader. +- Cancel and finish outstanding owned console reads before closing their handle. +- Add a Windows CI smoke using an isolated native console for Ctrl+Z, following + input, Alt+F, Unicode, and shutdown with an outstanding read. + ## 1.29.0 - Add independent immutable session clipboard read/write budgets, preserving the diff --git a/Directory.Build.props b/Directory.Build.props index 1c26e35d6..01d918986 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.29.0 + 1.29.1 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix) $(Version) diff --git a/Icod.Terminal.csproj b/Icod.Terminal.csproj index 28c914c53..0aec22ec5 100644 --- a/Icod.Terminal.csproj +++ b/Icod.Terminal.csproj @@ -39,7 +39,7 @@ Icod.Terminal Timothy J. Bruce Managed, cross-platform live-terminal session, endpoint, mode, input, lifecycle, and terminal-control foundation for .NET. - Icod.Terminal 1.29.0 adds independent bounded clipboard read/write budgets: 64 KiB defaults with explicit opt-in through 16 MiB. Larger correlated reads use incremental bounded storage; writes encode directly into a complete ordered frame. Includes explicit public samples and package validation while preserving .NET 8/9/10, existing APIs and production dependencies. Local capacity does not negotiate remote acceptance. Release notes: https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.0.md. Compatibility policy: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md. Usage: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Clipboard-Transfers.md. + 1.29.1 decodes legacy Escape-prefixed Alt characters, including fragmented Unicode, and preserves Ctrl+Z and Unicode on Windows using direct ReadConsoleW input. Raw sessions expose Ctrl+C and Ctrl+Z as application keys; CBreak retains host control handling. Adds cancellation-safe owned console reads, Rich Input sample checks, Unix PTY coverage, and a native Windows console regression smoke. No public API or dependency changes. Release notes: https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.1.md. Compatibility policy: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md. README.md icon.png https://github.com/uniblab/Icod.Terminal diff --git a/README.md b/README.md index a36c1676e..d4990b34c 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,9 @@ ## Status -Stable release: **1.29.0**. It adds bounded, opt-in OSC 52 clipboard transfers; -the stable **1.0.0 compatibility floor** remains unchanged. +Stable release: **1.29.1**. The stable **1.0.0 compatibility floor** remains unchanged. -See the [1.29.0 release notes](https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.0.md), +See the [1.29.1 release notes](https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.1.md), [changelog](https://github.com/uniblab/Icod.Terminal/blob/main/CHANGELOG.md) for release history, the [development roadmap](https://github.com/uniblab/Icod.Terminal/blob/main/Icod.Terminal-Development-Roadmap.md) for current work, and the [compatibility policy](https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md) @@ -66,7 +65,7 @@ See [`docs/Architecture.md`](docs/Architecture.md) for the permanent architectur Install the stable release: ```text -dotnet add package Icod.Terminal --version 1.29.0 +dotnet add package Icod.Terminal --version 1.29.1 ``` The partial animation-frame API requires `Icod.Terminal 1.23.0` or later. The geometry and planning APIs described below require `Icod.Terminal 1.24.0` or later. @@ -235,7 +234,7 @@ The [compatibility sample](samples/Icod.Terminal.Compatibility.Sample/README.md) The root README describes the current product by capability rather than by the release in which each feature first appeared. - **Live terminal session and lifecycle** — terminal endpoint observation; native terminal-mode capture/mutation; deterministic session disposal; lifecycle invalidation; scoped/reversible state where exact restoration is supportable. -- **Unified input and events** — one authoritative reader for text, keys, bracketed paste, focus, mouse, lifecycle observations, active query responses, and unsolicited protocol-neutral semantic events. +- **Unified input and events** — one authoritative reader for text, keys, bracketed paste, focus, mouse, lifecycle observations, active query responses, and unsolicited protocol-neutral semantic events, including legacy Escape-prefixed Alt characters and direct Windows console control/Unicode input. - **Typed terminal queries** — bounded cursor, status, style, color, clipboard, notification, pointer, appearance, and related observations integrated with the same input/query authority. - **Terminal environment awareness** — opt-in appearance and in-band resize reporting with independent scoped ownership, typed semantic observations, and separate native-geometry provenance. - **Static screen advertisement** — immutable `Profile.Screen.Advertises...` facts, separate from parameter-specific planning and live evidence; the [capability sample](samples/Icod.Terminal.CapabilityPlanning.Sample/README.md) demonstrates all three. @@ -394,6 +393,19 @@ The repository uses C# 13. Release qualification covers Windows, Linux, and macO `TerminalSession.OpenAsync(options)` reads interactive Unix input through a session-owned byte transport on Linux and macOS. It bypasses .NET console line editing so keys and terminal replies reach the decoder without waiting for Enter or acquiring managed echo. The existing input-mode and echo options still control native terminal behavior. Dispose the session to restore modes and release its input descriptor. The overload accepting explicit byte services continues to borrow them; callers retain their lifetime responsibility. +On Windows, the same overload owns a separate `CONIN$` handle and reads it with +`ReadConsoleW`. This keeps Ctrl+Z in the input stream instead of allowing a +standard byte-stream read to interpret it as end-of-input, and converts console +Unicode to UTF-8 for the common decoder. Cancellation finishes the outstanding +native read before its owned handle is closed. Sessions opened with explicit +byte services retain their existing borrowed-transport behavior. + +Applications that bind Ctrl+C or Ctrl+Z as commands should request +`TerminalInputMode.Raw`. Raw mode disables POSIX signal handling and Windows +processed input so both arrive through `ReadEventAsync()` as Character keys with +the Control modifier. `CBreak` deliberately retains host signal/processed-input +handling for applications that want conventional interrupt and suspend behavior. + `Icod.Terminal` is a managed library but intentionally uses native platform facilities where terminal modes or console behavior require them. Protocol and feature availability still depends on the terminal endpoint attached to the process; operating-system identity or terminal brand is not treated as sufficient support proof. ## Design Boundaries and Guarantees diff --git a/docs/Input-and-Events.md b/docs/Input-and-Events.md index 90a265a62..9627239aa 100644 --- a/docs/Input-and-Events.md +++ b/docs/Input-and-Events.md @@ -18,6 +18,14 @@ Typed query methods share the same underlying input stream and response router. `ITerminalInput` remains public so custom transports can be supplied when opening a session. A live session does not expose that borrowed input transport as a public property. Callers must not create a competing reader over the same transport while the session owns it. +The default Windows session owns a separate `CONIN$` handle and reads Unicode +characters through `ReadConsoleW`, then incrementally encodes them as UTF-8 for +the common decoder. This preserves control characters such as Ctrl+Z instead of +giving byte-stream EOF semantics to the Windows console host, and preserves +surrogate pairs across native read boundaries. Cancellation completes the +outstanding native read before closing the owned handle. An explicit +`ITerminalInput` remains borrowed and does not use this Windows-console path. + This rule is normative. It prevents an external read from stealing bytes belonging to fragmented UTF-8, terminal keys, mouse/focus/paste frames, modern keyboard frames, active query responses, or unsolicited semantic reports. ## 2. Unified event model @@ -145,6 +153,15 @@ The decoder recognizes terminfo key capabilities for navigation, editing, functi Traditional keyboard input remains available regardless of whether a modern keyboard protocol is negotiated. +Applications that need Ctrl+C, Ctrl+Z, or another host control character as an +application command must request `TerminalInputMode.Raw`. In raw mode, decoded +Ctrl+C and Ctrl+Z are `TerminalKey.Character` events with the Control modifier +and `Character` equal to `C` or `Z`. `TerminalInputMode.CBreak` intentionally +retains POSIX signal handling and Windows processed-input handling; it is not an +interception mode for those shortcuts. The application chooses the input mode +when opening the session and handles the resulting event through +`ReadEventAsync(...)`; no separate signal-interception API is required. + ### Escape ambiguity An isolated Escape key is ambiguous with the prefix of many terminal sequences. The decoder therefore uses a bounded ambiguity interval. @@ -159,6 +176,14 @@ and may be configured per session through `TerminalInputDecoderOptions.EscapeSeq The timeout distinguishes an isolated Escape from an incomplete escape-prefixed sequence; it is not an application-wide input polling interval. +After reserving known terminfo matches and control-family introducers for their +existing sequence and recovery paths, Escape followed within that interval by +one valid non-control UTF-8 scalar becomes a `TerminalKey.Character` key with +the Alt modifier. The scalar may arrive across multiple native reads. CSI, OSC, +DCS, APC, PM, SOS, SS3, and string-terminator introducers do not become Alt +characters. If no qualifying scalar arrives within the interval, the decoder +returns the isolated Escape key and retains later input for normal decoding. + ## 7. Rich input events ### Mouse diff --git a/docs/releases/1.29.1.md b/docs/releases/1.29.1.md new file mode 100644 index 000000000..c5f478860 --- /dev/null +++ b/docs/releases/1.29.1.md @@ -0,0 +1,51 @@ +# Icod.Terminal 1.29.1 + +This patch repairs input problems exposed by the Icod.Widgets menu gallery. +Legacy Alt+F now arrives as one Alt-modified character. The same rule preserves +fragmented non-control Unicode Alt characters. Windows console input +uses `ReadConsoleW` so Ctrl+Z remains a control key on hosts where `ReadFile` +interprets it as end-of-input, even in raw mode. + +## Input behavior + +Known terminfo keys and control-family introducers retain their existing decoding +and recovery paths. A valid non-control UTF-8 scalar following Escape within the +configured ambiguity interval becomes one `TerminalKey.Character` event with +the Alt modifier. An isolated Escape still resolves after the bounded interval. + +The default Windows session opens a separate owned `CONIN$` handle. `ReadConsoleW` +provides UTF-16 input, which the transport incrementally converts to UTF-8 for +the shared decoder. Ctrl+Z therefore remains data rather than end-of-input, and +surrogate pairs remain intact across native reads and small caller buffers. + +Applications can intercept both Ctrl+C and Ctrl+Z by opening the session with +`TerminalInputMode.Raw` and handling their Control-modified Character events +through `ReadEventAsync(...)`. `CBreak` continues to preserve host signal and +processed-input behavior. This input-mode contract is unchanged; 1.29.1 repairs +the Windows transport so Ctrl+Z can satisfy it reliably. + +## Ownership and compatibility + +The reader preserves Unicode across native read boundaries and owns a separate +console handle. Cancellation waits for the native read to finish before closing +the handle. Process input/output encodings are not changed. + +No public APIs or production dependencies change. Targets remain `net8.0`, `net9.0`, and `net10.0`. +See [compatibility policy](../Compatibility-and-Versioning.md). +Existing clipboard budgets and keyboard protocol reporting remain available. +Ambiguous control-family introducers retain their existing sequence behavior. + +## Validation + +Decoder and transport tests cover fragmented Alt input, standalone Escape, +control sequences, Ctrl+Z, small UTF-8 buffers, split surrogate pairs, +cancellation, reuse, and disposal. The PR Windows job runs an isolated native +console smoke with injected key records through the real session reader. + +The Rich Input sample now uses raw input and prompts a fallback Alt+F check plus +Ctrl+C/Ctrl+Z-followed-by-input checks. Linux and macOS PTY smokes verify both +control shortcuts in raw mode; the isolated native Windows smoke verifies them +through the real Windows session reader. + +After publishing, the Widgets/DCurses dependency chain must consume this patch +before the Widgets gallery's physical Alt+F and Windows Ctrl+Z retest. diff --git a/samples/Icod.Terminal.RichInput.Sample/Program.cs b/samples/Icod.Terminal.RichInput.Sample/Program.cs index a1bbd8821..b45aa2691 100644 --- a/samples/Icod.Terminal.RichInput.Sample/Program.cs +++ b/samples/Icod.Terminal.RichInput.Sample/Program.cs @@ -25,7 +25,8 @@ You should have received a copy of the GNU General Public License await using TerminalSession session = await TerminalSession.OpenAsync( new TerminalSessionOptions { - InputMode = TerminalInputMode.CBreak, + // Raw lets applications bind control characters such as Ctrl+C and Ctrl+Z. + InputMode = TerminalInputMode.Raw, EchoInput = false } ); @@ -83,6 +84,14 @@ await WriteLineAsync( session, "Kitty-capable terminals may report repeat/release phases, alternate key identities, and associated text." ); + await WriteLineAsync( + session, + "With traditional input, Alt+F should report one Alt-modified Character key." + ); + await WriteLineAsync( + session, + "Raw mode lets Ctrl+C and Ctrl+Z report as Control-modified Character keys; input should continue." + ); await WriteLineAsync( session, "Mouse coordinates below are zero-based terminal-cell coordinates." diff --git a/samples/Icod.Terminal.RichInput.Sample/README.md b/samples/Icod.Terminal.RichInput.Sample/README.md index 363abe268..8cb92aa0e 100644 --- a/samples/Icod.Terminal.RichInput.Sample/README.md +++ b/samples/Icod.Terminal.RichInput.Sample/README.md @@ -12,6 +12,28 @@ dotnet run --project samples/Icod.Terminal.RichInput.Sample/Icod.Terminal.RichIn The sample also targets `net8.0` and `net9.0` when the matching runtime is installed. Type text, use navigation/editing keys, paste, click, or change focus. Press **q**, **Q**, or **Escape** to exit; end of input also ends the loop. Terminal support determines which reports actually arrive, so a physical terminal need not emit every event shown in the scripted package witness. +## 1.29.1 input checks + +When modern keyboard reporting is unavailable and the terminal uses traditional +Escape-prefixed input, press Alt+F. It should produce one event resembling: + +```text +Key key=Character ... character="f" ... modifiers=Alt +``` + +The exact phase and alternate-character fields depend on the terminal. The +important observations are one Character key, `f`, and the Alt modifier rather +than a standalone Escape followed by text. + +The sample deliberately requests `TerminalInputMode.Raw`. Press Ctrl+C, type a +printable key, then press Ctrl+Z and type another printable key. Each control +shortcut should produce one `TerminalKey.Character` event with the Control +modifier (`C` or `Z`), and both following keys must still arrive. Raw mode is +essential here: `CBreak` retains host signal/processed-input handling, where +POSIX normally maps Ctrl+C to `SIGINT` and Ctrl+Z to `SIGTSTP`, and Windows +retains processed console handling. Neither check requires modern keyboard +reporting. + ## Reporting and fallback The sample first requests an input-protocol lease for Kitty `AllKeys` reporting, bracketed paste, focus, and button mouse tracking. If that combined request is unavailable, it tries paste/focus/mouse without modern keyboard reporting. If that is also unavailable, the basic event loop still runs. The lease is disposed on exit; the session remains the sole reader throughout. diff --git a/samples/README.md b/samples/README.md index ed888535a..a8722631d 100644 --- a/samples/README.md +++ b/samples/README.md @@ -78,7 +78,7 @@ Normal execution needs interactive input/output and alternate-screen/cursor/rend Interactive inspector for text, keys, bracketed paste, focus, mouse, lifecycle, unsolicited semantic events, and negotiated modern keyboard reporting. -On terminals that send Kitty phase-bearing functional CSI frames, navigation, editing, and Menu keys retain repeat/release phases in the existing event model. The example tracks a held Left key and Control+Insert repeats only while a modern keyboard-reporting lease is active. Traditional key decoding remains the fallback; an ordinary traditional press does not imply that a matching release will arrive. The [sample walkthrough](Icod.Terminal.RichInput.Sample/README.md) explains reporting, output, privacy, and fresh-package verification. +On terminals that send Kitty phase-bearing functional CSI frames, navigation, editing, and Menu keys retain repeat/release phases in the existing event model. The example tracks a held Left key and Control+Insert repeats only while a modern keyboard-reporting lease is active. Traditional key decoding remains the fallback; an ordinary traditional press does not imply that a matching release will arrive. In fallback mode, Alt+F demonstrates one Alt-modified character event. The sample uses raw input so Ctrl+C and Ctrl+Z can be application shortcuts rather than host signals or processed-console controls. The [sample walkthrough](Icod.Terminal.RichInput.Sample/README.md) explains reporting, the 1.29.1 checks, output, privacy, and fresh-package verification. ```text dotnet run --project samples/Icod.Terminal.RichInput.Sample/Icod.Terminal.RichInput.Sample.csproj -f net10.0 diff --git a/src/Input/TerminalInputDecoder.cs b/src/Input/TerminalInputDecoder.cs index 350aaa1fb..a198ae735 100644 --- a/src/Input/TerminalInputDecoder.cs +++ b/src/Input/TerminalInputDecoder.cs @@ -318,6 +318,8 @@ CancellationToken cancellationToken switch ( first ) { case EscapeByte: + TerminalInputEvent? alt = await this.TryReadLegacyAltAsync( cancellationToken ).ConfigureAwait( false ); + if ( alt is not null ) return alt; this.Consume( 1 ); return TerminalInputEvent.FromKey( TerminalKey.Escape ); @@ -369,6 +371,23 @@ out int bytesConsumed } } + private async ValueTask TryReadLegacyAltAsync( CancellationToken cancellationToken ) { + while ( this.bufferedBytes.Count < 2 ) { + if ( !await this.ReadMoreWithinEscapeWindowAsync( cancellationToken ).ConfigureAwait( false ) ) return null; + } + // Keep control-family and SS3 introducers on the existing sequence/recovery path. + byte next = this.bufferedBytes[ 1 ]; + if ( next < 0x20 || next is 0x7f or (byte)'[' or (byte)']' or (byte)'P' or (byte)'_' or (byte)'^' or (byte)'X' or (byte)'O' or (byte)'\\' ) return null; + while ( true ) { + OperationStatus status = Rune.DecodeFromUtf8( this.bufferedBytes.Skip( 1 ).ToArray(), out Rune rune, out int consumed ); + if ( status == OperationStatus.Done ) { + this.Consume( consumed + 1 ); + return TerminalInputEvent.FromKey( TerminalKey.Character, TerminalKeyModifiers.Alt, rune ); + } + if ( status != OperationStatus.NeedMoreData || !await this.ReadMoreWithinEscapeWindowAsync( cancellationToken ).ConfigureAwait( false ) ) return null; + } + } + private async ValueTask ReadPasteEventAsync( CancellationToken cancellationToken ) { diff --git a/src/Platform/WindowsTerminalInput.cs b/src/Platform/WindowsTerminalInput.cs new file mode 100644 index 000000000..1d0c87e16 --- /dev/null +++ b/src/Platform/WindowsTerminalInput.cs @@ -0,0 +1,106 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +/// Reads Windows console UTF-16 directly, preserving Ctrl+Z and encoding it as UTF-8. +internal sealed class WindowsTerminalInput : ITerminalInput, IDisposable { + private readonly Func read; + private readonly Action cancel; + private readonly IDisposable? owner; + private readonly CancellationTokenSource stop = new(); + private readonly SemaphoreSlim gate = new( 1, 1 ); + private readonly Encoder encoder = Encoding.UTF8.GetEncoder(); + private readonly char[] characters = new char[ 256 ]; + private readonly byte[] bytes = new byte[ Encoding.UTF8.GetMaxByteCount( 256 ) ]; + private int offset, count; + private bool ended; + private int disposed; + + internal WindowsTerminalInput( Func read, Action cancel, IDisposable? owner = null ) { + this.read = read; this.cancel = cancel; this.owner = owner; + } + + internal static WindowsTerminalInput OpenStandardInput() { + if ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( "Direct Windows console input requires Windows." ); + if ( !SystemTerminalControlProvider.Instance.Observe( TerminalEndpoint.StandardInput ).GetRequiredValue().IsTerminal ) + throw new InvalidOperationException( "Standard input must identify an interactive console." ); + // A separate console handle gives cancellation ownership to this reader alone. + SafeFileHandle handle = CreateFile( "CONIN$", 0x80000000, 3, 0, 3, 0, 0 ); + if ( handle.IsInvalid ) { int error = Marshal.GetLastPInvokeError(); handle.Dispose(); throw NativeError( error ); } + return new WindowsTerminalInput( buffer => { + if ( !ReadConsole( handle, buffer, (uint)buffer.Length, out uint read, 0 ) ) throw NativeError( Marshal.GetLastPInvokeError() ); + return checked( (int)read ); + }, () => { _ = CancelIoEx( handle, 0 ); }, handle ); + } + + public async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default ) { + ObjectDisposedException.ThrowIf( Volatile.Read( ref this.disposed ) != 0, this ); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, this.stop.Token ); + CancellationToken token = linked.Token; + await this.gate.WaitAsync( token ).ConfigureAwait( false ); + try { + token.ThrowIfCancellationRequested(); + if ( buffer.IsEmpty ) return 0; + while ( this.count == 0 && !this.ended ) { + TaskCompletionSource canceled = new( TaskCreationOptions.RunContinuationsAsynchronously ); + using CancellationTokenRegistration registration = token.Register( () => canceled.TrySetResult( true ) ); + Task reading = Task.Run( () => this.read( this.characters ), CancellationToken.None ); + await Task.WhenAny( reading, canceled.Task ).ConfigureAwait( false ); + // Repeat until the native call ends: cancellation can race its initial entry. + while ( !reading.IsCompleted ) { + this.cancel(); await Task.WhenAny( reading, Task.Delay( 10 ) ).ConfigureAwait( false ); + } + int read; + try { read = await reading.ConfigureAwait( false ); } + catch ( IOException ) when ( token.IsCancellationRequested ) { throw new OperationCanceledException( token ); } + token.ThrowIfCancellationRequested(); + this.ended = read == 0; this.offset = 0; + this.count = this.encoder.GetBytes( this.characters, 0, read, this.bytes, 0, flush: this.ended ); + } + int copied = Math.Min( buffer.Length, this.count ); + this.bytes.AsMemory( this.offset, copied ).CopyTo( buffer ); this.offset += copied; this.count -= copied; + return copied; + } finally { this.gate.Release(); } + } + + public void Dispose() { + if ( Interlocked.Exchange( ref this.disposed, 1 ) != 0 ) return; + this.stop.Cancel(); this.gate.Wait(); + try { this.owner?.Dispose(); } finally { this.gate.Release(); } + } + + private static IOException NativeError( int error ) => new( $"Cannot read Windows console input: {new Win32Exception( error ).Message} (error {error})." ); +#pragma warning disable SYSLIB1054 // Match the existing platform interop boundary. + [DllImport( "kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true )] + private static extern SafeFileHandle CreateFile( string name, uint access, uint share, nint security, uint creation, uint flags, nint template ); + [DllImport( "kernel32.dll", EntryPoint = "ReadConsoleW", SetLastError = true )] + [return: MarshalAs( UnmanagedType.Bool )] + private static extern bool ReadConsole( SafeFileHandle handle, [Out, MarshalAs( UnmanagedType.LPArray, ArraySubType = UnmanagedType.U2, SizeParamIndex = 2 )] char[] buffer, uint length, out uint read, nint control ); + [DllImport( "kernel32.dll", SetLastError = true )] + [return: MarshalAs( UnmanagedType.Bool )] + private static extern bool CancelIoEx( SafeFileHandle handle, nint overlapped ); +#pragma warning restore SYSLIB1054 +} diff --git a/src/Session/TerminalSession.cs b/src/Session/TerminalSession.cs index cf537f68a..08ef62035 100644 --- a/src/Session/TerminalSession.cs +++ b/src/Session/TerminalSession.cs @@ -203,11 +203,13 @@ public static async ValueTask OpenAsync( ) { cancellationToken.ThrowIfCancellationRequested(); - PosixTerminalInput? ownedInput = null; + IDisposable? ownedInput = null; try { ITerminalInput input = OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() - ? ownedInput = PosixTerminalInput.OpenStandardInput() - : new StreamTerminalInput( Console.OpenStandardInput() ); + ? (ITerminalInput)( ownedInput = PosixTerminalInput.OpenStandardInput() ) + : OperatingSystem.IsWindows() + ? (ITerminalInput)( ownedInput = WindowsTerminalInput.OpenStandardInput() ) + : new StreamTerminalInput( Console.OpenStandardInput() ); TerminalSession session = await OpenAsync( SystemTerminalControlProvider.Instance, TerminalEndpoint.StandardInput, diff --git a/tests/Icod.Terminal.Tests/src/Graphics/TerminalRasterAnimationHardeningTests.cs b/tests/Icod.Terminal.Tests/src/Graphics/TerminalRasterAnimationHardeningTests.cs index 95a327361..2d6e9102c 100644 --- a/tests/Icod.Terminal.Tests/src/Graphics/TerminalRasterAnimationHardeningTests.cs +++ b/tests/Icod.Terminal.Tests/src/Graphics/TerminalRasterAnimationHardeningTests.cs @@ -266,6 +266,12 @@ await Assert.ThrowsAsync( ), resource.Animation.State ); + // A committed flush failure retains bounded ownership of its possible + // late response. Resolve that ownership before starting the short-timeout + // append used to prove that frame-sequence certainty was preserved. + transport.Publish( + Encoding.ASCII.GetBytes( "\u001b_Gi=77;OK\u001b\\" ) + ); transport.ReplyToNextFlush = true; Task> append = resource.Animation.AddFrameAsync( diff --git a/tests/Icod.Terminal.Tests/src/Input/TerminalInputDecoderTests.cs b/tests/Icod.Terminal.Tests/src/Input/TerminalInputDecoderTests.cs index 8f06e1f45..b600a3d7c 100644 --- a/tests/Icod.Terminal.Tests/src/Input/TerminalInputDecoderTests.cs +++ b/tests/Icod.Terminal.Tests/src/Input/TerminalInputDecoderTests.cs @@ -30,6 +30,32 @@ namespace Icod.Terminal.Tests.Input; /// Verifies incremental T08 byte decoding without touching the process terminal. /// public sealed class TerminalInputDecoderTests { + [Theory] + [InlineData( "f" )] + [InlineData( "F" )] + [InlineData( "界" )] + [InlineData( "🙂" )] + public async Task LegacyAltCharacterIsOneModifiedKeyEvenWhenFragmented( string text ) { + byte[] bytes = Encoding.UTF8.GetBytes( "\u001b" + text ); + TerminalInputDecoder decoder = CreateDecoder( new ScriptedTerminalInput( bytes.Select( value => new byte[] { value } ) ) ); + TerminalInputEvent key = await decoder.ReadAsync(); + Assert.Equal( TerminalInputEventKind.Key, key.Kind ); + Assert.Equal( TerminalKey.Character, key.Key ); + Assert.Equal( TerminalKeyModifiers.Alt, key.Modifiers ); + Assert.Equal( Rune.GetRuneAt( text, 0 ), key.Character ); + Assert.Equal( TerminalInputEventKind.EndOfInput, ( await decoder.ReadAsync() ).Kind ); + } + + [Fact] + public async Task ControlCAndControlZBytesAreKeysAndDoNotEndTheDecoder() { + TerminalInputDecoder decoder = CreateDecoder( new ScriptedTerminalInput( [ new byte[] { 0x03, 0x1a, (byte)'x' } ] ) ); + TerminalInputEvent copy = await decoder.ReadAsync(); + Assert.Equal( TerminalKeyModifiers.Control, copy.Modifiers ); Assert.Equal( new Rune( 'C' ), copy.Character ); + TerminalInputEvent undo = await decoder.ReadAsync(); + Assert.Equal( TerminalKeyModifiers.Control, undo.Modifiers ); Assert.Equal( new Rune( 'Z' ), undo.Character ); + Assert.Equal( new Rune( 'x' ), ( await decoder.ReadAsync() ).Character ); + } + [Fact] public async Task FragmentedTerminfoSequenceDecodesAsOneKey() { TerminalDescription terminal = new TerminalDescriptionBuilder( "fragmented" ) diff --git a/tests/Icod.Terminal.Tests/src/Input/WindowsTerminalInputTests.cs b/tests/Icod.Terminal.Tests/src/Input/WindowsTerminalInputTests.cs new file mode 100644 index 000000000..cfbc00851 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Input/WindowsTerminalInputTests.cs @@ -0,0 +1,63 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Input; +using System.Text; +using Xunit; + +public sealed class WindowsTerminalInputTests { + [Fact] + public async Task PreservesControlCControlZAndSplitSurrogatesAcrossSmallReads() { + Queue chunks = new( [ "\u0003\u001a", "\ud83d", "\ude42界", "" ] ); + using WindowsTerminalInput input = new( buffer => { string next = chunks.Dequeue(); next.CopyTo( 0, buffer, 0, next.Length ); return next.Length; }, () => { } ); + List received = []; byte[] one = new byte[ 1 ]; int count; + while ( ( count = await input.ReadAsync( one ) ) > 0 ) received.AddRange( one.Take( count ) ); + Assert.Equal( "\u0003\u001a🙂界", Encoding.UTF8.GetString( received.ToArray() ) ); + } + + [Fact] + public async Task CancellationWaitsForNativeReadAndAllowsAnotherRead() { + using ManualResetEventSlim started = new(); using ManualResetEventSlim canceled = new(); + int reads = 0; + using WindowsTerminalInput input = new( buffer => { + if ( Interlocked.Increment( ref reads ) == 1 ) { started.Set(); canceled.Wait(); throw new IOException( "Canceled native read" ); } + buffer[ 0 ] = 'x'; return 1; + }, () => canceled.Set() ); + using CancellationTokenSource stop = new(); byte[] buffer = new byte[ 4 ]; + Task read = input.ReadAsync( buffer, stop.Token ).AsTask(); + Assert.True( started.Wait( TimeSpan.FromSeconds( 5 ) ) ); stop.Cancel(); + await Assert.ThrowsAnyAsync( () => read.WaitAsync( TimeSpan.FromSeconds( 5 ) ) ); + Assert.Equal( 1, await input.ReadAsync( buffer ) ); Assert.Equal( (byte)'x', buffer[ 0 ] ); + } + + [Fact] + public async Task DisposalCancelsOutstandingReadBeforeClosingOwner() { + using ManualResetEventSlim started = new(); using ManualResetEventSlim canceled = new(); + bool finished = false; Owner owner = new( () => Assert.True( finished ) ); + WindowsTerminalInput input = new( _ => { started.Set(); canceled.Wait(); finished = true; throw new IOException(); }, () => canceled.Set(), owner ); + Task read = input.ReadAsync( new byte[ 4 ] ).AsTask(); + Assert.True( started.Wait( TimeSpan.FromSeconds( 5 ) ) ); input.Dispose(); input.Dispose(); + await Assert.ThrowsAnyAsync( () => read ); Assert.Equal( 1, owner.Disposals ); + } + private sealed class Owner( Action onDispose ) : IDisposable { + internal int Disposals { get; private set; } + public void Dispose() { onDispose(); this.Disposals++; } + } +} diff --git a/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs b/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs index 6293e7028..d7221bbad 100644 --- a/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs +++ b/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs @@ -28,7 +28,7 @@ namespace Icod.Terminal.Tests.Packaging; /// public sealed class StableReleaseIdentityTests { [Fact] - public void MainDistributionPinsOneTwentyNineStableIdentity() { + public void MainDistributionPinsOneTwentyNinePatchOneStableIdentity() { string root = FindRepositoryRoot(); XDocument properties = XDocument.Load( Path.Combine( root, "Directory.Build.props" ) @@ -51,42 +51,42 @@ public void MainDistributionPinsOneTwentyNineStableIdentity() { Path.Combine( root, "CHANGELOG.md" ) ); string releaseNotes = File.ReadAllText( - Path.Combine( root, "docs", "releases", "1.29.0.md" ) + Path.Combine( root, "docs", "releases", "1.29.1.md" ) ); - Assert.Equal( "1.29.0", versionPrefix ); + Assert.Equal( "1.29.1", versionPrefix ); Assert.True( null == versionSuffix || String.IsNullOrWhiteSpace( versionSuffix.Value ) ); Assert.Contains( - "EXPECTED_VERSION: 1.29.0", + "EXPECTED_VERSION: 1.29.1", workflow, StringComparison.Ordinal ); Assert.Contains( - "Stable release: **1.29.0**.", + "Stable release: **1.29.1**.", readme, StringComparison.Ordinal ); Assert.Contains( - "dotnet add package Icod.Terminal --version 1.29.0", + "dotnet add package Icod.Terminal --version 1.29.1", readme, StringComparison.Ordinal ); Assert.Contains( - "https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.0.md", + "https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.1.md", readme, StringComparison.Ordinal ); Assert.Contains( - "## 1.29.0", + "## 1.29.1", changelog, StringComparison.Ordinal ); Assert.True( File.Exists( - Path.Combine( root, "docs", "releases", "1.29.0.md" ) + Path.Combine( root, "docs", "releases", "1.29.1.md" ) ) ); foreach ( string requiredReleaseNoteText in new[] { diff --git a/tests/Icod.Terminal.WindowsInput.Smoke/Icod.Terminal.WindowsInput.Smoke.csproj b/tests/Icod.Terminal.WindowsInput.Smoke/Icod.Terminal.WindowsInput.Smoke.csproj new file mode 100644 index 000000000..021bb9904 --- /dev/null +++ b/tests/Icod.Terminal.WindowsInput.Smoke/Icod.Terminal.WindowsInput.Smoke.csproj @@ -0,0 +1,25 @@ + + + + + + Exenet10.0false + + diff --git a/tests/Icod.Terminal.WindowsInput.Smoke/Program.cs b/tests/Icod.Terminal.WindowsInput.Smoke/Program.cs new file mode 100644 index 000000000..fc0b4fdd4 --- /dev/null +++ b/tests/Icod.Terminal.WindowsInput.Smoke/Program.cs @@ -0,0 +1,79 @@ +/* + Icod.Terminal.WindowsInput.Smoke + Validation utility for Icod.Terminal release and integration contracts. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Icod.Terminal; +using Icod.TermInfo; +using Microsoft.Win32.SafeHandles; + +// Run in a separate process: a private console must never alter the CI runner's console. +if ( !OperatingSystem.IsWindows() ) return 0; +Native.FreeConsole(); +Check( Native.AllocConsole(), "AllocConsole" ); +using SafeFileHandle handle = Native.CreateFile( "CONIN$", 0xc0000000, 3, 0, 3, 0, 0 ); +Check( !handle.IsInvalid, "CONIN$" ); +Check( Native.SetStdHandle( -10, handle.DangerousGetHandle() ), "SetStdHandle" ); +using CancellationTokenSource deadline = new( TimeSpan.FromSeconds( 20 ) ); +await using ( TerminalSession session = await TerminalSession.OpenAsync( new TerminalSessionOptions { + InputMode = TerminalInputMode.Raw, EchoInput = false, RequireInteractiveOutput = false, + ConfigureOutput = false, ObserveLifecycleEvents = false, TerminalOverride = TerminalProfiles.Xterm +}, deadline.Token ) ) { + Send( '\u0003', 0x43, 8 ); Send( '\u001a', 0x5a, 8 ); Send( 'x', 0x58 ); Send( '\u001b', 0x1b ); Send( 'f', 0x46 ); Send( '界', 0 ); + TerminalInputEvent copy = await Read( session, deadline.Token ); + if ( copy.Kind != TerminalInputEventKind.Key || copy.Character != new Rune( 'C' ) || copy.Modifiers != TerminalKeyModifiers.Control ) throw new InvalidOperationException( "Ctrl+C was not a control key." ); + TerminalInputEvent undo = await Read( session, deadline.Token ); + if ( undo.Kind != TerminalInputEventKind.Key || undo.Character != new Rune( 'Z' ) || undo.Modifiers != TerminalKeyModifiers.Control ) throw new InvalidOperationException( "Ctrl+Z was not a control key." ); + if ( ( await Read( session, deadline.Token ) ).Character != new Rune( 'x' ) ) throw new InvalidOperationException( "Input ended after Ctrl+Z." ); + TerminalInputEvent alt = await Read( session, deadline.Token ); + if ( alt.Character != new Rune( 'f' ) || alt.Modifiers != TerminalKeyModifiers.Alt ) throw new InvalidOperationException( "Alt+F was not preserved." ); + if ( ( await Read( session, deadline.Token ) ).Character != new Rune( '界' ) ) throw new InvalidOperationException( "Unicode input was not preserved." ); + // A pending native read must not hang disposal after the caller stops waiting. + using CancellationTokenSource cancel = new( TimeSpan.FromMilliseconds( 100 ) ); + if ( ( await session.ReadEventAsync( cancel.Token ) ).Kind != TerminalEventKind.Cancelled ) throw new InvalidOperationException( "Expected canceled wait." ); +} +return 0; + +void Send( char value, ushort key, uint modifiers = 0 ) { + Native.InputRecord[] records = [ new() { EventType = 1, KeyDown = 1, RepeatCount = 1, VirtualKey = key, Character = value, ControlState = modifiers } ]; + Check( Native.WriteConsoleInput( handle, records, 1, out uint written ) && written == 1, "WriteConsoleInput" ); +} +static async Task Read( TerminalSession session, CancellationToken token ) => ( await session.ReadEventAsync( token ) ).Input ?? throw new InvalidOperationException( "Expected input before deadline." ); +static void Check( bool result, string operation ) { if ( !result ) throw new Win32Exception( Marshal.GetLastPInvokeError(), operation ); } + +internal static class Native { + [StructLayout( LayoutKind.Explicit, Size = 20, CharSet = CharSet.Unicode )] + internal struct InputRecord { + [FieldOffset( 0 )] public ushort EventType; + [FieldOffset( 4 )] public int KeyDown; + [FieldOffset( 8 )] public ushort RepeatCount; + [FieldOffset( 10 )] public ushort VirtualKey; + [FieldOffset( 14 )] public char Character; + [FieldOffset( 16 )] public uint ControlState; + } +#pragma warning disable SYSLIB1054 + [DllImport( "kernel32.dll", SetLastError = true )] [return: MarshalAs( UnmanagedType.Bool )] internal static extern bool AllocConsole(); + [DllImport( "kernel32.dll", SetLastError = true )] [return: MarshalAs( UnmanagedType.Bool )] internal static extern bool FreeConsole(); + [DllImport( "kernel32.dll", SetLastError = true )] [return: MarshalAs( UnmanagedType.Bool )] internal static extern bool SetStdHandle( int kind, nint handle ); + [DllImport( "kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true )] internal static extern SafeFileHandle CreateFile( string name, uint access, uint share, nint security, uint creation, uint flags, nint template ); + [DllImport( "kernel32.dll", EntryPoint = "WriteConsoleInputW", SetLastError = true )] [return: MarshalAs( UnmanagedType.Bool )] internal static extern bool WriteConsoleInput( SafeFileHandle handle, InputRecord[] records, uint count, out uint written ); +#pragma warning restore SYSLIB1054 +} diff --git a/tools/unix-input-smoke/Program.cs b/tools/unix-input-smoke/Program.cs index efd44e593..1ec2431f2 100644 --- a/tools/unix-input-smoke/Program.cs +++ b/tools/unix-input-smoke/Program.cs @@ -39,7 +39,7 @@ You should have received a copy of the GNU General Public License foreach ( TerminalInputMode mode in new[] { TerminalInputMode.CBreak, TerminalInputMode.Raw } ) { await RunParentAsync( mode ); } -Console.WriteLine( "Unix standard-input regression passed: immediate keys, UTF-8, query routing, no echo, cancellation, restoration and reopen." ); +Console.WriteLine( "Unix standard-input regression passed: immediate keys, raw Ctrl+C/Ctrl+Z, UTF-8, query routing, no echo, cancellation, restoration and reopen." ); return 0; static async Task RunChildAsync( TerminalInputMode mode ) { @@ -49,6 +49,11 @@ static async Task RunChildAsync( TerminalInputMode mode ) { await session.WriteTextAsync( "READY|" ); await ExpectCharacterAsync( session, 'd' ); await session.WriteTextAsync( "KEY|" ); + if ( TerminalInputMode.Raw == mode ) { + await ExpectControlCharacterAsync( session, 'C' ); + await ExpectControlCharacterAsync( session, 'Z' ); + await session.WriteTextAsync( "CONTROL|" ); + } await ExpectCharacterAsync( session, 'é' ); await session.WriteTextAsync( "UTF8|" ); _ = await session.QueryPrimaryDeviceAttributesAsync( TimeSpan.FromSeconds( 3 ) ); @@ -81,6 +86,15 @@ static async Task ExpectCharacterAsync( TerminalSession session, char expected ) $"Expected immediate '{expected}' without Enter; received {result.Kind}." ); } +static async Task ExpectControlCharacterAsync( TerminalSession session, char expected ) { + TerminalEvent result = await session.ReadEventAsync( TimeSpan.FromSeconds( 3 ) ); + Require( result.Input?.Kind == TerminalInputEventKind.Key + && result.Input.Key == TerminalKey.Character + && result.Input.Character?.Value == expected + && result.Input.Modifiers == TerminalKeyModifiers.Control, + $"Expected Ctrl+{expected} as an application key; received {result.Kind}." ); +} + static async Task RunParentAsync( TerminalInputMode mode ) { string assembly = Assembly.GetExecutingAssembly().Location; string failurePath = Path.GetTempFileName(); @@ -119,6 +133,11 @@ static async Task RunParentAsync( TerminalInputMode mode ) { } await SendAsync( "d" ); await ExpectAsync( "KEY|" ); + if ( TerminalInputMode.Raw == mode ) { + await process.StandardInput.BaseStream.WriteAsync( new byte[] { 0x03, 0x1a }, deadline.Token ); + await process.StandardInput.BaseStream.FlushAsync( deadline.Token ); + await ExpectAsync( "CONTROL|" ); + } // Split a UTF-8 scalar across writes to exercise the byte decoder. await process.StandardInput.BaseStream.WriteAsync( new byte[] { 0xc3 }, deadline.Token ); await process.StandardInput.BaseStream.FlushAsync( deadline.Token );