From 8d041dca19d43711859c4c59eb1cb62fb43e86ae Mon Sep 17 00:00:00 2001 From: Zoey Riordan Date: Wed, 6 Nov 2019 13:10:35 -0800 Subject: [PATCH 1/4] add more comprehensive tests --- src/Pty.Net.Tests/ExitTests.cs | 93 ++++++++++++++ src/Pty.Net.Tests/PtyTests.cs | 117 +----------------- src/Pty.Net.Tests/ResizeTests.cs | 70 +++++++++++ src/Pty.Net.Tests/Utilities.cs | 89 +++++++++++++ src/Pty.Net/Windows/NativeMethods.cs | 8 +- .../Windows/PseudoConsoleConnection.cs | 10 ++ src/Pty.Net/Windows/PtyProvider.cs | 2 +- 7 files changed, 272 insertions(+), 117 deletions(-) create mode 100644 src/Pty.Net.Tests/ExitTests.cs create mode 100644 src/Pty.Net.Tests/ResizeTests.cs create mode 100644 src/Pty.Net.Tests/Utilities.cs diff --git a/src/Pty.Net.Tests/ExitTests.cs b/src/Pty.Net.Tests/ExitTests.cs new file mode 100644 index 0000000..c85a4c8 --- /dev/null +++ b/src/Pty.Net.Tests/ExitTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Pty.Net.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + using Xunit; + + public class ExitTests + { + [Fact] + public async Task SuccessfulExitTest() + { + var completionSource = new TaskCompletionSource(); + + Utilities.TimeoutToken.Register(() => + { + completionSource.SetCanceled(); + }); + + using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + terminal.ProcessExited += (sender, e) => + { + completionSource.SetResult(e.ExitCode); + }; + + using var writer = new StreamWriter(terminal.WriterStream); + using var reader = new StreamReader(terminal.ReaderStream); + + await writer.WriteAsync("exit 0\r"); + await writer.FlushAsync(); + + var exitCode = await completionSource.Task; + + Assert.Equal(0, exitCode); + Assert.Equal(0, terminal.ExitCode); + } + + [Fact] + public async Task UnsuccessfulExitTest() + { + var completionSource = new TaskCompletionSource(); + + Utilities.TimeoutToken.Register(() => + { + completionSource.SetCanceled(); + }); + + using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + terminal.ProcessExited += (sender, e) => + { + completionSource.SetResult(e.ExitCode); + }; + + using var writer = new StreamWriter(terminal.WriterStream); + using var reader = new StreamReader(terminal.ReaderStream); + + await writer.WriteAsync("exit 1\r"); + await writer.FlushAsync(); + + var exitCode = await completionSource.Task; + + Assert.Equal(1, exitCode); + Assert.Equal(1, terminal.ExitCode); + } + + [Fact] + public async Task ForceKillTest() + { + var completionSource = new TaskCompletionSource(); + + Utilities.TimeoutToken.Register(() => + { + completionSource.SetCanceled(); + }); + + using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + terminal.ProcessExited += (sender, e) => + { + completionSource.SetResult(e.ExitCode); + }; + + terminal.Kill(); + + await completionSource.Task; + } + } +} diff --git a/src/Pty.Net.Tests/PtyTests.cs b/src/Pty.Net.Tests/PtyTests.cs index 693d23c..8b5315d 100644 --- a/src/Pty.Net.Tests/PtyTests.cs +++ b/src/Pty.Net.Tests/PtyTests.cs @@ -16,126 +16,19 @@ namespace Pty.Net.Tests public class PtyTests { - private static readonly int TestTimeoutMs = Debugger.IsAttached ? 300_000 : 5_000; - - private CancellationToken TimeoutToken { get; } = new CancellationTokenSource(TestTimeoutMs).Token; - [Fact] public async Task ConnectToTerminal() { - const uint CtrlCExitCode = 0xC000013A; - - var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); const string Data = "abc✓ЖЖЖ①Ⅻㄨㄩ 啊阿鼾齄丂丄狚狛狜狝﨨﨩ˊˋ˙– ⿻〇㐀㐁䶴䶵"; - string app = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.SystemDirectory, "cmd.exe") : "sh"; - var options = new PtyOptions - { - Name = "Custom terminal", - Cols = Data.Length + Environment.CurrentDirectory.Length + 50, - Rows = 25, - Cwd = Environment.CurrentDirectory, - App = app, - Environment = new Dictionary() - { - { "FOO", "bar" }, - { "Bazz", string.Empty }, - }, - }; - - IPtyConnection terminal = await PtyProvider.SpawnAsync(options, this.TimeoutToken); - - var processExitedTcs = new TaskCompletionSource(); - terminal.ProcessExited += (sender, e) => processExitedTcs.TrySetResult((uint)terminal.ExitCode); - - string GetTerminalExitCode() => - processExitedTcs.Task.IsCompleted ? $". Terminal process has exited with exit code {processExitedTcs.Task.GetAwaiter().GetResult()}." : string.Empty; - - var firstOutput = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var firstDataFound = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var output = string.Empty; - var checkTerminalOutputAsync = Task.Run(async () => - { - var buffer = new byte[4096]; - var ansiRegex = new Regex( - @"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))"); - - while (!this.TimeoutToken.IsCancellationRequested && !processExitedTcs.Task.IsCompleted) - { - int count = await terminal.ReaderStream.ReadAsync(buffer, 0, buffer.Length, this.TimeoutToken); - if (count == 0) - { - break; - } - - firstOutput.TrySetResult(null); - - output += encoding.GetString(buffer, 0, count); - output = output.Replace("\r", string.Empty).Replace("\n", string.Empty); - output = ansiRegex.Replace(output, string.Empty); - - var index = output.IndexOf(Data); - if (index >= 0) - { - firstDataFound.TrySetResult(null); - if (index <= output.Length - (2 * Data.Length) - && output.IndexOf(Data, index + Data.Length) >= 0) - { - return true; - } - } - } - - firstOutput.TrySetCanceled(); - firstDataFound.TrySetCanceled(); - return false; - }); - - try - { - await firstOutput.Task; - } - catch (OperationCanceledException exception) - { - throw new InvalidOperationException( - $"Could not get any output from terminal{GetTerminalExitCode()}", - exception); - } - - try - { - byte[] commandBuffer = encoding.GetBytes("echo " + Data); - await terminal.WriterStream.WriteAsync(commandBuffer, 0, commandBuffer.Length, this.TimeoutToken); - await terminal.WriterStream.FlushAsync(); - - await firstDataFound.Task; - - await terminal.WriterStream.WriteAsync(new byte[] { 0x0D }, 0, 1, this.TimeoutToken); // Enter - await terminal.WriterStream.FlushAsync(); - - Assert.True(await checkTerminalOutputAsync); - } - catch (Exception exception) - { - throw new InvalidOperationException( - $"Could not get expected data from terminal.{GetTerminalExitCode()} Actual terminal output:\n{output}", - exception); - } - - terminal.Resize(40, 10); + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); - terminal.Dispose(); + using var writer = new StreamWriter(terminal.WriterStream); - using (this.TimeoutToken.Register(() => processExitedTcs.TrySetCanceled(this.TimeoutToken))) - { - uint exitCode = await processExitedTcs.Task; - Assert.True( - exitCode == CtrlCExitCode || // WinPty terminal exit code. - exitCode == 1 || // Pseudo Console exit code on Win 10. - exitCode == 0); // pty exit code on *nix. - } + await writer.WriteAsync($"echo {Data}\r"); + await writer.FlushAsync(); - Assert.True(terminal.WaitForExit(TestTimeoutMs)); + Assert.True(await Utilities.FindOutput(terminal.ReaderStream, Data)); } } } diff --git a/src/Pty.Net.Tests/ResizeTests.cs b/src/Pty.Net.Tests/ResizeTests.cs new file mode 100644 index 0000000..bdeea9d --- /dev/null +++ b/src/Pty.Net.Tests/ResizeTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Pty.Net.Tests +{ + using System; + using System.IO; + using System.Runtime.InteropServices; + using System.Threading.Tasks; + using Xunit; + + public class ResizeTests + { + [Fact] + public async Task ZeroSizeTest() + { + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + + terminal.Resize(0, 0); + } + + [Fact] + public async Task NegativeSizeTest() + { + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + + Assert.Throws(() => terminal.Resize(80, -25)); + Assert.Throws(() => terminal.Resize(-80, 25)); + Assert.Throws(() => terminal.Resize(-80, -25)); + } + + [Fact] + public async Task LargeInvalidSizeTest() + { + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + + Assert.Throws(() => terminal.Resize(short.MaxValue + 1, 25)); + Assert.Throws(() => terminal.Resize(80, short.MaxValue + 1)); + Assert.Throws(() => terminal.Resize(short.MaxValue + 1, short.MaxValue + 1)); + } + + [Fact] + public async Task LargeValidSizeTest() + { + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + + terminal.Resize(short.MaxValue, 25); + terminal.Resize(80, short.MaxValue); + terminal.Resize(short.MaxValue, short.MaxValue); + } + + [Fact] + public async Task ActuallyResizesTest() + { + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + using var writer = new StreamWriter(terminal.WriterStream); + + terminal.Resize(72, 13); + + var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "mode\r" + : "echo -n Lines: && tput lines && echo -n Columns: && tput cols\r"; + + await writer.WriteAsync(command); + await writer.FlushAsync(); + + Assert.True(await Utilities.FindOutput(terminal.ReaderStream, "Lines:\\s*13\\s*Columns:\\s*72", Utilities.TimeoutToken)); + } + } +} diff --git a/src/Pty.Net.Tests/Utilities.cs b/src/Pty.Net.Tests/Utilities.cs new file mode 100644 index 0000000..9543f90 --- /dev/null +++ b/src/Pty.Net.Tests/Utilities.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Pty.Net.Tests +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Runtime.InteropServices; + using System.Text; + using System.Text.RegularExpressions; + using System.Threading; + using System.Threading.Tasks; + + internal static class Utilities + { + public static readonly int TestTimeoutMs = Debugger.IsAttached ? 300_000 : 5_000; + + public static CancellationToken TimeoutToken { get; } = new CancellationTokenSource(TestTimeoutMs).Token; + + public static async Task CreateConnectionAsync(CancellationToken token = default) + { + string app = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.SystemDirectory, "cmd.exe") : "sh"; + var options = new PtyOptions + { + Name = "Custom terminal", + Cols = 80, + Rows = 25, + Cwd = Environment.CurrentDirectory, + App = app, + Environment = new Dictionary() + { + { "FOO", "bar" }, + { "Bazz", string.Empty }, + }, + }; + + return await PtyProvider.SpawnAsync(options, token); + } + + public static async Task FindOutput(Stream terminalReadStream, string search, CancellationToken token = default) + { + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var buffer = new byte[4096]; + var ansiRegex = new Regex( + @"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))"); + var searchRegex = new Regex(search); + var output = string.Empty; + + while (!token.IsCancellationRequested) + { + int count = await terminalReadStream.ReadAsync(buffer, 0, buffer.Length).WithCancellation(token); + if (count == 0) + { + break; + } + + output += encoding.GetString(buffer, 0, count); + output = output.Replace("\r", string.Empty).Replace("\n", string.Empty); + output = ansiRegex.Replace(output, string.Empty); + + if (searchRegex.IsMatch(output)) + { + return true; + } + } + + return false; + } + + private static async Task WithCancellation(this Task task, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + using (cancellationToken.Register(s => ((TaskCompletionSource)s).TrySetResult(true), tcs)) + { + if (task != await Task.WhenAny(task, tcs.Task).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + + // Rethrow any fault/cancellation exception, even if we awaited above. + // But if we skipped the above if branch, this will actually yield + // on an incompleted task. + return await task.ConfigureAwait(false); + } + } +} diff --git a/src/Pty.Net/Windows/NativeMethods.cs b/src/Pty.Net/Windows/NativeMethods.cs index f6109eb..e4fbdde 100644 --- a/src/Pty.Net/Windows/NativeMethods.cs +++ b/src/Pty.Net/Windows/NativeMethods.cs @@ -175,13 +175,13 @@ internal struct STARTUPINFO [StructLayout(LayoutKind.Sequential)] internal struct Coord { - public ushort X; - public ushort Y; + public short X; + public short Y; public Coord(int x, int y) { - this.X = (ushort)x; - this.Y = (ushort)y; + this.X = checked((short)x); + this.Y = checked((short)y); } } diff --git a/src/Pty.Net/Windows/PseudoConsoleConnection.cs b/src/Pty.Net/Windows/PseudoConsoleConnection.cs index c16fbc7..a3f3753 100644 --- a/src/Pty.Net/Windows/PseudoConsoleConnection.cs +++ b/src/Pty.Net/Windows/PseudoConsoleConnection.cs @@ -75,6 +75,16 @@ public void Kill() /// public void Resize(int cols, int rows) { + if (cols < 0) + { + throw new ArgumentOutOfRangeException(nameof(cols)); + } + + if (rows < 0) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + int hr = ResizePseudoConsole(this.handles.PseudoConsoleHandle, new Coord(cols, rows)); if (hr != S_OK) { diff --git a/src/Pty.Net/Windows/PtyProvider.cs b/src/Pty.Net/Windows/PtyProvider.cs index 63ffd63..f077eca 100644 --- a/src/Pty.Net/Windows/PtyProvider.cs +++ b/src/Pty.Net/Windows/PtyProvider.cs @@ -322,7 +322,7 @@ private Task StartPseudoConsoleAsync( throw new InvalidOperationException("Could not create an anonymous pipe", new Win32Exception()); } - var coord = new Coord(options.Cols, options.Rows); + var coord = new Coord(checked((short)options.Cols), checked((short)options.Rows)); var pseudoConsoleHandle = new SafePseudoConsoleHandle(); int hr; RuntimeHelpers.PrepareConstrainedRegions(); From 98124ccaa9cc892fb61ff79e684fceb241fb2cab Mon Sep 17 00:00:00 2001 From: Zoey Riordan Date: Wed, 6 Nov 2019 13:46:32 -0800 Subject: [PATCH 2/4] add argument validation to mac and linux --- README.md | 16 +- azure-pipelines/dotnet.yml | 2 +- src/Pty.Net.Tests/ExitTests.cs | 39 +---- src/Pty.Net.Tests/Properties/AssemblyInfo.cs | 6 + src/Pty.Net.Tests/PtyTests.cs | 19 ++- src/Pty.Net.Tests/ResizeTests.cs | 35 ++-- src/Pty.Net.Tests/Utilities.cs | 169 ++++++++++++++++++- src/Pty.Net/Linux/NativeMethods.cs | 20 ++- src/Pty.Net/Linux/PtyConnection.cs | 20 ++- src/Pty.Net/Mac/NativeMethods.cs | 6 +- src/Pty.Net/Mac/PtyConnection.cs | 2 +- src/Pty.Net/Unix/PtyConnection.cs | 21 ++- src/Pty.Net/Unix/PtyStream.cs | 2 +- 13 files changed, 289 insertions(+), 68 deletions(-) create mode 100644 src/Pty.Net.Tests/Properties/AssemblyInfo.cs diff --git a/README.md b/README.md index a816cd0..f86d20e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,21 @@ Pty.Net is a cross platform, .NET library providing idiomatic bindings for `fork Pty.Net supports Linux, macOS, and Windows. On versions of windows older than 1809 the [winpty](https://github.com/rprichard/winpty) is used. For windows 1809+ this library ships a side-by-side copy of conhost. -# Contributing +pty bindings for .NET languages. This allows you to spawn processes connected to a pseudoterminal. + +This is useful for: + +- Writing a terminal emulator. +- Convincing programs that you are a terminal in order to get control sequences from their output. + +## Supported Platforms + +Pty.Net supports Windows, MacOS, and Linux and can run on the following .NET runtimes: + +- .NET 4.7.2 +- .NET Standard 2.0 + +## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us diff --git a/azure-pipelines/dotnet.yml b/azure-pipelines/dotnet.yml index c9aa4c0..6007d0e 100644 --- a/azure-pipelines/dotnet.yml +++ b/azure-pipelines/dotnet.yml @@ -9,4 +9,4 @@ steps: command: test arguments: --no-build -c $(BuildConfiguration) -v n /p:CollectCoverage=true testRunTitle: netcoreapp2.2-$(Agent.JobName) - workingDirectory: src + workingDirectory: src \ No newline at end of file diff --git a/src/Pty.Net.Tests/ExitTests.cs b/src/Pty.Net.Tests/ExitTests.cs index c85a4c8..e693316 100644 --- a/src/Pty.Net.Tests/ExitTests.cs +++ b/src/Pty.Net.Tests/ExitTests.cs @@ -13,72 +13,49 @@ namespace Pty.Net.Tests public class ExitTests { - [Fact] + [Fact(Skip = "Diagnosing issues on mac/linux")] public async Task SuccessfulExitTest() { var completionSource = new TaskCompletionSource(); - Utilities.TimeoutToken.Register(() => - { - completionSource.SetCanceled(); - }); - using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); terminal.ProcessExited += (sender, e) => { completionSource.SetResult(e.ExitCode); }; - using var writer = new StreamWriter(terminal.WriterStream); - using var reader = new StreamReader(terminal.ReaderStream); + await terminal.RunCommand("exit", Utilities.TimeoutToken); - await writer.WriteAsync("exit 0\r"); - await writer.FlushAsync(); - - var exitCode = await completionSource.Task; + var exitCode = await completionSource.Task.WithCancellation(Utilities.TimeoutToken); Assert.Equal(0, exitCode); Assert.Equal(0, terminal.ExitCode); } - [Fact] + [Fact(Skip = "Diagnosing issues on mac/linux")] public async Task UnsuccessfulExitTest() { var completionSource = new TaskCompletionSource(); - Utilities.TimeoutToken.Register(() => - { - completionSource.SetCanceled(); - }); - using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); terminal.ProcessExited += (sender, e) => { completionSource.SetResult(e.ExitCode); }; - using var writer = new StreamWriter(terminal.WriterStream); - using var reader = new StreamReader(terminal.ReaderStream); - - await writer.WriteAsync("exit 1\r"); - await writer.FlushAsync(); + await terminal.RunCommand("exit 1", Utilities.TimeoutToken); - var exitCode = await completionSource.Task; + var exitCode = await completionSource.Task.WithCancellation(Utilities.TimeoutToken); Assert.Equal(1, exitCode); Assert.Equal(1, terminal.ExitCode); } - [Fact] + [Fact(Skip = "Diagnosing issues on mac/linux")] public async Task ForceKillTest() { var completionSource = new TaskCompletionSource(); - Utilities.TimeoutToken.Register(() => - { - completionSource.SetCanceled(); - }); - using IPtyConnection terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); terminal.ProcessExited += (sender, e) => { @@ -87,7 +64,7 @@ public async Task ForceKillTest() terminal.Kill(); - await completionSource.Task; + await completionSource.Task.WithCancellation(Utilities.TimeoutToken); } } } diff --git a/src/Pty.Net.Tests/Properties/AssemblyInfo.cs b/src/Pty.Net.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..144dfa9 --- /dev/null +++ b/src/Pty.Net.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/Pty.Net.Tests/PtyTests.cs b/src/Pty.Net.Tests/PtyTests.cs index 8b5315d..a5ac7b7 100644 --- a/src/Pty.Net.Tests/PtyTests.cs +++ b/src/Pty.Net.Tests/PtyTests.cs @@ -16,19 +16,28 @@ namespace Pty.Net.Tests public class PtyTests { - [Fact] + [Fact(Skip = "Diagnosing issues on mac/linux")] public async Task ConnectToTerminal() { const string Data = "abc✓ЖЖЖ①Ⅻㄨㄩ 啊阿鼾齄丂丄狚狛狜狝﨨﨩ˊˋ˙– ⿻〇㐀㐁䶴䶵"; using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); - using var writer = new StreamWriter(terminal.WriterStream); - - await writer.WriteAsync($"echo {Data}\r"); - await writer.FlushAsync(); + await terminal.RunCommand($"echo {Data}", Utilities.TimeoutToken); Assert.True(await Utilities.FindOutput(terminal.ReaderStream, Data)); } + + [Fact] + public async Task OldConnectToTerminal() + { + const string Data = "abc✓ЖЖЖ①Ⅻㄨㄩ 啊阿鼾齄丂丄狚狛狜狝﨨﨩ˊˋ˙– ⿻〇㐀㐁䶴䶵"; + + using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); + + var output = await Utilities.RunAndFind(terminal, $"echo {Data}", Data, Utilities.TimeoutToken); + + Assert.NotNull(output); + } } } diff --git a/src/Pty.Net.Tests/ResizeTests.cs b/src/Pty.Net.Tests/ResizeTests.cs index bdeea9d..1c61b85 100644 --- a/src/Pty.Net.Tests/ResizeTests.cs +++ b/src/Pty.Net.Tests/ResizeTests.cs @@ -6,6 +6,7 @@ namespace Pty.Net.Tests using System; using System.IO; using System.Runtime.InteropServices; + using System.Text.RegularExpressions; using System.Threading.Tasks; using Xunit; @@ -34,9 +35,13 @@ public async Task LargeInvalidSizeTest() { using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); - Assert.Throws(() => terminal.Resize(short.MaxValue + 1, 25)); - Assert.Throws(() => terminal.Resize(80, short.MaxValue + 1)); - Assert.Throws(() => terminal.Resize(short.MaxValue + 1, short.MaxValue + 1)); + var size = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? short.MaxValue + 1 + : ushort.MaxValue + 1; + + Assert.Throws(() => terminal.Resize(size, 25)); + Assert.Throws(() => terminal.Resize(80, size)); + Assert.Throws(() => terminal.Resize(size, size)); } [Fact] @@ -44,27 +49,31 @@ public async Task LargeValidSizeTest() { using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); - terminal.Resize(short.MaxValue, 25); - terminal.Resize(80, short.MaxValue); - terminal.Resize(short.MaxValue, short.MaxValue); + var size = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? (int)short.MaxValue + : (int)ushort.MaxValue; + + terminal.Resize(size, 25); + terminal.Resize(80, size); + terminal.Resize(size, size); } [Fact] public async Task ActuallyResizesTest() { using var terminal = await Utilities.CreateConnectionAsync(Utilities.TimeoutToken); - using var writer = new StreamWriter(terminal.WriterStream); - terminal.Resize(72, 13); var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "mode\r" - : "echo -n Lines: && tput lines && echo -n Columns: && tput cols\r"; + ? "mode" + : "echo Lines: && tput lines && echo Columns: && tput cols"; + + var output = await Utilities.RunAndFind(terminal, command, "Lines:\\D*(?\\d+).*Columns:\\D*(?\\d+)"); - await writer.WriteAsync(command); - await writer.FlushAsync(); + var metches = Regex.Match(output, "Lines:\\D*(?\\d+).*Columns:\\D*(?\\d+)"); - Assert.True(await Utilities.FindOutput(terminal.ReaderStream, "Lines:\\s*13\\s*Columns:\\s*72", Utilities.TimeoutToken)); + Assert.Equal("13", metches.Groups["rows"].Value); + Assert.Equal("72", metches.Groups["cols"].Value); } } } diff --git a/src/Pty.Net.Tests/Utilities.cs b/src/Pty.Net.Tests/Utilities.cs index 9543f90..213d95b 100644 --- a/src/Pty.Net.Tests/Utilities.cs +++ b/src/Pty.Net.Tests/Utilities.cs @@ -15,9 +15,9 @@ namespace Pty.Net.Tests internal static class Utilities { - public static readonly int TestTimeoutMs = Debugger.IsAttached ? 300_000 : 5_000; + public static readonly int TestTimeoutMs = Debugger.IsAttached ? 300_000 : 10_000; - public static CancellationToken TimeoutToken { get; } = new CancellationTokenSource(TestTimeoutMs).Token; + public static CancellationToken TimeoutToken => new CancellationTokenSource(TestTimeoutMs).Token; public static async Task CreateConnectionAsync(CancellationToken token = default) { @@ -39,12 +39,83 @@ public static async Task CreateConnectionAsync(CancellationToken return await PtyProvider.SpawnAsync(options, token); } + public static async Task RunCommand(this IPtyConnection terminal, string command, CancellationToken token = default) + { + var processExitedTcs = new TaskCompletionSource(); + terminal.ProcessExited += (sender, e) => processExitedTcs.TrySetResult((uint)terminal.ExitCode); + string GetTerminalExitCode() => + processExitedTcs.Task.IsCompleted ? $". Terminal process has exited with exit code {processExitedTcs.Task.GetAwaiter().GetResult()}." : string.Empty; + + var firstOutput = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstDataFound = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + var checkForFirstOutput = Task.Run(async () => + { + var buffer = new byte[4096]; + + var ansiRegex = new Regex( + @"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))"); + var output = string.Empty; + + while (!token.IsCancellationRequested) + { + int count = await terminal.ReaderStream.ReadAsync(buffer, 0, buffer.Length).WithCancellation(token); + if (count == 0) + { + break; + } + + firstOutput.TrySetResult(null); + + output += encoding.GetString(buffer, 0, count); + output = output.Replace("\r", string.Empty).Replace("\n", string.Empty); + output = ansiRegex.Replace(output, string.Empty); + var index = output.IndexOf(command); + if (index >= 0) + { + firstDataFound.TrySetResult(null); + return; + } + } + + firstOutput.TrySetCanceled(); + firstDataFound.TrySetCanceled(); + return; + }); + + byte[] commandBuffer = encoding.GetBytes(command); + + try + { + await firstOutput.Task; + Console.WriteLine("first output found"); + } + catch (OperationCanceledException exception) + { + throw new InvalidOperationException( + $"Could not get any output from terminal{GetTerminalExitCode()}", + exception); + } + + await terminal.WriterStream.WriteAsync(commandBuffer, 0, commandBuffer.Length, token); + await terminal.WriterStream.FlushAsync(); + + await firstDataFound.Task.WithCancellation(token); + + await terminal.WriterStream.WriteAsync(new byte[] { 0x0D }, 0, 1, token); + await terminal.WriterStream.FlushAsync(); + + await checkForFirstOutput; + } + public static async Task FindOutput(Stream terminalReadStream, string search, CancellationToken token = default) { var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); var buffer = new byte[4096]; + var ansiRegex = new Regex( - @"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))"); + @"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))"); var searchRegex = new Regex(search); var output = string.Empty; @@ -59,7 +130,6 @@ public static async Task FindOutput(Stream terminalReadStream, string sear output += encoding.GetString(buffer, 0, count); output = output.Replace("\r", string.Empty).Replace("\n", string.Empty); output = ansiRegex.Replace(output, string.Empty); - if (searchRegex.IsMatch(output)) { return true; @@ -69,7 +139,96 @@ public static async Task FindOutput(Stream terminalReadStream, string sear return false; } - private static async Task WithCancellation(this Task task, CancellationToken cancellationToken) + public static async Task RunAndFind(IPtyConnection terminal, string command, string search, CancellationToken token = default) + { + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var processExitedTcs = new TaskCompletionSource(); + terminal.ProcessExited += (sender, e) => processExitedTcs.TrySetResult((uint)terminal.ExitCode); + + string GetTerminalExitCode() => + processExitedTcs.Task.IsCompleted ? $". Terminal process has exited with exit code {processExitedTcs.Task.GetAwaiter().GetResult()}." : string.Empty; + + var firstOutput = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstDataFound = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output = string.Empty; + var regexOffset = 0; + + var checkTerminalOutputAsync = Task.Run(async () => + { + var buffer = new byte[4096]; + var ansiRegex = new Regex( + @"[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))"); + var searchRegex = new Regex(search); + while (!token.IsCancellationRequested && !processExitedTcs.Task.IsCompleted) + { + int count = await terminal.ReaderStream.ReadAsync(buffer, 0, buffer.Length).WithCancellation(token); + if (count == 0) + { + break; + } + + firstOutput.TrySetResult(null); + + output += encoding.GetString(buffer, 0, count); + output = output.Replace("\r", string.Empty).Replace("\n", string.Empty); + output = ansiRegex.Replace(output, string.Empty); + + Console.WriteLine($"output: {output}"); + var index = output.IndexOf(command); + if (index >= 0) + { + regexOffset = index + command.Length; + firstDataFound.TrySetResult(null); + if (index <= output.Length - (2 * search.Length) + && output.IndexOf(search, index + search.Length) >= 0) + { + return search; + } + else if (searchRegex.IsMatch(output, regexOffset)) + { + return searchRegex.Match(output, regexOffset).ToString(); + } + } + } + + firstOutput.TrySetCanceled(); + firstDataFound.TrySetCanceled(); + return null; + }); + + try + { + await firstOutput.Task; + } + catch (OperationCanceledException exception) + { + throw new InvalidOperationException( + $"Could not get any output from terminal{GetTerminalExitCode()}", + exception); + } + + try + { + byte[] commandBuffer = encoding.GetBytes(command); + await terminal.WriterStream.WriteAsync(commandBuffer, 0, commandBuffer.Length, token); + await terminal.WriterStream.FlushAsync(); + + await firstDataFound.Task; + + await terminal.WriterStream.WriteAsync(new byte[] { 0x0D }, 0, 1, token); // Enter + await terminal.WriterStream.FlushAsync(); + + return await checkTerminalOutputAsync; + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Could not get expected data from terminal.{GetTerminalExitCode()} Actual terminal output:\n{output}", + exception); + } + } + + public static async Task WithCancellation(this Task task, CancellationToken cancellationToken) { var tcs = new TaskCompletionSource(); using (cancellationToken.Register(s => ((TaskCompletionSource)s).TrySetResult(true), tcs)) diff --git a/src/Pty.Net/Linux/NativeMethods.cs b/src/Pty.Net/Linux/NativeMethods.cs index 086f4f9..525d96d 100644 --- a/src/Pty.Net/Linux/NativeMethods.cs +++ b/src/Pty.Net/Linux/NativeMethods.cs @@ -83,6 +83,17 @@ public enum TermSpecialControlCharacter VTIME = 5, } + public enum FcntlOperation + { + F_GETFL = 3, + F_SETFL = 4, + } + + public enum FcntlFlags + { + O_NONBLOCK = 0x0004, + } + // int cfsetispeed(struct termios *, speed_t); [DllImport(LibSystem)] internal static extern int cfsetispeed(ref Termios termios, IntPtr speed); @@ -138,6 +149,9 @@ internal static void execvpe(string file, string?[] args, IDictionary protected override bool Kill(int master) { - return kill(this.Pid, SIGHUP) != -1; + var status = kill(this.Pid, SIGHUP) != -1; + + if (!status) + { + Console.WriteLine($"failed to kill process {this.Pid}"); + } + + return status; } /// protected override bool Resize(int fd, int cols, int rows) { - var size = new WinSize((ushort)rows, (ushort)cols); + var size = new WinSize(rows, cols); return ioctl(fd, TIOCSWINSZ, ref size) != -1; } diff --git a/src/Pty.Net/Mac/NativeMethods.cs b/src/Pty.Net/Mac/NativeMethods.cs index a3353e0..b7115ce 100644 --- a/src/Pty.Net/Mac/NativeMethods.cs +++ b/src/Pty.Net/Mac/NativeMethods.cs @@ -253,10 +253,10 @@ public struct WinSize public ushort XPixel; public ushort YPixel; - public WinSize(ushort rows, ushort cols) + public WinSize(int rows, int cols) { - this.Rows = rows; - this.Cols = cols; + this.Rows = checked((ushort)rows); + this.Cols = checked((ushort)cols); this.XPixel = 0; this.YPixel = 0; } diff --git a/src/Pty.Net/Mac/PtyConnection.cs b/src/Pty.Net/Mac/PtyConnection.cs index ded1601..22ee188 100644 --- a/src/Pty.Net/Mac/PtyConnection.cs +++ b/src/Pty.Net/Mac/PtyConnection.cs @@ -30,7 +30,7 @@ protected override bool Kill(int fd) /// protected override bool Resize(int fd, int cols, int rows) { - var size = new WinSize((ushort)rows, (ushort)cols); + var size = new WinSize(rows, cols); return ioctl(fd, TIOCSWINSZ, ref size) != -1; } diff --git a/src/Pty.Net/Unix/PtyConnection.cs b/src/Pty.Net/Unix/PtyConnection.cs index 6e1b8c1..cfa3700 100644 --- a/src/Pty.Net/Unix/PtyConnection.cs +++ b/src/Pty.Net/Unix/PtyConnection.cs @@ -65,7 +65,14 @@ public void Dispose() { this.ReaderStream?.Dispose(); this.WriterStream?.Dispose(); - this.Kill(); + + try + { + this.Kill(); + } + catch + { + } } /// @@ -80,6 +87,16 @@ public void Kill() /// public void Resize(int cols, int rows) { + if (cols < 0) + { + throw new ArgumentOutOfRangeException(nameof(cols)); + } + + if (rows < 0) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + if (!this.Resize(this.master, cols, rows)) { throw new InvalidOperationException($"Resizing terminal failed with error {Marshal.GetLastWin32Error()}"); @@ -144,10 +161,10 @@ private void ChildWatcherThreadProc() return; } - Console.WriteLine($"Wait succeeded"); this.exitSignal = status & SignalMask; this.exitCode = this.exitSignal == 0 ? (status >> 8) & ExitCodeMask : 0; this.terminalProcessTerminatedEvent.Set(); + Console.WriteLine($"Wait on {this.pid} succeeded with signal {this.exitSignal}, code: {this.exitCode}"); this.ProcessExited?.Invoke(this, new PtyExitedEventArgs(this.exitCode)); } } diff --git a/src/Pty.Net/Unix/PtyStream.cs b/src/Pty.Net/Unix/PtyStream.cs index 3a0d6f2..ffbfb44 100644 --- a/src/Pty.Net/Unix/PtyStream.cs +++ b/src/Pty.Net/Unix/PtyStream.cs @@ -18,7 +18,7 @@ internal sealed class PtyStream : FileStream /// The fd to connect the stream to. /// The access permissions to set on the fd. public PtyStream(int fd, FileAccess fileAccess) - : base(new SafeFileHandle((IntPtr)fd, ownsHandle: false), fileAccess, bufferSize: 1024, isAsync: false) + : base(new SafeFileHandle((IntPtr)fd, ownsHandle: false), fileAccess, bufferSize: 1024, isAsync: true) { } From 5a1fe183a998aab685962facabe64c50aac8315e Mon Sep 17 00:00:00 2001 From: Zoey Riordan Date: Wed, 29 Jul 2020 12:41:26 -0700 Subject: [PATCH 3/4] remove skips --- src/Pty.Net.Tests/ExitTests.cs | 6 +++--- src/Pty.Net.Tests/PtyTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Pty.Net.Tests/ExitTests.cs b/src/Pty.Net.Tests/ExitTests.cs index e693316..a762e5e 100644 --- a/src/Pty.Net.Tests/ExitTests.cs +++ b/src/Pty.Net.Tests/ExitTests.cs @@ -13,7 +13,7 @@ namespace Pty.Net.Tests public class ExitTests { - [Fact(Skip = "Diagnosing issues on mac/linux")] + [Fact] public async Task SuccessfulExitTest() { var completionSource = new TaskCompletionSource(); @@ -32,7 +32,7 @@ public async Task SuccessfulExitTest() Assert.Equal(0, terminal.ExitCode); } - [Fact(Skip = "Diagnosing issues on mac/linux")] + [Fact] public async Task UnsuccessfulExitTest() { var completionSource = new TaskCompletionSource(); @@ -51,7 +51,7 @@ public async Task UnsuccessfulExitTest() Assert.Equal(1, terminal.ExitCode); } - [Fact(Skip = "Diagnosing issues on mac/linux")] + [Fact] public async Task ForceKillTest() { var completionSource = new TaskCompletionSource(); diff --git a/src/Pty.Net.Tests/PtyTests.cs b/src/Pty.Net.Tests/PtyTests.cs index a5ac7b7..f4d8334 100644 --- a/src/Pty.Net.Tests/PtyTests.cs +++ b/src/Pty.Net.Tests/PtyTests.cs @@ -16,7 +16,7 @@ namespace Pty.Net.Tests public class PtyTests { - [Fact(Skip = "Diagnosing issues on mac/linux")] + [Fact] public async Task ConnectToTerminal() { const string Data = "abc✓ЖЖЖ①Ⅻㄨㄩ 啊阿鼾齄丂丄狚狛狜狝﨨﨩ˊˋ˙– ⿻〇㐀㐁䶴䶵"; From e10df376843a48ffacc0b460f2fd6d9e91984d64 Mon Sep 17 00:00:00 2001 From: Zoey Riordan Date: Wed, 29 Jul 2020 12:49:56 -0700 Subject: [PATCH 4/4] fix build --- src/Pty.Net.Tests/Properties/AssemblyInfo.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Pty.Net.Tests/Properties/AssemblyInfo.cs b/src/Pty.Net.Tests/Properties/AssemblyInfo.cs index 144dfa9..aeeb572 100644 --- a/src/Pty.Net.Tests/Properties/AssemblyInfo.cs +++ b/src/Pty.Net.Tests/Properties/AssemblyInfo.cs @@ -1,4 +1,7 @@ -using System; +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; using System.Collections.Generic; using System.Text; using Xunit;